Сначала здесь было долгое вступление про то, как я додумался до гениальной идеи (шутка, это миксины в TS/JS), которой и посвящена статья. Не буду тратить ваше время, вот виновник сегодняшнего торжества (осторожно, 5 строчек на JS):
function Extends(clazz) {
return class extends clazz {
// ...
}
}
Поясню, как это работает. Вместо обычного наследования мы пользуемся механизмом выше. Потом мы указываем базовый класс только при создании объекта:
const Class = Extends(Base)
const object = new Class(...args)
Я постараюсь убедить вас, что это — сын маминой подруги для наследования классов и способ вернуть наследованию звание труъ-ООП инструмента (сразу после прототипного наследования, конечно).
Условимся насчёт названий: я буду называть такую технику миксином, хотя под этим всё-таки подразумевается немного другое. До того, как мне подсказали, что это миксины из TS/JS, я использовал название LBC (late-bound classes).
«Проблемы» наследования классов
Все мы знаем, как «все» «не любят» наследование классов. Какие же у него проблемы? Давайте разберёмся и заодно поймём, как миксины их решают.
Наследование реализации нарушает инкапсуляцию
Основная задача ООП — связывать вместе данные и операции над ними (инкапсуляция). Когда один класс наследуется от другого, эта связь нарушается: данные оказываются в одном месте (родитель), операции — в другом (наследник). Более того, наследник может перегружать публичный интерфейс класса, так что ни по коду базового класса, ни по коду класса-наследника в отдельности больше нельзя сказать, что будет происходить с состоянием объекта. Т.е., классы оказываются coupled.
Миксины, в свою очередь, сильно снижают coupling: от поведения какого базового класса зависеть наследнику, если базового класса в момент объявления класса-наследника просто нет? Однако, благодаря late-bound this и перегрузке методов, «Yo-yo problem» остаётся. Если вы используете наследование в своём дизайне, от неё никуда не деться, но, например, в Котлине ключевые слова open
и override
должны сильно облегчать ситуацию (не знаю, не слишком тесно знаком с Котлином).
Наследование лишних методов
Классический пример со списком и стеком: если наследовать стек от списка, в интерфейс стека попадут методы из интерфейса списка, которые могут нарушить инвариант стека. Не сказал бы, что это проблема наследования, потому что, например, в C++ для этого есть приватное наследование (а отдельные методы можно сделать публичными с помощью using
), так что это скорее проблема отдельных языков.
Недостаток гибкости
- Если мы наследуемся от класса, мы наследуем всю его функциональность: мы не можем унаследовать только его часть. Однако, если вам нужно наследовать только часть класса, пора разбивать базовый класс на два: скорее всего, эта часть слабо связана с остальным поведением класса, так что cohesion только повысится. Опять же, это не проблема наследования как такового.
- Если в языке нет множественного наследования (и это хорошо), мы не можем наследовать реализацию нескольких классов. Кажется, в таком случае лучше вообще использовать композицию вместо наследования: если вам действительно нужна открытая рекурсия в условиях множественного наследования, мне вас искренне жаль.
- Использование конкретных классов ограничивает полиморфизм. Если нужно обобщить функцию над каким-то объектом, достаточно заменить тип в сигнатуре функции с класса на интерфейс. Почему нельзя сделать то же самое с наследованием, и обобщить наследуемые характеристики, что миксины и делают? Ведь в каком-то смысле класс — это просто фабрика объектов, т.е. функция.
- Использование конкретных классов ограничивает переиспользование кода. Если мы хотим добавить какую-нибудь фичу через наследование классов, мы можем добавить её только к какому-то одному базовому классу. С миксинами, очевидно, такой проблемы больше нет.
Проблема хрупкого базового класса
Если класс наследуется от реализации другого класса, изменение этой реализации может сломать класс-наследник. В этой статье есть очень хорошая иллюстрация этой проблемы со Stack
и MonitorableStack
.
С миксинами же программист обязан учитывать, что класс-наследник, который он пишет, должен работать не только с каким-то конкретным базовым классом, но и с другими классами, отвечающими интерфейсу базового класса.
Банан, горилла и джунгли
ООП обещает компонируемость, т.е. возможность переиспользовать отдельные классы в разных ситуациях и даже в разных проектах. Однако если класс наследуется от другого класса, чтобы переиспользовать наследника, нужно скопировать все зависимости, базовый класс и все его зависимости, и его базовый класс…. Т.е. хотели банан, а вытащили гориллу, а потом и джунгли. Если объект был создан с учётом Dependency Inversion Principle, с зависимостями всё не так плохо — достаточно скопировать их интерфейсы. Однако с цепочкой наследования так сделать не получится.
Миксины, в свою очередь, делают возможным (и обязывают) использование DIP в отношении наследования.
Прочие приятности Миксинов
На этом плюсы миксинов не заканчиваются. Давайте посмотрим, что ещё можно сделать с их помощью.
Смерть иерархии наследования
Классы больше не зависят друг от друга: они зависят только от интерфейсов. Т.е. реализация становится листьями графа зависимостей. Это должно облегчить рефакторинг — теперь модель домена не связана с его реализацией.
Смерть абстрактных классов
Абстрактные классы теперь не нужны. Рассмотрим пример паттерна Фабричный Метод на Java, позаимствованный у refactoring guru:
interface Button {
void render();
void onClick();
}
abstract class Dialog {
void renderWindow() {
Button okButton = createButton();
okButton.render();
}
abstract Button createButton();
}
Да, конечно, Фабричные методы эволюционируют в паттерны Строитель и Стратегия. Но с миксинами можно сделать и так (представим на секунду, что в Java есть first-class миксины):
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. Отличий два:
- Экземпляры миксинов могут содержать данные, которых не было в базовом классе;
- Миксины не модифицируют класс, от которого наследуются: чтобы использовать функциональность миксины, нужно явно создать объект миксина, а не базового класса.
Второе отличие приводит к тому, что, скажем так, миксины действуют локально, в отличие от типажей, действующих на все экземпляры базового класса. Насколько это удобно — зависит от программиста и от проекта, не стану утверждать, что моё решение однозначно лучше.
Эти отличия приближают миксины к обычному наследованию, так что эта штука мне представляется забавным компромиссом между наследованием и типажами.
Минусы миксинов
Ох, если бы всё было так просто. У миксинов точно есть одна небольшая проблема и один жирный минус.
Взрыв интерфейсов
Если наследоваться можно только от интерфейса, очевидно, интерфейсов в проекте станет больше. Конечно, если в проекте соблюдается DIP, ещё несколько интерфейсов погоды не сделают, но далеко не все следуют SOLID. Эту проблему можно решить, если на основе каждого класса будет генерироваться интерфейс, содержащий все публичные методы, и при упоминании имени класса различать, имеется в виду класс как фабрика объектов или как интерфейс. Что-то похожее сделано в TypeScript, но там почему-то в сгенерированном интерфейсе упомянуты и приватные поля и методы.
Сложные конструкторы
Если использовать миксины, самой сложной задачей станет создать объект. Рассмотрим два варианта в зависимости от того, включен ли конструктор в интерфейс базового класса:
- Если конструктор не включён в интерфейс, мы не можем его перегружать, только расширять. Например, при использовании в базовом классе паттерна Стратегия мы не сможем в классе-наследнике подменить стратегию своим Декоратором. Тем более не понятно, в каком порядке нужно будет передавать аргументы в конструктор.
- Если конструктор включён в интерфейс, мы рискуем сильно ограничить множество подходящих базовых классов. Например:
interface Base { new(values: Array<int>) } class Subclass extends Base { // ... } class DoesntFit { new(values: Array<int>, mode: Mode) { // ... } }
Класс
DoesntFit
не подходит в качестве базового дляSubclass
, но два аргумента его конструктора не связаны каким-то инвариантом. Так чтоSubclass
можно было бы использовать в качестве наследникаDoesntFit
, не будь интерфейсBase
таким ограниченным. - На самом деле, есть ещё один вариант — передавать в конструктор не список аргументов, а словарь. Это решает проблему выше, потому что
{ values: Array<int>, mode: Mode }
очевидно подходит под шаблон{ values: Array<int> }
, но это приводит к непредсказуемой коллизии имён в таком словаре: например, и суперкласс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
Автор: Павел