После появления статей типа "Я не знаю ООП" — возникает желание внести ясность, «сорвать покровы» и «докопаться до истины».
Принципы объектно-ориентированности
Обычно выделяют (читай: на собеседовании требуют назвать) четыре «принципа объектно-ориентированного программирования»: абстракцию, инкапсуляцию, наследование и полиморфизм.
На мой взгляд (не говоря о том, что абстракция и полиморфизм могут быть запросто отнесены к подразделам наследования), принцип тут один, в общем, тот же самый, что при проектировании баз данных: представление всего в виде объекта — некоторой штуковины со свойствами. Набор обычно бывает фиксированным, и тогда говорят о классе объектов, а даже если понятия класса и нет, то наличие свойств с определёнными названиями подразумевается логикой программы, т.е. нечто типа класса в виде некоего минимального набора свойств всё равно присутствует. В общем, воззрения восходят к давнему С-шному/паскалевскому типу данных struct/record. Потом к этому добавили немного «функциональности» (в смысле функционального программирования): значением свойства может быть функция, причём такая, которая имеет доступ к самой структуре/записи, значением одного из свойств которой она является. Сей феномен, в лучших традициях немецкого латиноязычного нейминга (когда опция называется «вариантом», а степень числа — «потенцией»), назвали «методом». Желание повторно использовать код, в сочетании с представлением каждого предмета как некоего подобия паскалевской «записи», привело к появлению концепции «наследования».
С появлением «методов» всплыл ещё один приятный момент в части организации функционала: вместо создания 900 функций с перекрёстно дублирующимися первыми и вторыми половинами названий (помните WordBasic?) можно сделать 30 классов с 30 методами в каждом.
Также, в стало возможным сделать языки со строгой типизацией, что дало возможность перенести время обнаружения многих ошибок с фазы выполнения программы на на фазу её компиляции. А в случае со строгой типизацией иногда стало необходимо делать классы, в которых метод объявлен при том, что реализован он только в потомках. Так появилось понятие чисто виртуальных методов и абстрактных классов.
А инкапсуляция, наследование, абстракция и полиморфизм являются не «принципами», а, скорее, «гайдлайнами» объектно-ориентированного программирования. Инкапсуляция, в частности, делает возможным проксирование. Заметим, гайдлайнами именно и только самого процесса «программирования», а не «дизайна» или «подхода», что часто может ввести в заблуждение.
Критика. Наследование. Рождение интерфейсов
С развитием концепции наследования хочется иметь возможность того, чтоб объект принадлежал сразу к нескольким классам, не являющимися по отношению друг к другу родителем и потомком. Это можно сделать, введя возможность множественного наследования. Но, как выяснилось, настоящее множественное наследование обычно порождает проблему «ромбического наследования»: при наследовании от двух потомков одного класса непонятно как разруливать многократное наследование одних и тех же его членов — полей, свойств и методов.
Проблема в значительной мере разрешилась изобретением «интерфейсов» — полностью абстрактных классов, в которых в качестве членов разрешались только чисто виртуальные, т.е. абстрактные, методы. «Места для вставки статических реализаций» (а в общем-то любой метод класса в строго типизированном языке, если его рассматривать как значение чего-то, является статическим: это «значение» — определение метода — одинаково для всех экземпляров класса) — единственное, что разрешалось в этих методах — не могло, по определению, вызвать проблем при «ромбическом наследовании». Тем более, с распространением инкапсуляции, когда «наружу торчат» только методы, это не воспринимается как нечто экстраординарное.
Ещё интерфейс хорош тем, что почти реализует функциональную парадигму для не-функционального языка, позволяя фактически передавать набор алгоритмов как аргумент.
Критика. Почему так мало «настоящих» классов. Multiple dispatching. Рождение «менеджеров» и отделение их от Domain objects. Синглетоны
Считается, что класс — это набор свойств плюс «поведение». Вспоминаю прочитанные в 1998 году слова из хелпа к VisualBasic 5, что-то вроде: «Method is what you tell object to do. Event is what object tells you is done upon it». Так что насчёт «поведения» тут ещё вопрос – метод или ещё что-то.
Проблемы дизайна начинаются, когда «поведение» включает два и более класса. Как тут в комментарии — «Рост, вес, возраст — свойства. Ходить, сп(р)ать — поведение.» Вот с случае моделирования поведения с буквой «р» возникают ещё Чем и Куда (да и «ходить» тоже — есть ещё и Куда). В общем, когда в действии участвуют несколько непримитивных классов, при разоработки объектной модели возникает вопрос — и к какому же из двух, трёх, четырёх и т.д. классов — в данном случае «кто», «чем» или «куда» — должен принадлежать метод, моделирующий это действие? И в любом случае это, скорее всего, будет tight coupling и в процессе дальнейших наращиваний системы (а постоянное дописывание, расширение и улучшение должно рассматриваться как штатная, а не исключительная ситуация) «вылезет боком».
Эта проблем решается выделением специальной сущности — «менеджера». В результате у объекта типа «банковский счёт» не остаётся метода перевести_на(счёт, сумма), а вместо этого у объекта типа «менеджер счетов» появляется метод перевести_с_на(с_счёт, на_счёт, сумма).
У собственно классов, которые не «менеджеры», а отражают объекты реального мира — domain object classes, при продолжении такой тенденции вообще не остаётся методов кроме тех, что требуются для обеспечения принципа инкапсуляции — самых примитивных акцессоров и мутаторов. И что характерно, в той объектной системе, в которой проблема multiple dispatching решена сразу и радикально — в CLOS – методов как таковых нет, а есть специальные одноимённые функции с разными типами аргументов, которые принадлежат всей системе.
Что интересно, в общем случае о типе «менеджер счетов» тут не надо знать ничего кроме того, что у него есть метод перевести_с_на(с_счёт, на_счёт, сумма). Т.е. это — типичный интерфейс с реализацией. И если потребуется поменять механизм обеспечения целостности данных (с транзакций на ручные блокировки или наоборот) — то не потребуется переписывать код типа «счёт», переписывать вообще ничего не потребуется (и перетестировать что-то старое, соответственно, тоже не потребуется), потребуется только написать новую реализацию интерфейса.
Там же, где такие методы решили оставить в одном из участников, приходится использовать уродство типа «шаблона проектирования» Visitor – когда реально функциональность вынесена «наружу», а «изнутри» её дергают, как если бы она оставалась «внутри».
Возникает вопрос — если в классе нет свойств, существенных с точки зрения объектной модели куска реальности, если он не моделирует ни одного физического объекта, то сколько его экземпляров должно существовать? Не хватит ли вообще одного? В общем, чем экземпляров меньше, тем лучше, и количество определяется техническими причинами — например, по одному на коннекцию из пула. В особо вырожденном случае достаточно одного экземпляра, тем более если его создание довольно ресурсоёмко. Это и называется шаблон проектирования «синглетон» — «одиночка». Класс пишется так, что существует его единственный экземпляр, получаемый через статический метод.
В сегодняшнюю эпоху, когда вопрос инстанциирования сервисных классов отдаётся на откуп разным инжекторам зависимостей, реализация паттерна «синглетон», как пример жёсткого ручного управления инстанциированием, является чем-то вроде разновидности tight coupling и, поэтому, нарушением принципа проектирования и очень не-комильфо.
Критика. «Хочу переопределяемый конструктор». Фабрики
Использование конструкторов со сложным поведением, как и интенсивное использование наследования, является, в общем, примером tight coupling (полей и интерфейса, хе-хе) и, в связи с этим, моветоном. При вызове конструктора класса-потомка вначале вызовутся конструкторы всех классов-предков, что, в общем, нередко не надо и вообще ресурсоёмко и просто вредно. Лучше логику инстанциирования класса сосредоточить в отдельном сервисном классе, загнав в него заодно и всю «предпродажную подготовку». Сервисный класс можно оформить как интерфейс и так далее со всеми уже описанными остановками. Получается «фабрика».
У фабрики есть ещё пара плюсов: 1) может вернуть null и 2) может возвращать один и тот же объект много раз.
Критика. Примитивы и прочие Value objects. Иммутабельность. Интернизация.
Принцип «всё есть объект», конечно, привлекает своей последовательностью. Хотя изначально предполагается, что значения свойств объектов сами не являются объектами. И, вообще, у системы классов должны быть в конце концов выходы в «реал», а «реал» — это всегда данные одного из FoxPro-шных типов: число, строка, дата-время или да/нет. Поэтому, если не требуется чрезмерная гибкость, то лучше не делать всё объектами и оставить некоторые не-объектные типы данных.
Особо важный смысл эта идея приобретает в особо высокоуровневых языках — языках с автоматической сборкой мусора, с которых объекты представлены исключительно ссылками на них. Для ускорения быстродействия не все типы данных стоит делать ссылочными.
C примитивами по своему замыслу граничат Value objects. Это объекты без специальных методов, имеющие набор свойств со значениями, примитивными или тоже Value objects, которые проверяются на равенство исходя не из физической одинаковости (одинаковости ссылок на них), а из значений свойств.
Нередко (даже, скорее, как правило) Value objects делают иммутабельными, т.е. неизменяемыми. Иммутабельность позволяет безбоязненно использовать сложные value objects в качестве ключей и вообще даёт кучу дополнительных «плюшек», в частности, иммутабельный квадрат может без какой-либо «задней» логики быль наследником иммутабельного прямоугольника, чего нельзя сказать об их мутабельных версиях.
Рядом с концепцией иммутабельности идёт концепция интернизации — чтобы все равные (в смысле значений свойств) объекты определённого класса были равны и физически, т.е. были одним и тем же объектом. Это обеспечивается хранением всех когда-либо использованных экземпляров данного класса в статическом или по-иному «сиглетонном» множестве (Set), и, в идеале, получением нового только через «фабрику», «пропускающую» всю «продукцию» через проверку на уникальность и выдающую только уникальные экземпляры. На фазе создания это сильно тормозит, но использование потом ускоряется многократно.
Классы IRL. Критика. «Некоторые классы равнее других»
Близко к вершине пирамиды классов «равноправие» и равноценность классов нарушается. Особенно когда хочется «обуть в объектно-ориентированность» саму среду выполнения.
В языке Java корневым классом для всех классов является класс Object. Но у класса Object есть метод getClass(), возвращающий объект типа Class, и метод toString(), возвращающий объект типа String. Также имеет методы, бросающие CloneNotSupportedException, InterruptedException, IllegalArgumentException и Throwable. При этом классы Class, Throwable и String являются прямыми потомками класса Object, а CloneNotSupportedException, InterruptedException и IllegalArgumentException — даже более чем непрямыми. В общем-то, класс, имеющий в половине методов, ссылки на его заведомых потомков, нередко непрямых — это bad design (как и всякие циклические зависимости), но «если нельзя, но очень нужно, то один раз можно». Да и корневой класс для исключений и ошибок — Throwable — тоже содержит в себе ссылки на своих непрямых потомков: IllegalStateException и IllegalArgumentException, так что, опять-таки, в ограниченной дозировке «не догма».
Хотя, классы String и Class — финальные, т.е. не могут иметь потомков, что снижает «криминальность» их использования в классе Object.
Классы IRL. Чем они стали в конце концов
В языке Java классы превратились в значительной степени в некие метки на объектах. В ситуации интенсивного использования перегрузки методов они превратились, тоже в значительной степени, в часть имени метода. Строгая типизация со временем сыграла неожиданную положительную роль: язык Java оказался наиболее пригодным для рефакторинга.
Наследование, как и прицепливание алгоритмов к классу domain object’а, признано tight coupling и bad practice, и применяется строго дозированно. В частности, предпочтительным способом реализации сущности «социальный аккаунт» для программы, работающей с разными социальными сетями, является не создание абстрактного класса SocialAccount с потомками FBAccount, VKAccount и TwitterAccount, а создание нормального domain class’а SocialAccount и фабрик-конвертеров в него из данных от разных социальных сетей, может быть даже JSON-to-SocialAccount-конвертеров. Напоминает ORM? Так ещё лет 10 назад всё было обратно: были распространены DB-persistence-платформы, в которых логика чтения/записи БД находилась в методах самих персистируемых классов, из-за чего они должны были наследоваться от специального класса типа какого-нибудь DBExternalizableObject. В последствии, «как известно», от этого отказались.
Современные domain classes, кроме того, на деле почти всегда являются «прослойкой» к записям в таблицах баз дынных и проектируются изначально в СУБД или в средстве проектирования СУБД.
А «нормальные», «классические» классы со свойствами, методами и наследованием встречаются в основном в серверах приложений и прочих библиотеках.
Автор: Flammar