Чиним наследование?
Сначала здесь было долгое вступление про то, как я додумался до гениальной идеи (шутка), которой и посвящена статья. Не буду тратить ваше время, вот виновник сегодняшнего торжества (осторожно, 5 строчек на JS):
function Extends(clazz) {
return class extends clazz {
// ...
}
}
Поясню, как это работает. Вместо обычного наследования мы пользуемся механизмом выше. Потом мы указываем базовый класс только при создании объекта:
const Class = Extends(Base)
const object = new Class(...args)
Я постараюсь убедить вас, что это — сын маминой подруги для наследования классов и способ вернуть наследованию звание труъ-ООП инструмента (сразу после прототипного наследования, конечно).
Я бы даже сделал ЯП с этим приёмом как основной фичей, но, боюсь, этот pet project умрёт, как и другие мои pet project’ы. Так что пусть хотя бы будет статья, чтобы идея пошла в массы.
Договоримся об именах. У меня есть два варианта названия таких функций:
- «Наследование от интерфейса» — по аналогии с тем, как обычно классы наследуются от классов, здесь классы наследуются от заранее неизвестного класса, который, тем не менее, должен отвечать какому-то интерфейсу.
- «Late-bound class» — аналогично «late-bound this».
Второй вариант звучит круче, первый может запутать C++-программистов, так что дальше буду называть такие функции LBC. Если у вас есть варианты названий получше, жду их в комментариях.
«Проблемы» наследования классов
Все мы знаем, как «все» «не любят» наследование классов. Какие же у него проблемы? Давайте разберёмся и заодно поймём, как LBC их решает.
Наследование реализации нарушает инкапсуляцию
Основная задача ООП — связывать вместе данные и операции над ними (инкапсуляция). Когда один класс наследуется от другого, эта связь нарушается: данные оказываются в одном месте (родитель), операции — в другом (наследник). Более того, наследник может перегружать публичный интерфейс класса, так что ни по коду базового класса, ни по коду класса-наследника в отдельности больше нельзя сказать, что будет происходить с состоянием объекта. Т.е., классы оказываются coupled.
LBC, в свою очередь, сильно снижает coupling: от поведения какого базового класса зависеть наследнику, если базового класса в момент объявления класса-наследника просто нет? Однако, благодаря late-bound this и перегрузке методов, «Yo-yo problem» остаётся. Если вы используете наследование в своём дизайне, от неё никуда не деться, но, например, в Котлине ключевые слова open
и override
должны сильно облегчать ситуацию (не знаю, не слишком тесно знаком с Котлином).
Наследование лишних методов
Классический пример со списком и стеком: если наследовать стек от списка, в интерфейс стека попадут методы из интерфейса списка, которые могут нарушить инвариант стека. Не сказал бы, что это проблема наследования, потому что, например, в C++ для этого есть приватное наследование (а отдельные методы можно сделать публичными с помощью using
), так что это скорее проблема отдельных языков.
Недостаток гибкости
- Если мы наследуемся от класса, мы наследуем всю его функциональность: мы не можем унаследовать только его часть. Однако, если вам нужно наследовать только часть класса, пора разбивать базовый класс на два: скорее всего, эта часть слабо связана с остальным поведением класса, так что cohesion только повысится. Опять же, это не проблема наследования как такового.
- Если в языке нет множественного наследования (и это хорошо), мы не можем наследовать реализацию нескольких классов. Кажется, в таком случае лучше вообще использовать композицию вместо наследования: если вам действительно нужна открытая рекурсия в условиях множественного наследования, мне вас искренне жаль.
- Использование конкретных классов ограничивает полиморфизм. Если нужно обобщить функцию над каким-то объектом, достаточно заменить тип в сигнатуре функции с класса на интерфейс. Почему нельзя сделать то же самое с наследованием, и обобщить наследуемые характеристики, что LBC и делает? Ведь в каком-то смысле класс — это просто фабрика объектов, т.е. функция.
- Использование конкретных классов ограничивает переиспользование кода. Если мы хотим добавить какую-нибудь фичу через наследование классов, мы можем добавить её только к какому-то одному базовому классу. С LBC, очевидно, такой проблемы больше нет.
Проблема хрупкого базового класса
Если класс наследуется от реализации другого класса, изменение этой реализации может сломать класс-наследник. В этой статье есть очень хорошая иллюстрация этой проблемы со Stack
и MonitorableStack
.
В LBC же программист обязан учитывать, что класс-наследник, который он пишет, должен работать не только с каким-то конкретным базовым классом, но и с другими классами, отвечающими интерфейсу базового класса.
Банан, горилла и джунгли
ООП обещает компонируемость, т.е. возможность переиспользовать отдельные объекты в разных ситуациях и даже в разных проектах. Однако если класс наследуется от другого класса, чтобы переиспользовать наследника, нужно скопировать все зависимости, базовый класс и все его зависимости, и его базовый класс…. Т.е. хотели банан, а вытащили гориллу, а потом и джунгли. Если объект был создан с учётом Dependency Inversion Principle, с зависимостями всё не так плохо — достаточно скопировать их интерфейсы. Однако с цепочкой наследования так сделать не получится.
LBC, в свою очередь, делает возможным (и обязывает) использование DIP в отношении наследования.
Прочие приятности LBC
На этом плюсы LBC не заканчиваются. Давайте посмотрим, что ещё можно сделать с их помощью.
Смерть иерархии наследования
Классы больше не зависят друг от друга: они зависят только от интерфейсов. Т.е. реализация становится листьями графа зависимостей. Это должно облегчить рефакторинг — теперь модель домена не связана с его реализацией.
Смерть абстрактных классов
Абстрактные классы теперь не нужны. Рассмотрим пример паттерна Фабричный Метод на Java, позаимствованный у refactoring guru:
interface Button {
void render();
void onClick();
}
abstract class Dialog {
void renderWindow() {
Button okButton = createButton();
okButton.render();
}
abstract Button createButton();
}
Да, конечно, Фабричные методы эволюционируют в паттерны Строитель и Стратегия. Но с LBC можно сделать и так (представим на секунду, что в Java есть LBC):
interface Button {
void render();
void onClick();
}
interface ButtonFactory {
Button createButton();
}
class Dialog extends ButtonFactory {
void renderWindow() {
Button okButton = createButton();
okButton.render();
}
}
Такой трюк можно провернуть с почти любым абстрактным классом. Пример, когда это не сработает:
abstract class Abstract {
void method() {
abstractMethod();
}
abstract void abstractMethod();
}
class Concrete extends Abstract {
private encapsulated = new Encapsulated();
@Override
void method() {
encapsulated.method();
super.method();
}
void abstractMethod() {
encapsulated.otherMethod();
}
}
Здесь поле encapsulated
нужно и в перегрузке method
, и в реализации abstractMethod
. То есть, без нарушения инкапсуляции класс Concrete
нельзя разделить на потомка Abstract
и на «суперкласс» Abstract
. Но я не уверен, что это — пример хорошего дизайна.
Гибкость, сравнимая с типажами
Внимательный читатель заметит, что всё это очень похоже на типажи из Smalltalk / Rust. Отличий два:
- Экземпляры LBC могут содержать данные, которых не было в базовом классе;
- LBC не модифицируют класс, от которого наследуются: чтобы использовать функциональность LBC, нужно явно создать объект LBC, а не базового класса.
Второе отличие приводит к тому, что, скажем так, LBC действуют локально, в отличие от типажей, действующих на все экземпляры базового класса. Насколько это удобно — зависит от программиста и от проекта, не стану утверждать, что моё решение однозначно лучше.
Эти отличия приближают LBC к обычному наследованию, так что эта штука мне представляется забавным компромиссом между наследованием и типажами.
Минусы LBC
Ох, если бы всё было так просто. У LBC точно есть одна небольшая проблема и один жирный минус.
Взрыв интерфейсов
Если наследоваться можно только от интерфейса, очевидно, интерфейсов в проекте станет больше. Конечно, если в проекте соблюдается DIP, ещё несколько интерфейсов погоды не сделают, но далеко не все следуют SOLID. Эту проблему можно решить, если на основе каждого класса будет генерироваться интерфейс, содержащий все публичные методы, и при упоминании имени класса различать, имеется в виду класс как фабрика объектов или как интерфейс. Что-то похожее сделано в TypeScript, но там почему-то в сгенерированном интерфейсе упомянуты и приватные поля и методы.
Сложные конструкторы
Если использовать LBC, самой сложной задачей станет создать объект. Рассмотрим два варианта в зависимости от того, включен ли конструктор в интерфейс базового класса:
- Если конструктор не включён в интерфейс, мы не можем его перегружать, только расширять. Например, при использовании в базовом классе паттерна Стратегия мы не сможем в классе-наследнике подменить стратегию своим Декоратором. Тем более не понятно, в каком порядке нужно будет передавать аргументы в конструктор.
- Если конструктор включён в интерфейс, мы рискуем сильно ограничить множество подходящих базовых классов. Например:
interface Base { new(values: Array
) } class Subclass extends Base { // ... } class DoesntFit { new(values: Array , mode: Mode) { // ... } }
КлассDoesntFit
не подходит в качестве базового дляSubclass
, но два аргумента его конструктора не связаны каким-то инвариантом. Так чтоSubclass
можно было бы использовать в качестве наследникаDoesntFit
, не будь интерфейсBase
таким ограниченным. - На самом деле, есть ещё один вариант — передавать в конструктор не список аргументов, а словарь. Это решает проблему выше, потому что
{ values: Array
очевидно подходит под шаблон, mode: Mode } { values: Array
, но это приводит к непредсказуемой коллизии имён в таком словаре: например, и суперкласс} A
, и наследникB
используют одинаково называющиеся параметры, но это имя не указано в интерфейсе базового класса дляB
.
Вместо заключения
Я уверен, что пропустил какие-то аспекты этой идеи. Либо то, что это уже дикий баян и лет двадцать назад был язык, использующий эту идею. В любом случае, жду вас в комментариях!
Список источников
neethack.com/2017/04/Why-inheritance-is-bad
www.infoworld.com/article/2073649/why-extends-is-evil.html
www.yegor256.com/2016/09/13/inheritance-is-procedural.html
refactoring.guru/ru/design-patterns/factory-method/java/example
scg.unibe.ch/archive/papers/Scha03aTraits.pdf