Композиция вместо наследования — это принцип, согласно которому классы должны достигать полиморфного поведения и повторного использования кода путем их композиции, а не наследования от базы.

Наследование

Чтобы лучше понять, почему мы можем предпочесть композицию наследованию, давайте сначала рассмотрим наследование в 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)


  1. elzahaggard13
    17.09.2021 13:27
    +1

    Возможно пример страдает, но это же логично, что не все растения нужно пересаживать. С другой стороны мы можем это сделать, даже если нам не надо пересаживать овощи.

    Также как вариант можно создать интерфейсы поливаемый, пересажеваемый, собираемый и имплементировать их


  1. MentalBlood
    17.09.2021 15:16

    теперь мы создаем дублирующие методы на разных экземплярах, которые делают одно и то же, что не соответствует принципам DRY (Don’t Repeat Yourself). Это проблема, которая может быть порождена паттерном наследования

    Да, но конкретно здесь это потому что в JS нет множественного наследования


    1. oxidmod
      17.09.2021 15:25

      Множественное наследование имеет другие проблемы так то.


  1. 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


    1. LordDarklight
      17.09.2021 16:03

      Лично мне это решение нравится больше, чем из статьи (синтаксически не нравится только скобочная вложенность). Можно четко проверять иерархию типов - и как следствие поддерживаемую совместимость.

      Но пример с композицией тоже хороший для ряда случаев.

      Но в примере из статьи, почему-то только не показали возможности разворачивания структурных типов - когда часть мемберов можно поместить в другой структурный тип и переносить все скопом конструкцией {...Waterable}.

      И не практически осталась не раскрыта тема, когда нужна будет проверка иерархии типов предков