Оригинал

Уже десятилетия я программирую с на объектно-ориентированных языках программтрования. Первым объектно-ориентированным языком, который я использовал, был C++. После этого был Smalltalk и, в конце, .NET и Java.

Я фанатично использовал преимущество Наследования, Инкапсуляции и Полиморфизма. Три столпа Объектно-ориентированного программирования.

Я жаждал получить обещаное Переиспользование и использовать мудрость полученную теми, кто был до меня в этом новом удивительном мире.

Я не мог сдержать волнения при мысли, что можно сопоставить объекты реального мира с моими классами и ожидал, что всё встанет на свои места.

Ещё никогда я так не ошибался.

Наследование. Первый столп падёт.

На первый взгляд, Наследование кажется самым главным преимуществом ООП. Все упрощённые примеры с иерархией фигур выглядят логичными.

goodbye-oop

И Переиспользование - это слово дня. Нет… пусть будет словом года и даже больше.

Я повёлся на это и ринулся в мир с новыми знаниями.

Проблема банана, обезьяны, джунглей

С верой в сердце и задачами, требующими решения, я начал выстраивать иерархии классов и писать код. И всё в мире было отлично.

Я никогда не забуду тот день, когда был готов получить преимущества Переиспользования, унаследовавшись от существующего класса. Это был тот самый момент, которого я ждал.

Появился новый проект и я вспомнил про Класс, который я так любил на предыдущем проекте.

Без проблем. Переиспользование - маё спасение. Всё что мне требуется - всего лишь перенести класс из старого проекта в новый.

Ну… На самом деле… не только этот Класс. Нам потребуется ещё родительский класс. Но… И это ещё не всё.

Тьфу… Погодите… Похоже, нам ещё потребуется родительский класс родительского класса… И ещё… Нам потребуются ВСЕ родительские классы. Хорошо… Я справляюсь с этим. Не проблема.

Великолепно. Теперь это не компилируется. Почему?? Ох, вижу… Этот объект содержит другой объект. Таким образом мне требуется и другой класс. Не проблема.

Погодите… Мне нужен не только другой объект. Мне нужен ещё родитель другого класса и родительский класс родительского класса и так далее и так далее для каждого объекта который используется в используемом классе и ВСЕ родительские классы родителей, родители родителей и так далее.

Тьфу.

Вот великолепная цитата создателя Erlang Джо Армстронга:

Проблема объектно-ориентированных языков программирования в том, что у них есть неявные зависимости, котороые они тянут за собой. Вам необходим банан, но вместо этого вы получаете гориллу, держащую банан и все джунгли.

Решение проблемы банана, обезьяны, джунглей

Я решил эту проблему не создавая глубоких иерархий классов. Но если наследование - это ключ к Переиспользованию, то любые ограничения этой возможности ограничат возможности Переиспользования. Так?

Именно.

Так что же делать бедному ООП-программисту?

Включать и Делегировать. Больше об этом далее.

Ромбовидное наследование (Diamond problem)

Рано или поздно эта проблема покажет свою ужасную и, в зависимости от языка программирования, неразрешимую голову.

goodbye-oop2

Многие объекто-ориентированные языки программирования не поддерживают эту возможность, даже не смотря на то, что она выглядит логично. В чём проблема поддержки этой фичи в объектно-ориентированном языке?

Окей, представьте следующий псевдокод:

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”

*Отсылка к оригинальному названию ромбовидных наследований