Алгоритмы

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

Spread the love

Эта статья отражает личную точку зрения автора и она очень спорная. Не нужно ее воспринимать как истину в последней инстанции. Но темы затронутые в ней очень интересны, с точки зрения вопросов, зачем нужны принципы SOLID. Пожалуй, если бы автор был бы с ними знаком, он не смог бы написать эту статью или сильно ее скорректировал бы.
Оригинальная статья: Charles ScalfaniGoodbye, Object Oriented Programming


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

Я свято верил в преимущества ООП Наследование, Инкапсуляция и Полиморфизм. Это три основных компонента парадигмы ООП, своего рода это Три Столпа веры.

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

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

Пожалуй, тогда я не мог быть более наивным.

Наследование, падение первого столпа

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

И повторное использование кода — это как слова молитвы. Я уверовал эту идею и бросился в мир со своим новым знанием.

Проблема обезьян с бананными в джунглях

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

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

Но появился новый проект, и я вспомнил старый класс, который мне как то понравился в одном из моих последних проектов.

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

Ну … на самом деле, … как оказалось … не только этот класс. Потребовалось использовать родительский класс. Но …и это еще не все. … Там так же потребовался родитель родителей … А потом … Еще понадобились вообще ВСЕ родители. Хорошо … Хорошо … И я справился и с этим. Нет проблем. Отлично. Но затем это отказалось компилироваться. Почему?? А потому что этот объект как оказалось содержал другой объект. Но этот другой объект мне был не нужен.

В итоге оказалось мне был нужен родитель объекта и родитель его родителя и так далее и так далее с каждым содержащимся объектом и ВСЕМИ родителями того, что они содержат вместе с родителями, родителями, родителями… Но были какие то объекты которые мне были не нужны и от которых было не возможно избавиться.

Есть замечательная цитата Джо Армстронга, создателя Erlang:

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

Решение проблемы обезьян в джунглях

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

Да я так думаю…

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

Contain и Delegate. Но об этом позже…

Проблема ромба (The 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.

Так какую функцию запуска наследует класс Copier? Scanner? Printer? Он не может наследовать и ту и другую.

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

Решение достаточно простое. Хотя наверно в реальном коде его лучше не использовать. Да и большинство ОО-языков не позволяют этого сделать.

Но, … что если смоделировать это? Я хочу использовать повторное использование кода!

Тогда вы должны Contain и Delegate.

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, но его легко понять, даже если вы не знаете 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]); // это строка позже изменится
  }
}

ВАЖНО: обратите внимание на закомментированную строку кода. Эта строка будет позже изменена, что и сломает мой код.

Этот класс имеет 2 функции в своем интерфейсе, add() и addAll(). Функция add() добавляет один элемент, а addAll() добавляет несколько элементов, вызывая функцию add.

А вот класс ArrayCount наследующий класс Array:

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.

Давайте рассмотрим оба этих класса подробнее.

Array.add() добавляет элемент в локальный ArrayList. Array.addAll() вызывает локальное добавление ArrayList для каждого элемента.

ArrayCount.add() вызывает метод add() своего родителя, а затем увеличивает счетчик count. ArrayCount.addAll() вызывает метод addAll() своего родителя, а затем так же увеличивает count на количество элементов.

И все работает отлично.

Теперь переломное изменение. Помеченая строка кода в базовом классе изменяется на следующую:

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

Что касается базового класса, он все еще функционирует так, как объявлено. И все автоматизированные тесты все еще проходят.

Но владелец не обращает внимания на класс наследующий его класс ArrayCount. Теперь ArrayCount.addAll() вызывает метод addAll() своего родителя, который вызывает метод add(), который был переопределен в классе ArrayCount.

Это приводит к тому, что число увеличивается каждый раз, когда вызывается метод add() класса ArrayCount, а затем увеличивается СНОВА на количество элементов, добавленных в addAll() класса ArrayCount.

ТЕПЕРЬ count СЧИТАЕТСЯ ДВАЖДЫ.

Если это может произойти, и это происходит, то автор класса ArrayCount должен ЗНАТЬ, как реализован базовый класс. И они должны быть проинформированы о каждом изменении в базовом классе, так как это может привести к непредсказуемым последствиям для их производного класса.

Эта огромная трещина навсегда угрожает стабильности драгоценного столба Наследования.

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

Еще раз Contain и Delegate приходят на помощь.

Используя Contain и Delegate, мы переходим от программирования белого ящика к программированию черного ящика. При программировании Белого ящика мы должны знать о реализации базового класса.

При программировании «черного ящика» мы можем полностью не знать о реализации, поскольку мы не можем внедрить код в базовый класс, переопределив одну из его функций. Нам нужно только позаботиться о интерфейсе.

Наследование должно было стать огромным подспорьем при повторном использование кода.

Объектно-ориентированные языки не позволяют легко создавать Contain и Delegate. Они были разработаны, чтобы сделать легким наследование.

Если вы похожи на меня, вы начнете задумываться о том как используется наследование. Но что еще более важно, это должно поколебать вашу уверенность в силе классификации через иерархии.

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

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

Должен ли я создать папку с именем Documents и затем создать папку с именем Company в ней?

Или я могу создать папку с именем Company, а затем создать папку с именем Documents в этой папке?

Оба варианта работают. Но что правильно? Какой лучше?

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

Но если родитель и ребенок могут произвольно поменяться местами, то явно что-то не так с этой моделью.

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

Категориальные иерархии не работают. Для чего нужны иерархии?

Для Containment (Сдерживание.).

Если вы посмотрите на реальный мир, вы увидите повсюду иерархии Containment (или исключительного владения).

