Прощай, объектно-ориентированное программирование

в 10:26, , рубрики: IT-стандарты, Блог компании Mail.Ru Group, ооп, проблема ромба, проблема ссылки, прощай ооп, хрупкий базовый класс

Прощай, объектно-ориентированное программирование - 1

Я в течение десятилетий программировал на объектно-ориентированных языках. Первым из них стал С++, затем был Smalltalk, и наконец .NET и Java. Я фанатично использовал преимущества наследования, инкапсуляции и полиморфизма, этих трёх столпов парадигмы объектно-ориентированного программирования. Мне очень хотелось воспользоваться обещанным повторным использованием и прикоснуться к мудрости, накопленной моими предшественниками в этой новой и захватывающей сфере. Меня волновала сама мысль о том, что я могу мапить объекты реального мира в классы и думал, что весь мир можно аккуратно разложить по местам.

Я не мог ошибаться сильнее.

Наследование — первый павший Столп

Прощай, объектно-ориентированное программирование - 2

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

Прощай, объектно-ориентированное программирование - 3

А «повторное использование» — вообще термин дня. Нет, пожалуй, даже года, а то и больше. Я всё это проглатывал и бежал реализовывать в реальных проектах моё новообретённое видение.

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

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

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

Ну замечательно. Теперь не компилируется. Почему??? А, понятно… Этот объект содержит этот другой объект. Так что он мне тоже нужен. Без проблем. Погодите… Мне не нужен всего лишь тот объект. Мне нужен его родитель, и родитель родителя, и т.д. Для каждого вложенного объекта мне нужны родители, а также их родители, и их родители, родители… Мда. Джо Армстронг, создатель Erlang, когда-то сказал прекрасные слова:

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

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

Можно выйти из ситуации, не создавая слишком глубоких иерархий. Но поскольку наследование является ключом к повторному использованию, то любые накладываемые на этот механизм ограничения однозначно уменьшат преимущества, получаемые от него. Верно? Верно. Так что же делать бедному объектно-ориентированному программисту, который слепо поверил обещаниям? Агрегировать (contain) и делегировать (delegate). Об этом чуть позже.

Проблема ромба

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

Прощай, объектно-ориентированное программирование - 4

Большинство ОО-языков это не поддерживают, хотя оно и выглядит вполне здраво. Так в чём дело? Посмотрим на этот псевдокод:

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. Он делегирует реализацию функции 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]); // this line is going to be changed
    }
}

ВАЖНО: Обратите внимание на выделенную строку. Позднее она изменится и всё сломает. У этого класса две функции интерфейса: 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 содержит счётчик количества элементов. Давайте подробнее разберём оба класса.

  • Array add() добавляет элемент в локальный ArrayList.
  • Array addAll() вызывает локальный ArrayList для добавления каждого элемента.
  • ArrayCount add() вызывает свой родительский add(), а затем инкрементирует счётчик.
  • ArrayCount addAll() вызывает свой родительский addAll(), а затем инкрементирует счётчик по количеству элементов.

И всё работает замечательно. А теперь критическое изменение выделенной строки:

public void addAll(Object elements[])
{
    for (int i = 0; i < elements.length; ++i)
        add(elements[i]); // эта строка была изменена
}

Что касается владельца базового класса, то он работает так, как заявлено. И проходит все автотесты. Но владелец не обращает внимание на производный класс. И владелец производного класса сильно разочарован. Теперь ArrayCount addAll() вызывает родительский addAll(), который внутри себя вызывает add(), управление которым уже было ПЕРЕХВАЧЕНО производным классом. В результате счётчик инкрементируется при каждом вызове add() производного класса, а затем СНОВА инкрементируется по количеству элементов, добавляемых addAll() производного класса. ТО ЕСТЬ, ЭЛЕМЕНТЫ ПОДСЧИТЫВАЮТСЯ ДВАЖДЫ.

Раз такое может произойти, автор производного класса должен ЗНАТЬ, как был реализован базовый класс. И он должен быть информирован о каждом изменении в базовом классе, поскольку это может иметь непредсказуемое влияние на производный класс. Гхм! Эта огромная трещина будет всегда угрожать стабильности драгоценного Столпа Наследования.

Решение проблемы хрупкого базового класса

И снова на помощь спешат агрегирование и делегирование. Благодаря им мы переходим в программировании от стратегии «белого ящика» к стратегии «чёрного ящика». В первом случае мы должны следить за реализацией базового класса. А во втором случае мы можем полностью игнорировать эту реализацию, потому что не можем внедрить код в базовый класс, перехватив управление одной из его функций. Мы должны работать только с интерфейсом.

Это раздражающая тенденция… Ведь наследование должно было стать огромной победой с точки зрения повторного использования. Агрегирование и делегирование непросто реализовать в ОО-языках. Ведь они были разработаны с прицелом на облегчение наследования. Вероятно, вы уже испытываете удивление насчёт этого наследования. Но важнее то, что это могло уже поколебать вашу уверенность в мощи классификации посредством иерархий.

