Има нещо некомфортно в тезата „всичко е обект“. Ето едно любопитно разсъждение, на което попаднах наскоро (в Clean Code, която препоръчвам):
Обектите скриват данните си зад абстракции и предлагат функции, които работят с тях. Структурите предлагат директен достъп до данните и нямат смислени функции. На практика са противоположности.
Изтъркан пример в Ruby:
def initialize(center, radius)
@center = center
@radius = radius
end
def area
Math::PI * radius * radius
end
end
class Square
def initialize(top_left, side)
@top_left = top_left
@side = side
end
def area
side * side
end
end
Клиентският код изглежда така:
Square.new(Point.new(0.0, 0.0), 2.0).area
Всяка фигура отговаря за собствените си операции. Обектно-ориентирано програмиране по учебник.
Ето и структурния подход:
attr_accessor :center, :radius
def initialize(center, radius)
@center = center
@radius = radius
end
end
class Square
attr_accessor :top_left, :side
def initialize(top_left, side)
@top_left = top_left
@side = side
end
end
module Geometry
def self.area(shape)
case shape
when Circle then Math::PI * shape.radius * shape.radius
when Square then shape.side * shape.side
end
end
end
…и клиентския код:
Geometry.area(Square.new(Point.new(0.0, 0.0), 2.0))
Сега, сигурно искате да ме замерите с Refactoring и да престанете да четете. Но дайте шанс на процедурния код за момент. В някои случаи има интересни предимства.
Например, какво става ако искате да добавите нова фигура? В обектния подход е лесно: създавате нов клас и имплементирате операциите. В структурния е по-трудно – освен новия клас, трябва да промените всяка операция в Geometry
. Очевидно тук обектите са по-подходящи.
Но ако искате да добавите нова опреция? В обектния подход трябва да промените всяка една от фигурите. В структурния е лесно – добавяте нов метод в Geometry
. Тук структурите са по-подходящи.
Накратко
ОО кодът ни позволява да добавяме нови типове, без да променяме съществуващите операции. За сметка на това, когато добавяме нова операция се налага да променим всички съществуващи типове. Процедурният код ни позволява да добавяме нови операции, без да променяме съществуващите типове. За сметка на това, когато добавяме нов тип се налага да променим всички съществуващи операции.
Прочетете го пак.
Заигравка с Ruby
Има нещо много досадно в процедурния подход – трябва да пишете Geometry
навсякъде. Ruby ви позволява избегнете това:
def method_missing(message, *args, &block)
if Geometry.singleton_methods.include?(message.to_s)
Geometry.send(message, self, *args, &block)
else
super
end
end
end
class Circle
include Geometry
end
class Square
include Geometry
end
Така клиентския става:
Square.new(Point.new(0.0, 0.0), 2.0).area
От друга страна, част методите на Square
са имплементирани в друг модул (Geometry
) и то по крехък начин. Не съм сигурен, че допълнителната сложност си заслужава.
Visitor
Ако приложите visitor design pattern (или какъво и да е double-dispatch) в ОО подхода, може да извъртите нещата така, че добавянето на нови операции да не предизвиква промяна във всички типове. Гъвкавостта на Ruby ви позволява да направите това с малко код. От друга страна, това (1) прави кода доста по-сложен и (2) го кара да изглежда по-процедурен. Рядко попадам на случаи, в които е оправдано.
Една генерализация
Това ръзсъждение може се генерализира лесно. Въпросът опира до това дали да държите операците заедно с типовете или отделно от тях. В обектно-ориентирания подход те са заедно, докато в процедурния – отделно. Така може да приложите тази логика за Haskell или LISP.
Обобщение
Във всяка достатъчно сложна система се срещат и двата случая. На места обектите са по-подходящи, на други – структурите. Не всичко е обект – понякога имате нужда от прости структури и процедури, които боравят с тях.
От друга страна, много неща са обекти.
Здрасти 🙂 Позволи ми да изкажа алтернативна хипотеза по темата. Т.е. съгласен съм с твоето мнение относно разликата между структури/класове в Руби, но мисля че в C++(и по-важно – в C) причината на някои места да се ползват структури е различна.
Скорост и Памет. Структурите ползват по-малко от тях в сравнение с обектите. На цената на по-сложен код. Като се замислиш, обектите просто връзват всичките данни с някакви методи, нищо повече. Е, все някъде трябва да пише кои са тези методи, т.е. трябва да се подават някакви указатели в някакви стекове.
Това е лошо. Т.е. лошо е в случая на(примерно) математически код. Всякакви геометрии служат за нещо като „Асемблер“ на всичко от по-високо ниво. Т.е. в някой момент ще искаш да намериш общото лице на 10000 правоъгълника, по 10 пъти в секунда. Ти НЕ ИСКАШ този код да е бавен. Буквално ако можеше да бъде написан(работещ) на Асемблер, щеше да е на Асемблер.
Да повторим: Структурите се ползват тогава, когато паметта ти е скъпа, искаш да оптимизираш за L2 кеш достъп и искаш всичко възможно да стане по време на компилация. Поради това тези Geometry.send неща са нечовешки бавни. Всъщност, дори индексирането на таблицата Geometry е бавно. Направо ти трябва глобална функция AreaCircle.
Няма да ти трябва полиморфизъм за тези неща. Никога. Освен това НЯМА да ти трябва да добавяш нови методи в модули с процедурен код(най-много един-два пъти ЕВЪР). Не е като да бъде открита утре нова математическа операция.
Ако евентуално ще ти трябва да променяш каквото и да е или спецификациите не са замразени, или скоростта не е от значение – ползвай обекти.
@Михаил:
Факт. Но това, на което исках да наблегна е чистия код, не бързодействието. Когато гониш performance съображенията винаги са други.
Демек ако се абстрахираш от циклите, разсъждението е изцяло валидно за C++ (и C). Забележи, че „струкутра“ (в моята „терминология“) може да значи и клас, който дава публичен достъп до член-променливите си (или дори ги обвива зад getter и setter). Като цяло, аргумента е за това кога операциите да бъдат пакетирани с типа (обект) и кога отделно (структура).
Здрасти Стефане,
От известно време и аз клоня към разните функционални езици, в които няма обекти (без да броим Скала), а само структури и се замислих как би се решил проблема като искаш да добавиш нова структура или опрация.
Както ти каза – добавянето на операция във функционалните езици е лесно – няма какво да спорим :).
Добавянето на нова структура:
Стефан: „За сметка на това, когато добавяме нов тип се налага да променим всички съществуващи операции“.
Вариантите са два:
2.1 Съществуващите структури са твой код – просто добавяш функции, които да обработват новата структура. Във функционалните езици има читав петърн мачинг, който обикновенно се прави на аргументите на функциите, а не вътре в тях – така че случаят не е много по-различен от ОО.
2.2. Съествуващите структури са чужди (идват от фреумуърк или друго място) – тогава метода също ми се струва лесен – добавяш си твоята структура и функциите за нея и пишеш обвивки около бибклиотеката, които са по 1 ред – не е много елегантно но е приемливо.
Примерчета (на Erlang, но се четат):
Да добавим квадрат:
Сега добавяме новата функция в случай 2.1:
В случай 2.2 си пишем и една опаковка:
Започва все повече да ми харесва тоя функционален сок. 🙂
Специално за добавянето на нова функция си мисля, че по-добрия вариант не е да я добавяш към всеки клас поотделно, ами да си си дефинирал някакъв абстрактен, който обектите да онаследяват и който да предостави тази функция. Естествено това не е решение във всички случаи, а и самото добавяне на клас във веригата на онаследяване може така или иначе да доведе до промяна на всички класове, ползващи новата функция.
Като се абстрахирам от митовете за бързодействието на структури и класове, на мен лично идеята в примера ти не ми допада. Това вероятно е приложимо за малки програми, където няма много структури (или иначе казано типове/класове). С времето и добавянето на нови обекти, операциите които ще прилагаш върху тях, ще стават все по-големи и по-сложни, а от там по-трудни за поддръжка отколкото операциите на ниво клас. Аз лично съм по-близо до мисленето на Стефан Ковачев, защото ако ми се наложи да добавя нова операция, то ще го направя във възможно най-основния (абстрактен) клас от йерархията, така че само тези наследници на които наистина им е нужна да си я дефинират. Колко трудно е това? Във всяка добра йерархия основните (базови) класове са абстрактни.
Аз препрочитам да мисля, за класовете като затворени черни кутии, които знаят точно какво правят и го правят добре. Модула Geometry в примера по-скоро ми напомня на „майстор по всичко“.
БТВ Мисля, че по-точното заглавие в случая е „Структури vs Класове“, защото структурата както класа е тип, докато обектите са инстанции на типовете.
@Георги:
Разбира се, съгласен съм с идеята „добави операцията във възможно най-основния клас от йерархията“. Но лошото е, че тя е частен случай. Ако вземеш наивния пример с фигури, нямаш никакъв шанс да имплементираш
Впрочем, аз също предпочитам да мисля за обектите като затворени черни кутии. В крайна сметка, една от основните идеи на ООП-то е да скрива имплементационни детайли зад абстракция. Обаче всичко става на някаква цена – в случая, добавянето на нови операции върху хетерогенно множество от типове е по-скъпо.
И щях да се съглася с теб за името, ако всички бяхме C++ програмисти. Но в множеството от езици с които се занимаваме, понятията са доста по-размити. Какво е структура в Java или Python например? А каква е разликата между клас и обект в JavaScript?
@Стефан Кънев
Защо да не мога да направя
Не съм съгласен, че добавянето на нови операции в ООП е по-скъпо, защото не взимам в предвид само момента на писането. Може тогава да е по-скъпо, но в дългосрочен план ще бъде по-евтино, защото поддръжката на по-кратки и определени методите ще бъде по-лесна. Представи си, че някой след теб (дори самия ти) промени операция
Добре, хвана ме, че изхождам от C++ 🙂 Все пак си мисля, че темата е общо за ООП и терминологията трябва да не е обвързана с конкретен език
Имаш
(имам и добро разсъждение за останалата част от коментара ти, но първо искам да видя това :))
Съжалявам за забавянето, но трябва и да работя 🙂 Отговора на въпроса ти е, че просто няма да дефинирам
Paste link
Разширих примера с 3D фигури, за да имам разклонена йерархия. Kласа
Очаквам разсъжденията ти 🙂
Първо, през 2010 нямаш оправдание да пействаш огромно парче код във форма, вместо линк в pastie или github. Дори това, че още пишеш на C++ не оправдание 🙂 . Редактирах ти коментара и го направих по-човешко. Моля те не прави това никога повече никъде.
Второ, примерът ти ми показва единствено, че си противоречиш. По-горе казваш „Защо да не мога да направя area и perimeter в един базов клас!? … напълно приложимо е в примера ти.“. Сам демонстрираш, че не e – имплементираш
(Разбира се, вселенски закони едва ли ще се променят скоро и демек няма да ти се наложи да промениш семантиката на „лице на фигура“. Но когато имаш проблемна област с термини от реалния свят, такива промени са ежедневие.)
Като цяло имам чувството, че не вникваш в това, за което говоря. Не съм сигурен къде точно текстът ми се проваля в комуникацията, обаче 🙂
И понеже си ми дал толкова много код, чувствам се длъжен да го коментирам:
В 110 реда си успял да сложиш ред неща, които са ужасяващо лоши практики, в ООП-то или в програмирането като цяло. Ето ти част от тях:
Щях още да кажа, че (1) избягвам дълбоките йерархии и (2) когато само малка част от наследниците ми предефинират метод на родителя, третирам това като симптом, че един класът прави твърде много. В последното обикновено успявам да извлеча нов клас и да делегирам натам.
Тъй, разбирам какво иска да каже Стефан, виждам идеята му, но не разбирам защо мисли така. Хм, малко оксиморон се получи май? Обяснение:
Той разделя проблема на 2 области: Добавяне на нови класове, и добавяне на нова функционалност към съществуващи класове. В последния коментар добави и още една област, промяна на семантичния смисъл на съществуваща функционалност, за което не говори в статията, и мисля е съществено различен проблем, но ще обърна внимание и на него накрая.
Какво печелим и губим във всеки от трите случая при процедурен и ооп код?
При ООП подхода, онаследяването ни дава всичката обща функционалност за новия клас, и също ни дава хинтове какво специфично трябва да имплементираме. Което са само хубави работи
При процедурния, за да постигнем такава структура ни е необходим catch-all default в switch statement-ите, което е достатъчно тривиално при плитките йерархии, но става доста тежко при по-дълбоките. Лошо. Но пък имаме същото (или почти същото) ниво на подсказки какво точно да имплементираме – ако имаме default клауза, която хвърля изключение NotYetImplementedException ще хванем какво сме пропуснали при първия unit test. При ООП подхода бихме получили същото (абстрактия базов клас реализира по този начин тази функционалността. Освен ако функцията не е pure virtual, или не го онаследяваме от interface, когато ще получим грешката още от компилатора, но смея да кажа, че двете са доста еквивалентни)
ООП: Добавяме нов метод в базов клас, реализираме го като pure virtual (или съответния аналог от друг език, или го правим да хвърля NotYetImplementedException). Отваряме клас дървото, за всеки дъщерен клас имплементираме функционалността. Не е най-лесното и просто нещо на света, но дали процедурния подход е по-лесен?
Процедурно: Добавяме нов метод, switch за всеки клас. Дефаулт клауза, която хвърля въпросното изключение, отваряме клас дървото, за всеки relevant клас имплементираме функционалността. Някак си, струват ми се доста еднакви – дали ще е ООП или процедурно, стъпките които предприемаме са до голяма степен еднакви. Но ако функционалността е малко по-сложна, метода при процедурния подход ще стане 100 и кусур реда, което рядко е хубаво нещо.
Според мен, двата подхода са горе-долу еквивалентни
ООП: Тук вече, ООП sux. При този проблем, трябва да обходим всеки клас, който бива impact-нат. Тежка и error prone операция, защото това рядко са всички класове, и често името на класа не е достатъчно да преценим дали там има нужда от промяна. Трябва да сканираме всички методи, за да вземем решение. Лошо.
Процедурно: Тук всичко е лесно. Имаме един метод, с един поглед се вижда кой от случайте има нужда от промяна. Процедурния подход определено има голямо предимство.
В заключение, мисля, че това което трябва да определя дали да реализираме нещо процедурно или обектно ориентирано е нуждата от дълбоки йерархии, и евентуалната нужда от семантични промени. Лошото е, че често двете рядко са взаимно изключващи се, и е трудно да се прецени без голяма доза опит в проблемната област.
@Георги
Много хубав коментар! Мерси за което.
Малка бележка: не разделям на „добавяне на нови класове“ и „добавяне на функционалност към същестуващи класове“. Разделям на „добавяне на типове“ и „добавяне на операции“. Откривам, че тези термини правят мисленето ми по тази тема много по-чисто. Иначе:
Засягаш две неща: (1) разликата при добавянето на нова функционалност и (2) разликата между промяна и добавяне. В този ред:
Разликата при добавяне на нова функционалност. Повторил си каквото аз съм казал, само дето си го облякъл в повече подробности. Съгласен съм със всичко, освен последния ред. Не са горе долу еквиваленти. Въпроса не е в това каква логика ще добавиш, а каква е козехията ѝ. В „лошия“ случай логиката е разхвърляна на различни места, докато в добрия е на едно. Когато за да добавиш една логична единица се налага да пишеш на няколко различни места, отдалечени помежду си, това се нарича Shotgun Surgery и е Code Smell. Принципната разлика е, че при обекти получаваш shotgun surgery като добавяш операция, докато при структури – като добавяш тип. Това е причината да не са „горе-долу еквивалентни“ и точно това обяснявам в поста.
Разбира се, ако имаш метод 100 реда, това също е лошо и трябва да се избягва. Ако процедурния подход резултира в това, значи имаш да рефакторираш. Впрочем, най-вероятно решението ще е някакъв Visitor, а не да минеш към обекти – ако си избрал структури на първо място си го направил защото имаш много операции и ги добавяш често. Класове с по 1000 реда също не са добро нещо.
Промяна на семантичния смисъл на операция. За мен двете неща са „горе-долу еквиваленти“. Погледни другия вариант – променяш семантиката (или дори имплементацията) на типа. Първоначално си го направил лошо, открил си че не отговаря на реалния свят и сега искаш да отразиш новото познание в кода. Тогава лошия вариант ще е при структурите – там ще трябва да минеш през всяка операция и да прегледаш нейния switch/case внимателно. За това казвам, че са еквивалентни – shotgun surgery-то е на същите места, както и когато добавяш.
И една друга бележка:
Дълбоки йерархии. За мен това почти винаги е code smell. Ако имаш дълбока йерархия и се налага да правиш промени по нея, вероятно използваш наследяването лошо (погледни по-горния линк към pastie за пример). Всяка дълбока йерархия която съм виждал е ставала далеч по-добра, когато голяма част от наследяванията са били превръщани в делегация. Дори и GUI. А ако все пак дълбоката йерархия е добро решение (ще ми е интересно да видя пример за добра дълбока йерархия), тогава този въпрос въобще не е налице. 🙂
Терминологията. Колкото повече мисля, толкова повече се уверявам, че „ООП“ и „Процедурно програмиране“ са лоши термини за нещата в тая дискусия. Вместо това предпочитам да си мисля за обекти, които скриват данните си зад операции (което наричам „обекти“) и други обекти, които са далеч по-глупави и предлагат данните си публично (които наричам структури).
Първоначално тръгнах да пиша доста по-дълъг коментар, но колкото повече се замислих, стигнах до извода, че общо взето говорим за едни и същи неща. Ако разбирам правилно мисълта ти, това за което говориш е добавяне на логична единица, която се държи много сходно, но над хетерогенни данни? Ако съм прав, то тогава съм съгласен с теб – ползването на структури (по твоята дефиниция, не C/C++ структури) е многократно по-добро решение от използването на множество интерфейси.
Намирам Visitor патърна и това което описваш са прекалено сходни, и няма да коментирам Visitor-а отделно – предполагам във всички случай, когато се колебаеш кой вариант(структура или обект) да използваш, има страни на данните ти, които е добре да се държат като обект и такива, за които е полезно да са структура. Тогава така или иначе ще реализираш някакъв визитор. Когато данните ти са очевидно структура, няма причина да го енкапсулираш като обект, и тогава ще ползваш варианта, който пряко си описал.
За семантичния смисъл: И ти си прав. Подходих към въпроса от гледна точка на вече съществуващи класове, които са се доказали до голяма степен. Но си напълно прав, че понякога рефактор на класовете ти е по-доброто решение за промяна на семантичния смисъл на операциите.
За дълбоките йерахии е относително – за един човек дълбока са 4 онаследявания, за друг са 20. Конкретно в примера ми имах предвид под дълбока между 3 и 5 наследника, което се случва сравнително често в определени области. Само като пример – контейнери за различни структури от данни.
Така че, съгласен съм с теб: Деца, не правете обекти, само защото са обекти, когато няма нужда от тях.
Обичам съгласието 🙂 . Два малки коментара.
@ Кънев # 10
Първо, избора на какво да пиша е мой и това, че не сменям езика всяка година едва ли е порок. Това, че не ползвам точно това което и ти (pastie или github) отново не е порок. Още повече Makefile е развален в pastie (липсват табулациите). Мислех да сложа кода някъде, но ми се стори маловажно къде точно… Между другото въобще не мисля, че си интересен като „обиждаш” хората, че не са „съвременни”… и на лекции правиш същата грешка.
Второ, не мисля, че си противореча по какъвто и да е начин. Това което съм изпълнил като пример е напълно нормално в ООП. Ако е замислена добре като абстрактна, операцията
Разговора не върви на добре, ако ще ме обвиняваш, че не съм вникнал в това което говориш. Имам пълното право да не съм разбрал, което за съжаление е само твоя интерпретация.
Ще коментирам кода си, за да е напълно ясно какви са (простите) идеите заложени в него:
Мненията ми предизвикват доста писане от твоя страна, само че това ни отдалечава от темата. Нека да припомня, че ставаше въпрос за това, че добавянето на нови операции в ООП било по-скъпо и изисквало промени на „всички съществуващи операции“. Къде виждаш скъпото в метода
Мерси за оценките ти, но в крайна сметка искам да ти кажа, че просто изразявам мнение. Най-малко имам желание да се заяждам с теб, нито пък да те обиждам. Ти не си привърженик на дълбоките йерархии (въпреки, че моята въобще не е дълбока), но пък аз не съм привърженик на събирането на кода на различни обекти в процедурния
Понеже виждам, че мненията по темата са се развили искам да допълня относно промяна на смисъла на операция. Пак не виждам предимството в процедурния подход, защото този един единствен метод ще бъде неимоверно голям ако имаме повече обекти и по-сложна операция. В крайна сметка ще се стигне до разбиването му на отделни функции, което обезсмисля реализирането му за сметка на OOP подхода, където така или иначе ще имаме отделни функции. Нека да припомня мотото „Разделяй и владей“.
Съгласен съм с мнението изразено от Георги Попов, че не е нужно да се правят класовете само защото са класове, но практиката е нещо много по-различно от това което пише и се дава като пример в книгите.
Йерархия от 3-ма наследници въобще, ама въобще не е дълбока.
@Георги Сотиров
Мисля, че не ме разбра. Не виждам причина да преразказвам поста си още веднъж. Предвид кода ти и коментарите на моите коментари, дори не мисля, че ще излезем на глава за това какво е ООП. Съответно, не виждам защо да продължавам тази дискусия.
Понеже се зачетох в спора, само да вметна, че периметър (или по общо – дължина) в двумерно пространство е повърхност в тримерното пространство.
И всъщност математически погледнато сфера не трябва да наследява окръжност. Сферата е 3Д окръжност , т.е. един и същ клас , дефиниран с точка , радиус и степен на свобода. RR = xx + y*y + ……. (необходимия брой измерения)
И всъщност никаква промяна на операции не е нужна – обем и площ (независимо от размерността) се изчислява с интеграл , повърхност и периметър (дължина) – също.
Всъщност геометрията изобщо не е подходяща за илюстриране на ООП , защото всички геометрични обекти с описват с f(x,y,….) , a свойствата им (дължина,повърхност ; площ,обем) са интеграл на f(x,y,…)
Ето и затова не обичам ООП , там където нещата могат да се опишат просто , любителите на ООП ще дефинират един куп класове и наследници 🙂