То, что вы не найдете, это Категориальные иерархий. Объектно-ориентированная парадигма была основана на реальном мире, наполненном объектами. Но тогда она использует неверную модель, а именно. Категориальные Иерархии, которым нет реальной аналогии.

Но реальный мир наполнен иерархиями Containment. Отличным примером иерархии Containment являются ваши носки. Они находятся в ящике для носков, который содержится в одном ящике в вашем комоде, который содержится в вашей спальне, которая находится в вашем доме и т. д.

Каталоги на вашем жестком диске являются еще одним примером иерархии Containment. Они содержат файлы.

Так как же тогда классифицировать?

Что ж, если вы думаете о документах компании, это не имеет значения, куда я их положу. Я могу поместить их в папку «Документы» или в папку «Материалы».

Я классифицирую это с помощью тегов. Я помечаю файл следующими тегами:

Document
Company
Handbook

У тегов нет порядка или иерархии. (Это тоже решает проблему с ромбом.)

Теги аналогичны интерфейсам, поскольку с документом может быть связано несколько типов.

Но с таким количеством проблем похоже, что столб Наследования упал.

Прощай, Наследование.

Инкапсуляция, падение второго столпа

На первый взгляд, инкапсуляция является вторым по величине преимуществом объектно-ориентированного программирования.

Переменные состояния объекта защищены от внешнего доступа, т.е. они инкапсулированы в объекте.

Нам больше не придется беспокоиться о глобальных переменных, к которым может обратится кто попало.

Инкапсуляция это безопасность для ваших переменных.

Да здравствует инкапсуляция …

Но есть проблема…

Проблема ссылок

Ради эффективности Объекты передаются в функции НЕ по их значению, а по ссылке.

Это означает, что функции не передадут объект, а передадут ссылку или указатель на объект.

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

Но переданный Объект НЕ безопасен!

Почему нет? Потому что какой-то другой кусок кода имеет указатель на объект, а именно код, который называется конструктор. Он ДОЛЖЕН иметь ссылку на объект, иначе он не может передать его конструктору?

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

Конструктор должен будет клонировать переданный объект. И не поверхностный клон, а глубокий клон, т. е. каждый объект, содержащийся в переданном объекте, и каждый объект в этих объектах и т. д. и т. д.

Не сказать что это эффективно.

А вот и проблема. Не все объекты могут быть клонированы. У некоторых есть связанные с ними ресурсы операционной системы, что делает клонирование бесполезным в лучшем случае или в худшем случае невозможным.

И почти в КАЖДОМ языке OOП есть эта проблема.

Прощай, Инкапсуляция.

Полиморфизм, падение третьего столпа

Полиморфизм был рыжеволосым пасынком Объектно-ориентированной Троицы.

Это своего рода комик Ларри Файн из троицы. (Прим. перевод. В данном случае автор имеет ввиду бесполезного персонажа)

Куда бы они ни пошли, он был там, но он был просто второстепенным персонажем.

Это не значит, что полиморфизм не так хорош, просто вам не нужен объектно-ориентированный язык для этого.

Интерфейсы дадут вам это. И без всего багажа ООП.

А с интерфейсами нет предела тому, сколько разных типов поведения вы можете смешивать.

Так что без лишних слов мы прощаемся с ОО-полиморфизмом и приветствуем интерфейсный полиморфизм.

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

Ну, ООП, конечно, много обещал в первые дни. И на эти обещания все еще клюют наивные программисты, которые сидят в классах, читают блоги и посещают онлайн-курсы.

Мне потребовались годы, чтобы понять, как ООП лгал мне. Я тоже был с широко раскрытыми глазами, неопытным и доверчивым.

И я сгорел.

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

И что тогда?

Здравствуй, Функциональное Программирование. За последние несколько лет было так приятно работать с тобой.

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

Обжёгшись на молоке, будешь дуть и на воду

Если вам понравилось, нажмите 💚, чтобы другие увидели это здесь, на Medium.

Если вы хотите присоединиться к сообществу веб-разработчиков, которые учатся и помогают друг другу в разработке веб-приложений с использованием функционального программирования в Elm, пожалуйста, посетите мою группу в Facebook, и Learn Elm programming https://www.facebook.com/groups/learnelm/

Мой твиттер: @cscalfani

Была ли вам полезна эта статья?
[10 / 5]

Spread the love
Editorial Team

View Comments

  • Тема для срача и ненависти)) Несмотря на небольшой опыт сам столкнулся с половиной узких мест ООП из статьи. Но таки ФП ни разу не панацея, некоторые мнения коллег с хабра совпадают с моими. Если коротко - всему своё место.

Recent Posts

Vue 3.4 Новая механика v-model компонента

Краткий перевод: https://vuejs.org/guide/components/v-model.html Основное использование​ v-model используется для реализации двусторонней привязки в компоненте. Начиная с Vue…

12 месяцев ago

Анонс Vue 3.4

Сегодня мы рады объявить о выпуске Vue 3.4 «🏀 Slam Dunk»! Этот выпуск включает в…

12 месяцев ago

Как принудительно пере-отобразить (re-render) компонент Vue

Vue.js — это универсальный и адаптируемый фреймворк. Благодаря своей отличительной архитектуре и системе реактивности Vue…

2 года ago

Проблемы с установкой сертификата на nginix

Недавно, у меня истек сертификат и пришлось заказывать новый и затем устанавливать на хостинг с…

2 года ago

Введение в JavaScript Temporal API

Каким бы ни было ваше мнение о JavaScript, но всем известно, что работа с датами…

2 года ago

Когда и как выбирать между медиа запросами и контейнерными запросами

Все, кто следит за последними событиями в мире адаптивного дизайна, согласятся, что введение контейнерных запросов…

2 года ago