Проблема иерархии

Каждый раз, создавая новую компанию, мне приходится решать проблему, связанную с выбором места для документации компании. Нужно ли мне сделать папку Документы, а внутри неё создать папку Компания? Или мне нужно сделать папку Компания, а внутри неё — Документы? Оба варианта рабочие, но какой из них правильный? Какой лучше?

Идея иерархии “общее/частное” заключалась в том, чтобы были некие общие базовые классы (родители), и их специализированные версии — производные классы (дети). И даже ещё более специализированные, если следовать по цепочке наследования (см. схему иерархии форм в начале статьи). Но если родитель и ребёнок могут произвольно меняться местами, то с этой моделью явно что-то не так.

Решение проблемы иерархии

А не так с ней то… что иерархия “общее/частное” не работает. Так для чего тогда хороши иерархии?

Для включения (Containment).

Если посмотреть на реальный мир, то вы везде будете натыкаться на иерархии включения. А чего вы не найдёте, так это иерархии “общее/частное”. Не будем пока на этом останавливаться. Объектно-ориентированная парадигма изначально была взята из реального мира, наполненного объектами. Но потом она начала использовать порочные модели, вроде иерархии “общее/частное”, аналогов которым в жизни не существует. Зато мир наполнен иерархиями включения. Хороший пример — ваши носки. Они лежат ящике, который находится внутри шкафа, который находится внутри вашей комнаты, которая находится внутри вашего дома и т.д.

Другой пример иерархий включения — папки в компьютере. Они содержат файлы. Как мы их категорируем? Если вспомнить о документах компании, то не особо-то и важно, куда их класть, в папку Документы или в папку Барахло. Важно то, что я категорирую их с помощью тэгов. Я помечаю файл такими тэгами:

  • Document
  • Company
  • Handbook

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

Инкапсуляция, второй обрушившийся столп

Прощай, объектно-ориентированное программирование - 5

На первый взгляд, инкапсуляция — второе важнейшее преимущество объектно-ориентированного программирования. Переменные объекта защищены от доступа снаружи, то есть они инкапсулированы в объект. Больше не нужно беспокоиться о глобальных переменных, к которым обращается кто ни попадя. Инкапсуляция — это сейф для ваших переменных. Инкапсуляция просто НЕВЕРОЯТНАЯ!!! Долгой жизни инкапсуляции… До тех пор, пока не возникнет…

Проблема ссылки (The Reference Problem)

Ради повышения производительности, функциям передаются не значения объектов, а ссылки на них. Это означает, что и сами функции передают не объекты, а ссылки. Если ссылка на объект передаётся конструктору объектов, то он кладёт её в приватную переменную, защищённую инкапсулированием. Но переданный объект небезопасен! Почему? Потому в какой-то части нашего кода содержится указатель на объект, то есть код, вызывающий конструктор. Но он ДОЛЖЕН иметь ссылку на объект, в противном случае он не сможет передать её конструктору.

Решение проблемы ссылки

Конструктору придётся клонировать переданные объекты. Причём клон должен быть полным, то есть клонироваться будут все объекты, содержащиеся в переданном объекте, и все объекты, содержащиеся в этих объектах, и т.д. И всё ради повышения эффективности. Но дело в том, что не все объекты могут быть клонированы. Некоторые занимают ресурсы операционной системы, которые с ними ассоциированы, так что в лучшем случае клонировать их бесполезно, а в худшем — невозможно. В каждом из мейнстримовых ОО-языков существует такая проблема. Прощай, инкапсуляция.

Полиморфизм, третий обрушившийся Столп

Прощай, объектно-ориентированное программирование - 6

Полиморфизм — это рыжеволосый пасынок Объектно-Ориентированной Троицы. Вроде Ларри Файна в этой компании. Куда бы они не отправились, он был с ними, но всегда на вспомогательных ролях. Это не значит, что полиморфизм плох, просто для его использования вам не нужен ОО-язык. Его могут предоставить интерфейсы. Причём безо всякой дополнительной объектно-ориентированной нагрузки. Кроме того, в случае с интерфейсами вы не ограничены количеством возможных поведений, которые можно сочетать. Так что без лишних разговоров прощаемся с ОО-полиморфизмом, и приветствуем полиморфизм на основе интерфейсов.

Нарушенные обещания

Прощай, объектно-ориентированное программирование - 7

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

И что теперь?

Привет, функциональное программирование. Мне очень понравилось работать с тобой в последние годы. Чтобы вы знали: я не принимаю любые ваши обещания за чистую монету. Я сначала проверю их, чтобы поверить. Обжёгшись на молоке, будешь дуть и на воду. Ну вы поняли.

Автор: Mail.Ru Group

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js