Наследование реализаций: закопайте стюардессу
Как известно, классическое ООП покоится на трех китах
- Инкапсуляция
- Наследование
- Полиморфизм
Классическая же реализация по умолчанию:
- Инкапсуляция — публичные и приватные члены класса
- Наследование — реализация функционала за счет расширения одного класса-предка, защищенные члены класса.
- Полиморфизм — виртуальные методы класса-предка.
Но еще в 1986 году была обозначена серьезнейшая проблема, кратко формулируемая так:
Наследование ломает инкапсуляцию
- Классу-потомку доступны защищенные члены класса-предка. Всем остальным доступен только публичный интерфейс класса. Предельный случай взлома — антипаттерн Паблик Морозов
- Реально изменить поведение предка можно только с помощью перекрытия виртуальных методов.
- Принцип подстановки Лисков обязывает класс-потомок удовлетворять всем требованиям к классу-предку.
- Для выполнения пункта 2 в точном соответствии с пунктом 3 классу-потомку необходима полная информация о времени вызова и реализации перекрытого виртуального метода
- Информация из пункта 4 зависит от реализации класса-предка, включая приватные члены и их код.
В теории мы уже имеем былинный отказ, но как насчет практики?
- Зависимость, создаваемая наследованием, чрезвычайно сильна.
- Наследники гиперчувствительны к любым изменениям предка.
- Наследование от чужого кода добавляет адскую боль при сопровождении:
разработчики библиотеки рискуют получить обструкцию из-за поломанной обратной совместимости при малейшем изменении базового класса, а прикладники — регрессию при любом обновлении используемых библиотек.
Все, кто используют фреймворки, требующие наследования от своих классов (WinForms, WPF, WebForms, ASP.NET), легко найдут подтверждения всем трем пунктам в своем опыте.
Неужели все так плохо?
Теоретическое решение
Влияние проблемы можно ослабить принятием некоторых конвенций
- Защищенные члены не нужны.
Это соглашение ликвидирует пабликов морозовых как класс. - Виртуальные методы предка ничего не делают.
Это соглашение позволяет сочетать знание о реализации предка с независимостью от нее реализации уже в потомке. - Виртуальные методы предка никогда не вызываются в его коде.
Это соглашение позволяет потомкам не зависеть от внутренней реализации предка, а также требует публичности всех виртуальных методов. - Экземпляры предка никогда не создаются
Это соглашение позволяет избавиться от несоответствия требований к виртуальными методам (публичный контракт класса) с одной стороны и обязанностью ничего не делать (защищенный контракт класса) с другой. Теперь принцип подстановки Лисков можно соблюсти, не вступая в порочную связь с закрытым содержимым предка. - Невиртуальных членов у предка нет
С учетом предыдущих соглашений невиртуальные члены предка становятся бесполезными и подлежат ликвидации.
Результат: если класс-предок состоит из публичных виртуальных пустых методов и требований к ним для потомков, то наследование уже не ломает инкапсуляцию. Что и требовалось доказать.
Попутно получаем возможность решение проблемы ромба для случая множественного наследования от конвенционных предков.
Но это все теория, а нам нужны…
Практические решения
- Виртуальные методы-пустышки уже есть во многих языках и носят гордое звание абстрактных.
- Классы, экземпляры которых создавать нельзя, тоже есть во многих языках и даже имеют то же звание.
- Полное соблюдение указанных соглашений в языке C++ использовалось как паттерн для проектирования и реализации Component Object Model.
- Ну и самое приятное: в C# и многих других языках соглашения реализованы как первоклассный элемент «интерфейс».
Происхождение названия очевидно — в результате соблюдения соглашений от класса остается только его публичный интерфейс. И если множественное наследование от обычных классов — редкость, то от интерфейсов оно доступно без всяких ограничений.
Итоги
- Языки, где нет наследования от классов, но есть — от интерфейсов (например, Go), нельзя лишать звания объектно-ориентированных. Более того, такая реализация ООП правильнее теоретически и безопаснее практически.
- Наследование от обычных классов (имеющих реализацию) — чрезвычайно специфический и крайне опасный архаизм.
- Избегайте наследования реализаций без крайней необходимости.
- Используйте модификатор sealed (для .NET) или его аналог для всех классов, кроме специально спроектированных для наследования реализации.
- Избегайте публичных незапечатанных классов: пока наследование не выходит за рамки своих сборок, из него еще можно извлечь пользу и ограничить вред.
PS: Дополнения и критика традиционно приветствуются.
Комментарии (16)
19 сентября 2016 в 05:00
0↑
↓
Теперь понятно почему по умолчанию все классы в Kotlin sealed, а методы final.19 сентября 2016 в 06:42
0↑
↓
А это общее правило параноика для любого ЯП (хотя больше всего его любят в C++): если вы до конца не определились как вам оформить класс, то делайте его приватным, делайте его конструктор приватным, делайте все методы приватными и невиртуальными, и запрещайте наследование. Чтобы изменить любой из этих пунктов нужна вполне конкретная причина.
19 сентября 2016 в 07:44
0↑
↓
В теории это хорошо, но писать sealed/final быстро надоедает, даже с автокомплитом. Та же ситуация что и с переменными и типами, поведение по умолчанию (mutable, nullable) слишком часто надо переопределять.
19 сентября 2016 в 05:38
0↑
↓
Наследники гиперчувствительны к любым изменениям предка.
И что же теперь, ограничиваться одним уровнем наследования от абстрактного класса?Наследование от обычных классов (имеющих реализацию) — чрезвычайно специфический и крайне опасный архаизм
Бывают случаи, когда базовый класс сам по себе самостоятелен и реализует заложенный функционал. Но множество частных случаев требуют переопределения всего лишь одного-двух методов. Внимание, вопрос: с точки зрения автора статьи, как обходиться в таких ситуациях?19 сентября 2016 в 06:55
0↑
↓
Бывают случаи, когда базовый класс сам по себе самостоятелен и реализует заложенный функционал. Но множество частных случаев требуют переопределения всего лишь одного-двух методов. Внимание, вопрос: с точки зрения автора статьи, как обходиться в таких ситуациях?
Не автор, но прокомментирую :-)
Часто забывают, что ООП — это от слова «объект», а не от слова «класс». Задача программиста — сделать так, чтобы получались объекты, обладающие требуемым поведением. В среднестатистическом мейнстримном языке типа C#/Java есть 2 подхода:
- Если мне нужно новое поведение, я наследую существующий класс и переопределяю какой-то аспект его поведения.
- Если мне нужно новое поведение, я немного иначе «строю» объект, поведение которого мне нужно изменить.
Если весь код написан таким образом, что единственный способ сконструировать объект это
new ЧтоТоТам()
, конечно тут кроме наследования ЧтоТоТам нет вариантов. Но можно же изначально заложиться наnew ЧтоТоТам(new КакЯДелаюВотЭто(), new ИЕщёКакЯДелаюВотЭто())
. В таком случае получается на порядок больше мелких классов, где одни классы описывают какой-то конкретный аспект поведения, а другие — просто скручивают несколько таких аспектов поведения в один «настроенный объект». Статья автора, если я правильно понял, про такой подход.19 сентября 2016 в 07:09
–1↑
↓
1) Это называется АОП — Аспектно-ориентированное программирование. И оно — не панацея, так как не позволяет безболезненно связать между собой разные аспекты.
2) Это верно только для простых алгоритмов, которые будут вызываться изнутри кода. Вроде SortedVector (SortingAlgorithm); SortedVector (new Bubble ()); SortedVector (new QSort ());
Если это более сложный класс, который, тем более, должен иметь доступ к приватным полям объекта, ваш подход ещё хуже, так как требует объявить ваши КакЯДелаюВотЭто и ИЕщёКакЯДелаюВотЭто друзьями класса ЧтоТоТам. Далеко не все ЯП позволяют потомкам «друзей» оставаться «друзьями».19 сентября 2016 в 07:34
0↑
↓
Это называется АОП
Это ни в коем случае не АОП. Это самое обычное ООП + делегирование.
Это верно только для простых алгоритмов… Если это более сложный класс, который, тем более, должен иметь доступ к приватным полям объекта, ваш подход ещё хуже
Посмотрите примеры использования паттерна «Стратегия» — это собственно делегирование в чистом виде и есть.
19 сентября 2016 в 07:41
0↑
↓
Это ни в коем случае не АОП
Думаете? Подумайте лучше.19 сентября 2016 в 08:14
0↑
↓
Тогда считайте, что каждый виртуальный метод — это такая запись делегирования, не загромождающая код. Ведь функция — это тоже объект.
19 сентября 2016 в 07:36
0↑
↓
Про последний абзац, в том числе эту идею развивает Егор Бугаенко (yegor256.com), если не слышали о нём рекомендую ознакомится с 105 выпуском подкаста «Разбор полетов».19 сентября 2016 в 07:38
0↑
↓
Тогда для более-менее серьезного переопределения поведения понадобится создавать сразу несколько аспектов, которые надо еще и как-то инкапсулировать в один «набор аспектов, реализующих вот такое вот поведение». При этом каждый аспект сам по себе будет являться наследником виртуального класса. В конечном итоге мы приходим к куда более сложной и многословной реализации той же самой мысли.
19 сентября 2016 в 06:05
0↑
↓
Для выполнения пункта 2 в точном соответствии с пунктом 3 классу-потомку необходима полная информация о времени вызова и реализации перекрытого виртуального метода
Достаточно иметь контракт переопределяемого метода. А вызываться он может как угодно в соответствии с контрактом.
Вам же не требуется для реализации метода интерфейса знание где и как он будет вызываться.Например реализовываете вы собственный AbstractSpliterator. Вы можете реализовать только 1 метод в соответствии с контрактом и вообще не разбираться как оно там работает внутри (а работает оно хитро). Можете реализовать 2 или даже 4, если хотите улучшить производительность. Но даже в этом случае знать вам надо только контракты методов, а не то как они используются.
19 сентября 2016 в 06:34
0↑
↓
Точно, сейчас я считаю что большинство выстрелов по ногам происходит из-за нарушения контракта методов, в том числе при переопределении, или их отсутствия.
19 сентября 2016 в 06:18
0↑
↓
Наследование от обычных классов (имеющих реализацию) — чрезвычайно специфический и крайне опасный архаизм.
Когда вдруг наследование стало архаизмом?Есть несколько вариантов как переиспользовать код, и наследование один из них. Просто использовать нужно с умом.
Наследование от чужого кода добавляет адскую боль при сопровождении
Это надуманная проблема. Если вы взяли библиотеку и она решает ваши задачи, зачем ее обновлять?
Да и в любом случае каждая зависимость от сторонних библиотек добавляет риск, и по идее, вы должны понимать этот риск.
Сейчас какая-то странная тенденция, все хотят обновить все до самой новой версии, но помоему даже незнают зачем им это.19 сентября 2016 в 06:36
0↑
↓
Автор, попробуйте пожалуйста разобрать конкретный пример — как сделать редизайн какого-нибудь UI фреймворка, чтобы избежать всепроникающего базового класса Control. Я так понимаю, у вас есть опыт с .NET — предложите вариант принципиальных изменений для Windows Forms или WPF, чтобы там не было иерархий типа https://msdn.microsoft.com/en-us/library/system.windows.controls.button (v=vs.110).aspx:
System.Object System.Windows.Threading.DispatcherObject System.Windows.DependencyObject System.Windows.Media.Visual System.Windows.UIElement System.Windows.FrameworkElement System.Windows.Controls.Control System.Windows.Controls.ContentControl System.Windows.Controls.Primitives.ButtonBase System.Windows.Controls.Button
19 сентября 2016 в 06:42
–1↑
↓
С одной стороны, это действительно теоретическая проблема, выливающаяся в ряд практических проблем, вроде бинарной совместимости модулей и тп.
С другой стороны, это одно из лучших решений с учётом накладных расходов. Дело не только в уменьшении переписываемого кода, именно наследование позволяет делать как максимально гибкий, так и максимально быстрый в расширении код с наиболее понятными требованиями к программисту-пользователю.
Отказываться от наследования — всё равно что отказываться от промышленного оборудования из-за того, что долбарабочий может при нарушении ТБ покалечить себя до смерти — высший уровень тупизны. Да, каждый язык имеет некоторые антипаттерны и некоторые спорные технологии. Да что там, даже упомянутый Go не обошёлся без критики. Проблема не в том, что они есть и сколько их имеется, а в том, обучены ли программисты работе с этими технологиями. Чаще всего нет, и, что хуже всего, программисты крайне редко несут ответственность за собственный непрофессионализм*. Худшее, что с ними может произойти — переход в другую фирму после разорения предыдущей.*Нет, это не призыв калечить людей.