[Перевод] Прощай, Объектно-ориентированное программирование.
Уже десятилетия я программирую с на объектно-ориентированных языках программтрования. Первым объектно-ориентированным языком, который я использовал, был C++. После этого был Smalltalk и, в конце, .NET и Java.
Я фанатично использовал преимущество Наследования, Инкапсуляции и Полиморфизма. Три столпа Объектно-ориентированного программирования.
Я жаждал получить обещаное Переиспользование и использовать мудрость полученную теми, кто был до меня в этом новом удивительном мире.
Я не мог сдержать волнения при мысли, что можно сопоставить объекты реального мира с моими классами и ожидал, что всё встанет на свои места.
Ещё никогда я так не ошибался.
Наследование. Первый столп падёт.
На первый взгляд, Наследование кажется самым главным преимуществом ООП. Все упрощённые примеры с иерархией фигур выглядят логичными.
И Переиспользование - это слово дня. Нет… пусть будет словом года и даже больше.
Я повёлся на это и ринулся в мир с новыми знаниями.
Проблема банана, обезьяны, джунглей
С верой в сердце и задачами, требующими решения, я начал выстраивать иерархии классов и писать код. И всё в мире было отлично.
Я никогда не забуду тот день, когда был готов получить преимущества Переиспользования, унаследовавшись от существующего класса. Это был тот самый момент, которого я ждал.
Появился новый проект и я вспомнил про Класс, который я так любил на предыдущем проекте.
Без проблем. Переиспользование - маё спасение. Всё что мне требуется - всего лишь перенести класс из старого проекта в новый.
Ну… На самом деле… не только этот Класс. Нам потребуется ещё родительский класс. Но… И это ещё не всё.
Тьфу… Погодите… Похоже, нам ещё потребуется родительский класс родительского класса… И ещё… Нам потребуются ВСЕ родительские классы. Хорошо… Я справляюсь с этим. Не проблема.
Великолепно. Теперь это не компилируется. Почему?? Ох, вижу… Этот объект содержит другой объект. Таким образом мне требуется и другой класс. Не проблема.
Погодите… Мне нужен не только другой объект. Мне нужен ещё родитель другого класса и родительский класс родительского класса и так далее и так далее для каждого объекта который используется в используемом классе и ВСЕ родительские классы родителей, родители родителей и так далее.
Тьфу.
Вот великолепная цитата создателя Erlang Джо Армстронга:
Проблема объектно-ориентированных языков программирования в том, что у них есть неявные зависимости, котороые они тянут за собой. Вам необходим банан, но вместо этого вы получаете гориллу, держащую банан и все джунгли.
Решение проблемы банана, обезьяны, джунглей
Я решил эту проблему не создавая глубоких иерархий классов. Но если наследование - это ключ к Переиспользованию, то любые ограничения этой возможности ограничат возможности Переиспользования. Так?
Именно.
Так что же делать бедному ООП-программисту?
Включать и Делегировать. Больше об этом далее.
Ромбовидное наследование (Diamond problem)
Рано или поздно эта проблема покажет свою ужасную и, в зависимости от языка программирования, неразрешимую голову.
Многие объекто-ориентированные языки программирования не поддерживают эту возможность, даже не смотря на то, что она выглядит логично. В чём проблема поддержки этой фичи в объектно-ориентированном языке?
Окей, представьте следующий псевдокод:
Class PoweredDevice {
}
Class Scanner inherits from PoweredDevice {
function start() {
}
}
Class Printer inherits from PoweredDevice {
function start() {
}
}
Class Copier inherits from Scanner, Printer {
}
Заметьте, что оба класса Scanner
и Printer
реализуют метод start
.
Так какой метод start
класс Copier
наследует? Scanner
? Или Printer
? Не может быть, что оба одновременно.
Решение проблемы ромбовидного наследования
Решение простое - не делать подобного.
Да, это так. Многие ОО языки программирования не позволяют вам такого сделать.
Но, но… если я должен сделать подобное? Мне необходимо моё Переиспользование!
Тогда вам надо Включать и Делегировать:
Class PoweredDevice {
}
Class Scanner inherits from PoweredDevice {
function start() {
}
}
Class Printer inherits from PoweredDevice {
function start() {
}
}
Class Copier {
Scanner scanner
Printer printer
function start() {
printer.start()
}
}
Заметьте, что теперь класс Copier
содержит ссылки на объекты классов Printer
и Scanner
. Класс Copier
делегирует функцию start
реализации из класса Printer
. Так же просто можно делегировать её имплементацию классу Scanner
.
Эта проблема - ещё одна трещина в столпе Наследования.
Проблема хрупкого базового класса
Итак, моя иерархия классов небольшая и я не допускаю циклических наследований. Бриллианты* не для меня.
И всё было отлично в мире. До тех пор пока…
Однажды, мой работавший ранее код на следующий день перестал работать. Загвоздка в том, что я не менял код.
Возможно, это баг… Но подождите… Что-то изменилось…
И это был не мой код. Ломающее изменение оказалось в базовом классе.
Каким образом изменение базового класса могло поломать мой код?
А вот так…
Представьте, что базовый класс ниже (написан на Java, но дожен быть понятен и тем, кто не знаком с этим языком):
import java.util.ArrayList;
public class Array
{
private ArrayList<Object> a = new ArrayList<Object>();
public void add(Object element)
{
a.add(element);
}
public void addAll(Object elements[])
{
for (int i = 0; i < elements.length; ++i)
a.add(elements[i]); // эта строчка изменится.
}
}
Обратите внимание на строку с комментарием. Она изменится и приведёт к тому, что мой код перестанет работать.
Класс Array
содержит два метода add()
и addAll()
. Метод add()
добавляет в коллекцию один элемент, а метод addAll()
добавляет несколько элементов вызывая метод add()
.
И класс-наследник:
public class ArrayCount extends Array
{
private int count = 0;
@Override
public void add(Object element)
{
super.add(element);
++count;
}
@Override
public void addAll(Object elements[])
{
super.addAll(elements);
count += elements.length;
}
}
Класс ArrayCount
является специализацией общего класса Array
. С одной разницей, что ArrayCount
хранит количество элементов массива в поле count
.
Давайте внимательно посмотрим на оба класса.
Метод add()
класса Array
добавляет элемент в локальный ArrayList
.
Метод addAll()
класса Array
вызывает метод add
класса ArrayList
для каждого элемента.
Метод add()
класса ArrayCount
вызывает родительский метод add
и затем увеличивает счётчик count
. Метод addAll()
класса ArrayCount
вызывает родительский addAll()
и затем увеличивает счётчик count
на количество элементов в переданном массиве.
Всё работает отлично.
Теперь ломающее изменение. Строка с комментарием в базовом классе изменилась:
public void addAll(Object elements[])
{
for (int i = 0; i < elements.length; ++i)
add(elements[i]); // строчка изменилась
}
До тех пор пока создатель базового класса следит за тем, чтобы всё было нормально - всё будет работать как надо. И все автотесты будут проходить.
Но автору безразличен наследуемый класс. И автор класса-наследника получает проблемы.
Теперь ArrayCount.addAll()
вызывает базовый addAll()
который внутри вызывает переопределённый метод add()
из класса-наследника.
Это приводит к тому, что count
увеличивается каждый раз когда вызывается метод add()
класса-наследника и увеличивается ещё раз, на количество элеметов в массиве, когда вызывается метод addAll()
класса-наследника.
ЭЛЕМЕНЫ ПОДСЧИТЫВАЮТСЯ ДВАЖДЫ.
Если это могло произойти, то это произойдёт, автор класса-наследника обязан знать внутреннюю реализацию базового класса. И владельцев классов-наследников необходимо информировать обо всех изменениях в базовом классе так как это может непредсказуемо повлиять на класс-наследник.
Тьфу! Эта огромная трещина будет всегда угрожать стабильности прекрасного столпа Наследования.
Решение проблемы хрупкого базового класса
И снова Включение и Делегирование - путь к спасению.
Используя Включение и Делегирование мы переходим от разработки Белового Ящика (White Box programming) к разработке Чёрного Ящика (Black Box programming).
При разработке Белого Ящика мы должны знать то, как реализован базовый класс.
В случае Чёрного Ящика мы полностью ингорируем реализацию базового класса поскольку не имеем возможности внедрить код в базовый класс переопределяя его функции. Мы должны придерживаемся лишь некоего интерфейса.
Звучит тревожно…
Наследование должно было быть большим вкладом в Переиспользование кода.
Объектно-ориентированные языки программирования не были спроектированы для того, чтобы можно было просто Внедрять и Делегировать. Они были созданы для простого Наследования.
Если вы похожи на меня, то у вас должны появиться вопросы к Наследованию. Но, что более важно, это должно поколебать вашу уверенность в силе создания классов через иерархии классов.
Проблема Иерархий
Каждый раз, когда я создаю компанию, я сталкиваюсь с проблемой когда мне необходимо место, где будут храниться документы компании. Например, Руководство для сотрудников.
Должна ли быть создана папка “Документы” в которой будет папка с названием “Компания”?
Может быть, я должен создать папку “Компания” и в ней создать папку “Документы”?
Оба варианта подходят. Но какой из них - правильный? Который лучше?
Идея категориальных иерархий была в том, что базовый класс (родитель) является более общим, а классы-наследники (потомки) - более специализированными версиями базового класса. И становятся всё более узко-специализированными по мере спускания по иерархии вниз (посмотрите структуру классов выше).
Но если родитель и потомок могут произвольно меняться местами, то, очевидно, с этой моделью что-то не так.
Решение проблемы Иерархий.
Проблема в том, что…
Категориальные Иерерхии не работают
Тогда для чего иерархии подходят?
Включение
Если вы присмотритесь к реальному миру вокруг, то повсюду заметите Включаемые Иерерхии (или Иерархии Эксклюзивного Владения).
Вы не увидете Категориальных Иерархий. Отличный пример Включаемой Иерархии - это ваши носки. Они находятся в ящике для носков, который является одним из ящиков вашего комода, который находится в вашей спальне, которая находится в вашем доме, и так далее.
Директории на вашем жестком диске - другой пример Включаемой Иерархии. Они содержат папки.
Как мы можем их категоризировать?
Итак, если вы подумали о Документах Компании, то вообще не имеет значения куда я их положу. Я могу положить их в папку Документы или в папку Разное.
Для категоризации я решил использовать теги. И присвоил файлу теги:
Document
Company
Handbook
У тегов нет порядка в иерархии. (Это так же решает проблему ромбовидного наследования.)
Теги похожи на интерфейсы, которых может быть несколько для каждого типа.
Но с таким количеством трещин Наследование, похоже, разрушается.
Прощай, Наследование.
Инкапсуляция. Второй столп падёт.
На первый взгляд, Инкапсуляция - вторая большая плюшка объектно-ориентированного программирования.
Состояние объекта скрыто от внешнего доступа, т.е. оно инкапсулировано в объекте.
Больше нет необходимости думать о глобальных переменных, к которым имеют доступ непонятно кто.
Инкапсуляция предоставляет безопасность вашим переменным.
Инкапсуляция ВЕЛИКОЛЕПНА!!!
Долгой жизни инкапсуляции.
До тех пор пока…
Проблема ссылок
Ради эффективности, объекты передаются по ссылке, а не по значению.
Это означает, что в функцию передаётся не значение объекта, а ссылка или указатель на объект.
Если Объект передаётся в конструктор Объекта, то конструктор может сохранить ссылку на Объект в приватную переменную, которая защищена Инкапсуляцией.
Но переданный Объект не в безопасности!
Почему? Потому что указатель на Объект есть и в других местах. Например, в коде, который вызвал конструктор. Этот код объязан иметь ссылку на Объект. Иначе как он мог бы передать его в конструктор?
Решение проблемы ссылок
Конструктор должен создавать копию переданного Объекта. При этом должно осуществляться глубокое копирование, т.е. создаваться копии всех объектов, являющиеся членами переданного Объекта и копии всех объектов этих объектов и так далее.
Столько всего ради эффективности.
И в этом проблема. Не все объекты можно клонировать. Зависимость некоторых из них от ресурсов операционной системы делает клонирование бесполезным в лучшем случае и невозможным - в худшем.
И ВСЕ популярные объектно-ориентированные языки программирования имеют эту проблему.
Прощай, Инкапсуляция.
Полиморфизм. Третий столп падёт
Полиморфизм был рыжим приёмным ребёнком Объектно Ориентированной Троицы.
Он как Ларри Файн в этой группе.
Каждый раз когда они куда-то отправлялись - он всегда был с ними, но только как второстепенный персонаж.
Это не значит, что Полиморфизм плох, это значит лишь то, что вам не нужен Объектно-Ориентированный язык программирования, чтобы добиться его.
Интерфейсы предоставляют вам это. И без всего багажа Объектно-Ориентированности.
И с Интерфейсами у вас нет ограничений на то, как много различных поведений мы можете комбинировать.
Таким образом, без лишнего шума, попрощаемся с Объектно-Ориентированным Полиморфизмом и скажем “Привет” Полиморфизму, основаному на интерфейсах.
Нарушенные обещания
Ну, Объектно-ориентированное программирование пообещало многое. И эти обещания даются наивным программистам в учебных классах, читающих блоги и проходящих онлайн-курсы.
Мне потребовались годы, чтобы понять как ООП меня обманывало. Я тоже был доверчивым.
И меня сожгли.
И что дальше?
Привет, Функциональное программирование. Было очень приятно работать с тобой последние несколько лет.
Просто чтобы ты знал, я НЕ принимаю ни одно из твоих обещаний за чистую монету. Я должен буду проверить их, чтобы поверить.
Однажды обжёгсись на молоке, будешь дуть и на воду.
Ты понимаешь.
Вы можете присоединиться к сообществу веб-разработчиков изучающих и помогающих друг другу разрабатывать веб-приложения с использование функционального программирования на языке Elm, взгляните на группу в Facebook “Learn Elm Programming”
*Отсылка к оригинальному названию ромбовидных наследований