Композиция вместо наследования — это принцип, согласно которому классы должны достигать полиморфного поведения и повторного использования кода путем их композиции, а не наследования от базы.
Наследование
Чтобы лучше понять, почему мы можем предпочесть композицию наследованию, давайте сначала рассмотрим наследование в Javascript, а именно в ES6. Ключевое слово extends используется в объявлениях или выражениях для создания класса, который является дочерним по отношению к другому.
class Plant{
constructor(name){
this.name = name
}
water(){
console.log("Water the " + this.name)
}
repot(){
console.log( "Repot the " + this.name)
}harvest(){
console.log("Harvest the " + this.name)
}
}class Vegetable extends Plant {
constructor(name, size, health){
super(name)
this.health = health;
}
}class Flower extends Plant {
constructor(name, size, health){
super(name)
this.health = health;
}
}class Fruit extends Plant {
constructor(name, size, health){
super(name)
this.health = health;
}
}
Мы видим потенциальную проблему, которая начинает формироваться при использовании модели наследования.
Метод water
является общим для экземпляров Flower
, Vegetable
и Fruit
, что полезно, поскольку все они нуждаются в поливе (watered
), но нет необходимости, чтобы экземпляр Flower
имел доступ к методу harvest
(сбору урожая), а так как мои овощи высажены в землю, поэтому нет причин, чтобы они имели доступ к методу repot
(пересадка).
Ассоциации должны выглядеть следующим образом:
Фрукты поливаются, пересаживаются, собираются.
Цветы поливаются, пересаживаются в горшок
Овощи поливаются, собираются
Хорошо, а что если я сделаю что-то вроде следующего
class Plant{
constructor(name){
this.name = name
}
water(){
console.log("Water the " + this.name)
}
}class Vegetable extends Plant {
constructor(name, size, health){
super(name)
this.health = health;
} harvest(){
console.log("Harvest the " + this.name)
}
}class Flower extends Plant {
constructor(name, size, health){
super(name)
this.health = health;
} repot(){
console.log( "Repot the " + this.name)
}}class Fruit extends Plant {
constructor(name, size, health){
super(name)
this.health = health;
}
repot(){
console.log( "Repot the " + this.name)
} harvest(){
console.log("Harvest the " + this.name)
}
}
Это немного лучше, но теперь мы создаем дублирующие методы на разных экземплярах, которые делают одно и то же, что не соответствует принципам DRY (Don’t Repeat Yourself). Это проблема, которая может быть порождена паттерном наследования.
Проблема объектно-ориентированных языков в том, что они имеют всю эту неявную среду, которую переносят с собой. Вы хотели банан, а получили гориллу, которая держит банан и целые джунгли впридачу. - Джо Армстронг. Создатель Erlang.
Наследование по своей природе является сильно связанным по сравнению с композицией. Модель наследования вынуждает нас предсказывать будущее и строить таксономию типов. Поэтому, если мы не можем прогнозировать будущее, то неизбежно получим несколько ошибок.
Композиция
Здесь нам может помочь композиционный паттерн.
const harvest = () => {
console.log("Harvesting")
}const water = () => {
console.log("Watering)
}const repot = () => {
console.log( "Repotting")
}const Flower = (name) => {
return Object.assign(
{name},
water(),
repot()
)
}const Vegatable = (name) => {
return Object.assign(
{name},
water(),
harvest()
)
}const Fruit = (name) => {
return Object.assign(
{name},
water(),
repot(),
harvest()
)
}const daffodil = Plant();
daffodil.harvest() // undefined
const banana = Fruit();
banana.harvest() // Harvesting
Отдавая предпочтение композиции перед наследованием и рассуждая с точки зрения того, что вещи делают, а не чем они являются, можно увидеть, что мы освободились от жестко связанной структуры наследования.
Нам больше не нужно предсказывать будущее, потому что дополнительные методы могут быть легко добавлены и включены в отдельные классы.
Можно заметить, что мы больше не полагаемся на прототипное наследование, а вместо этого используем функциональное инстанцирование для создания объекта. После инстанцирования переменная утрачивает связь с общими методами. Таким образом, никакие изменения не будут переданы экземплярам, инстанцированным до этого.
Если это является проблемой, мы все еще можем использовать прототипное наследование и композицию вместе, чтобы добавить новые свойства к прототипам после их создания и таким образом сделать их доступными для всех объектов, которые ему делегируются.
Выражение стрелочной функции больше не может быть использовано, поскольку оно не имеет встроенного метода конструктора.
function Vegatable(name) {
this.name = name return Object.assign(
this,
water(),
harvest()
)
}const Carrot = new Vegatable('Carrot')
В заключение
Композиция удобна, когда мы описываем отношения "имеет", в то время как наследование полезно при описании отношений "является".
И то, и другое способствует повторному использованию кода. В отдельных случаях, в зависимости от требований и решения, применение наследования может иметь смысл.
Но подавляющее большинство решений заставят вас думать не только о текущих требованиях, но и о том, что понадобится в будущем, и в этом случае чаще всего побеждает композиция.
. . .
Вот и все. Я надеюсь, что вы нашли это полезным и благодарю за чтение. Если эта статья понравилась и она оказалась интересной, вам также могут пригодиться некоторые из других идей, которые мы создали на !!!nerdy. Новые идеи появляются каждый месяц.
Материал подготовлен в рамках курса «JavaScript Developer. Professional». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.
Комментарии (5)
MentalBlood
17.09.2021 15:16теперь мы создаем дублирующие методы на разных экземплярах, которые делают одно и то же, что не соответствует принципам DRY (Don’t Repeat Yourself). Это проблема, которая может быть порождена паттерном наследования
Да, но конкретно здесь это потому что в JS нет множественного наследования
Rsa97
17.09.2021 15:28+4В JS нет множественного наследования, но есть миксины
const Harvestable = (Base) => class extends Base { harvest() { console.log('Harvesting'); } }; const Waterable = (Base) => class extends Base { water() { console.log('Watering'); } }; const Repottable = (Base) => class extends Base { repot() { console.log('Repotting'); } }; class Plant { } class Flower extends Waterable(Repottable(Plant)) { } class Vegetable extends Waterable(Harvestable(Plant)) { } class Fruit extends Waterable(Repottable(Harvestable(Plant))) { } const daffodil = new Plant(); daffodil.harvest(); // undefined const banana = new Fruit(); banana.harvest(); // Harvesting
LordDarklight
17.09.2021 16:03Лично мне это решение нравится больше, чем из статьи (синтаксически не нравится только скобочная вложенность). Можно четко проверять иерархию типов - и как следствие поддерживаемую совместимость.
Но пример с композицией тоже хороший для ряда случаев.
Но в примере из статьи, почему-то только не показали возможности разворачивания структурных типов - когда часть мемберов можно поместить в другой структурный тип и переносить все скопом конструкцией {...Waterable}.
И не практически осталась не раскрыта тема, когда нужна будет проверка иерархии типов предков
elzahaggard13
Возможно пример страдает, но это же логично, что не все растения нужно пересаживать. С другой стороны мы можем это сделать, даже если нам не надо пересаживать овощи.
Также как вариант можно создать интерфейсы поливаемый, пересажеваемый, собираемый и имплементировать их