[ООП] Чиним наследование?
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Сначала здесь было долгое вступление про то, как я додумался до гениальной идеи (шутка), которой и посвящена статья. Не буду тратить ваше время, вот виновник сегодняшнего торжества (осторожно, 5 строчек на JS):
function Extends(clazz) {
return class extends clazz {
// ...
}
}
Поясню, как это работает. Вместо обычного наследования мы пользуемся механизмом выше. Потом мы указываем базовый класс только при создании объекта:
const Class = Extends(Base)
const object = new Class(...args)
Я постараюсь убедить вас, что это — сын маминой подруги для наследования классов и способ вернуть наследованию звание труъ-ООП инструмента (сразу после прототипного наследования, конечно).
Почти не оффтоп
SPL
Я бы даже сделал ЯП с этим приёмом как основной фичей, но, боюсь, этот 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<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
===========
Источник:
habr.com
===========
Похожие новости:
- [Информационная безопасность, Программирование, C++, ООП] C++: Коварство и Любовь, или Да что вообще может пойти не так? (перевод)
- [Изучение языков, Научно-популярное, ООП, Функциональное программирование] Сказка о парадигмах программирования
- [PHP, Проектирование и рефакторинг, ООП] PHP класс для работы с INI файлами
- [Ненормальное программирование, Ruby, Программирование] Программирование только классами (перевод)
- [Высокая производительность, Программирование, Проектирование и рефакторинг] Почему так важна иммутабельность (перевод)
- [C++, Анализ и проектирование систем, ООП, Программирование, Программирование микроконтроллеров] Micro Property — минималистичный сериализатор двоичных данных для embedded систем
- [.NET, C#, ООП, Программирование] Творческое использование методов расширения в C# (перевод)
- [C#, ООП, Программирование] Волшебные методы в C# (перевод)
- [TypeScript, ООП, Проектирование и рефакторинг, Совершенный код] Главное — в деталях. Что на самом деле даёт ООП?
- [Совершенный код, Проектирование и рефакторинг, UML Design, ООП] Как построить четкие модели классов и получить реальные преимущества от UML (перевод)
Теги для поиска: #_oop (ООП), #_oop_golovnogo_mozga (ооп головного мозга), #_dizajn_jazykov_programmirovanija (дизайн языков программирования), #_oop (
ООП
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 18:59
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Сначала здесь было долгое вступление про то, как я додумался до гениальной идеи (шутка), которой и посвящена статья. Не буду тратить ваше время, вот виновник сегодняшнего торжества (осторожно, 5 строчек на JS): function Extends(clazz) {
return class extends clazz { // ... } } Поясню, как это работает. Вместо обычного наследования мы пользуемся механизмом выше. Потом мы указываем базовый класс только при создании объекта: const Class = Extends(Base)
const object = new Class(...args) Я постараюсь убедить вас, что это — сын маминой подруги для наследования классов и способ вернуть наследованию звание труъ-ООП инструмента (сразу после прототипного наследования, конечно). Почти не оффтопSPLЯ бы даже сделал ЯП с этим приёмом как основной фичей, но, боюсь, этот pet project умрёт, как и другие мои pet project'ы. Так что пусть хотя бы будет статья, чтобы идея пошла в массы.
Договоримся об именах. У меня есть два варианта названия таких функций:
Второй вариант звучит круче, первый может запутать C++-программистов, так что дальше буду называть такие функции LBC. Если у вас есть варианты названий получше, жду их в комментариях. «Проблемы» наследования классов Все мы знаем, как «все» «не любят» наследование классов. Какие же у него проблемы? Давайте разберёмся и заодно поймём, как LBC их решает. Наследование реализации нарушает инкапсуляцию Основная задача ООП — связывать вместе данные и операции над ними (инкапсуляция). Когда один класс наследуется от другого, эта связь нарушается: данные оказываются в одном месте (родитель), операции — в другом (наследник). Более того, наследник может перегружать публичный интерфейс класса, так что ни по коду базового класса, ни по коду класса-наследника в отдельности больше нельзя сказать, что будет происходить с состоянием объекта. Т.е., классы оказываются coupled. LBC, в свою очередь, сильно снижает coupling: от поведения какого базового класса зависеть наследнику, если базового класса в момент объявления класса-наследника просто нет? Однако, благодаря late-bound this и перегрузке методов, «Yo-yo problem» остаётся. Если вы используете наследование в своём дизайне, от неё никуда не деться, но, например, в Котлине ключевые слова open и override должны сильно облегчать ситуацию (не знаю, не слишком тесно знаком с Котлином). Наследование лишних методов Классический пример со списком и стеком: если наследовать стек от списка, в интерфейс стека попадут методы из интерфейса списка, которые могут нарушить инвариант стека. Не сказал бы, что это проблема наследования, потому что, например, в C++ для этого есть приватное наследование (а отдельные методы можно сделать публичными с помощью using), так что это скорее проблема отдельных языков. Недостаток гибкости
Проблема хрупкого базового класса Если класс наследуется от реализации другого класса, изменение этой реализации может сломать класс-наследник. В этой статье есть очень хорошая иллюстрация этой проблемы со 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 точно есть одна небольшая проблема и один жирный минус. Взрыв интерфейсов Если наследоваться можно только от интерфейса, очевидно, интерфейсов в проекте станет больше. Конечно, если в проекте соблюдается DIP, ещё несколько интерфейсов погоды не сделают, но далеко не все следуют SOLID. Эту проблему можно решить, если на основе каждого класса будет генерироваться интерфейс, содержащий все публичные методы, и при упоминании имени класса различать, имеется в виду класс как фабрика объектов или как интерфейс. Что-то похожее сделано в TypeScript, но там почему-то в сгенерированном интерфейсе упомянуты и приватные поля и методы. Сложные конструкторы Если использовать LBC, самой сложной задачей станет создать объект. Рассмотрим два варианта в зависимости от того, включен ли конструктор в интерфейс базового класса:
Вместо заключения Я уверен, что пропустил какие-то аспекты этой идеи. Либо то, что это уже дикий баян и лет двадцать назад был язык, использующий эту идею. В любом случае, жду вас в комментариях! Список источников 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 =========== Источник: habr.com =========== Похожие новости:
ООП ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 22-Ноя 18:59
Часовой пояс: UTC + 5