Мысль о том, что в разработке любой более-менее сложной бизнес-логики приоритет должен отдаваться композиции объектов, нежели наследованию популярна в среде разработчиков программных продуктов самых разных типов. На очередной волне популярности функциональной парадигмы программирования, запущенной состоявшимся успехом ReactJS, разговоры о преимуществах композиционных решений пришли и во фронтенд. В данном посте есть немного раскладки по полкам теории о композиции объектов в Javascript, конкретный пример, его разбор и ответ на вопрос сколько стоит смысловая элегантность для пользователя (спойлер: немало).

В.Кандинский - Композиция X
Василий Кандинский — «Композиция X»

Годы успешного развития объектно-ориентированного подхода к разработке, по большей части в академической сфере, привели к заметному перекосу в уме среднестатистического разработчика. Так, в большинстве случаев первой мыслью при необходимости обобщить для ряда разноплановых сущностей какое-либо поведение является создание родительского класса и наследование этого поведения. Этот подход при злоупотреблении им приводит к нескольким проблемам, усложняющим проект и тормозящим разработку. 

Во первых, перегруженный логикой базовый класс становится хрупким?-?небольшое изменение в его методах способно фатальным образом отразиться на производных классах. Один из способов обойти такую ситуацию?-?распределить логику по нескольким классам, образовав более сложную иерархию наследования. В этом случае разработчик получает другую проблему?-?логика родительских классов дублируется в наследниках по необходимости, в случаях пересечения между функционалом родительских классов, но, что важно, не полном.

image
mail.mozilla.org/pipermail/es-discuss/2013-June/031614.html

Ну и наконец, создавая достаточно глубокую иерархию, пользователь при использовании любой сущности, вынужден тянуть за собой всех ее предков вкупе со всеми их зависимостями не взирая на то, собирается он использовать их функционал или нет. Эта проблема избыточной зависимости от окружения с легкой руки Джо Армстронга, создателя Erlang, получила название проблемы гориллы и банана:

I think the lack of reusability comes in object-oriented languages, not functional languages. Because the problem with object-oriented languages is they've got all this implicit environment that they carry around with them. You wanted a banana but what you got was a gorilla holding the banana and the entire jungle.

Решить все эти проблемы призвана композиция объектов, как альтернатива наследованию классов. Идея совсем не нова, однако не находит полного понимания среди разработчиков. Немного лучше ситуация в мире фронтенда, где структура программных проектов зачастую достаточно проста и не стимулирует к созданию сложной объектно-ориентированной схемы отношений. Однако, слепое следование заветам Банды четырех, рекомендующим предпочесть композицию наследованию, также может сыграть злую шутку с вдохновленным мудростью великих разработчиком.

Перенося определения из «Шаблонов проектирования» в динамический мир Javascript можно обобщенно говорить о трех типах объектной композиции: агрегации, конкатенации и делегировании. Стоит сказать что данное разделение и вообще понятие объектной композиции имеет сугубо техническую природу, в то время как по смыслу эти термины имеют пересечения, что вносит путаницу. Так, например, наследование классов в Javascript реализовано на основе делегирования (прототипное наследование). Поэтому каждый из случаев лучше подкрепить живыми примерами кода. 

Агрегация представляет собой перечисляемое объединение объектов, каждый из которых может быть получен с помощью уникального идентификатора доступа. Примерами могут служить массивы, деревья, графы. Хороший пример из мира web-разработки?-?DOM дерево. Главным качеством данного типа композиции и причиной его создания является возможность удобного применения некоторого обработчика к каждому дочернему элементу композиции. 

Синтетический пример?-?массив объектов, по очереди задающих стиль для произвольного визуального элемента.

const styles = [
 { fontSize: '12px', fontFamily: 'Arial' },
 { fontFamily: 'Verdana', fontStyle: 'italic', fontWeight: 'bold' },
 { fontFamily: 'Tahoma', fontStyle: 'normal'}
];

Каждый из объектов стиля может быть извлечен по его индексу без потери информации. Кроме того, с помощью Array.prototype.map() можно обрабатывать все хранимые значения заданным образом.

const getFontFamily = s => s.fontFamily;
styles.map(getFontFamily)
//["Arial","Verdana","Tahoma"]

Конкатенация предполагает расширение функционала существующего объекта путем добавления к нему новых свойств. Таким образом работают, например, редьюсеры состояния в Redux. Поступившие для обновления данные записываются в объект состояния, расширяя его. Данные о текущем состоянии объекта в отличие от агрегации при этом теряются, если их не сохранить. 

Возвращаясь к примеру, поочередно применяя к визуальному элементу приведенные выше настройки, можно сформировать итоговый результат путем конкатенации параметров объектов.

const concatenate = (a, s) => ({…a, …s});
styles.reduce(concatenate, {})
//{fontSize:"12px",fontFamily:"Tahoma",fontStyle:"normal",fontWeight:"bold"}

Значения более специфичного стиля в итоге перепишут предыдущие состояния.
 
При делегировании, как легко можно догадаться, один объект делегируется другому. Делегатами, например, являются прототипы в Javascript. Экземпляры объектов-наследников перенаправляют вызовы на родительские методы. При отсутствии требуемого свойства или метода в экземпляре массива, он перенаправит это обращение к Array.prototype, а если необходимо?-?дальше к Object.prototype. Таким образом, механизм наследования в Javascript построен на основе цепочки делегирования прототипа, что технически и является (сюрприз) вариантом композиции. 

Объединение массива объектов стилей путем делегирования можно произвести следующим образом.

const delegate = (a, b) => Object.assign(Object.create(a), b);
styles.reduceRight(delegate, {})
//{"fontSize":"12px","fontFamily":"Arial"}
styles.reduceRight(delegate, {}).fontWeight
//bold

Как видно, свойства делегата не доступны путем перечисления (например в помощью Object.keys()), а доступны только путем явного обращения. О том, что нам это дает?-?в конце поста. 

Теперь к конкретике. Хороший пример случая, наталкивающего разработчика на использование композиции вместо наследования приводится в статье Майкла Райза «Object Composition in Javascript». Здесь автор рассматривает процесс создания иерархии персонажей ролевой игры. Вначале требуются два типа персонажей?-?воин и маг, каждый из которых обладает некоторым запасом здоровья и имеет имя. Эти свойства являются общими и могут быть вынесены в родительский класс Character.

class Character {
  constructor(name) {
    this.name = name;
    this.health = 100;
  }
}

Воина отличает то, что он умеет наносить удары, расходуя при этом свой запас выносливости, а мага?-?способность творить заклинания, уменьшая величину маны.

class Fighter extends Character {
  constructor(name) {
    super(name);
    this.stamina = 100;
  }
  fight() {
    console.log(`${this.name} takes a mighty swing!`);
    this.stamina? - ?;
  }
}

class Mage extends Character {
  constructor(name) {
    super(name);
    this.mana = 100;
  }
  cast() {
    console.log(`${this.name} casts a fireball!`);
    this.mana? - ?;
  }
}

Создав классы Fighter и Mage, наследников Character, разработчик сталкивается с неожиданной проблемой, когда возникает потребность в создании класса Paladin. Нового персонажа отличает завидное умение как драться, так и колдовать. Навскидку видится пара решений, отличающихся одинаковым недостатком изящества.

  1. Можно сделать Paladin наследником Character и реализовать оба метода fight() и cast() в нем с нуля. В этом случае грубо нарушается DRY-принцип, ведь каждый из методов будет продублирован при создании и в последствии будет нуждаться в постоянной синхронизации с методами классов Mage и Fighter для отслеживания изменений.
  2. Методы fight() и cast() могут быть реализованы на уровне класса Charater таким образом, чтобы все три типа персонажей обладали ими. Это немного более приятное решение, однако в этом случае разработчик должен переопределить метод fight() для мага и метод cast() для воина, заменив их пустыми заглушками.

В любом из вариантов рано или поздно приходится столкнуться с проблемами наследования, озвученными в начале поста. Они могут быть решены при функциональном подходе к реализации персонажей. Достаточно оттолкнуться не от их типов, а от их функций. В сухом остатке мы имеем две ключевых особенности, определяющих способности персонажей?-?способность драться и способность колдовать. Эти особенности можно задать с помощью фабричных функций, расширяющих состояние, определяющее персонажа (пример композиции?-?конкатенация).

const canCast = (state) => ({
  cast: (spell) => {
    console.log(`${state.name} casts ${spell}!`);
    state.mana? - ?;
  }
}) 

const canFight = (state) => ({
  fight: () => {
    console.log(`${state.name} slashes at the foe!`);
    state.stamina? - ?;
  }
})

Таким образом, персонаж определяется набором этих особенностей, и исходными свойствами, как общими (именем и здоровьем), так и частными (выносливостью и маной).

const fighter = (name) => {
  let state = {
    name,
    health: 100,
    stamina: 100
  }
  return Object.assign(state, canFight(state));
} 

const mage = (name) => {
  let state = {
    name,
    health: 100,
    mana: 100
  }
  return Object.assign(state, canCast(state));
}

const paladin = (name) => {
  let state = {
    name,
    health: 100,
    mana: 100,
    stamina: 100
  }
  return Object.assign(state, canCast(state), canFight(state));
}

Все красиво?-?код действий используется повторно, добавить любого нового персонажа можно с легкостью, не касаясь предыдущих и не раздувая функционал какого-либо одного объекта. Чтобы найти ложку дегтя в предложенном решении, достаточно сравнить производительность решения на основе наследования (читай делегирования) и решения на основе конкатенации. Создадим миллионную армию экземпляров созданных персонажей.

var inheritanceArmy = [];
for (var i = 0; i < 1000000; i++) { 
  inheritanceArmy.push(new Fighter('Fighter' + i)); 
  inheritanceArmy.push(new Mage('Mage' + i));
}

var compositionArmy = [];
for (var i = 0; i < 1000000; i++) { 
  compositionArmy.push(fighter('Fighter' + i)); 
  compositionArmy.push(mage('Mage' + i));
}

И сравним затраты по памяти и вычислениям между наследованием и композицией, необходимые для создания объектов.

image

В среднем решение с применением композиции путем конкатенации требует на 100–150% больше ресурсов. Представленные результаты были получены в среде NodeJS, посмотреть результаты для браузерного движка можно, запустив этот тест.

Преимущество решения на основе наследования-делегирования можно объяснить экономией памяти за счет отсутствия неявного доступа к свойствам делегата, а также отключением некоторых оптимизаций движка для динамических делегатов. В свою очередь, решение на основе конкатенации использует очень дорогой метод Object.assign(), что сильно отражается на его производительности. Интересно, что Firefox Quantum показывает диаметрально противоположные Chromium результаты?-?второе решение работает в Gecko значительно быстрее. 

Безусловно, опираться на результаты тестов производительности стоит только при решении достаточно трудоемких задач, связанных с созданием большого числа объектов сложной инфраструктуры?-?например при работе с виртуальным деревом элементов или разработке графической библиотеки. В большинстве случаев структурная красота решения, его надежность и простота оказываются более важны, а небольшая разница в производительности не играет большой роли (операции с DOM элементами отнимут гораздо больше ресурсов).

В заключение стоит отметить, что рассмотренные виды композиции не являются единственными и взаимоисключающими. Делегирование может быть реализовано с использованием агрегации, а наследование классов с использованием делегирования (как это и сделано в JavaScript). По своей сути любое объединение объектов будет являться той или иной формой композиции и в конечном счете значение имеет только простота и гибкость полученного решения.

Комментарии (15)


  1. Aetet
    01.02.2019 07:59
    +2

    Не совсем корректно сравнивать реализации композиции и наследования. Ведь композицию можно сделать иначе — без пересоздания объекта на каждый чих.
    А если сделать объекты без методов? Пусть будут stateless сервисы по колдунству, что умеют работать с колдунами и паладинами, а с рыцарями пусть не работают? Взамен они будут возвращать или измененный объект или новый инстанс объекта с измененными свойствами.


    1. alexprozoroff Автор
      01.02.2019 11:07

      Не совсем корректно сравнивать реализации композиции и наследования. Ведь композицию можно сделать иначе — без пересоздания объекта на каждый чих.

      Только если логика приложения позволяет нам иметь одни и те же экземпляры функций для каждого юнита. В целом, конечно, это идеально, если мы можем их сохранить чистыми, да.


  1. TheShock
    01.02.2019 08:47
    +2

    Вечная проблема с этим воображаемым противостоянием композиции и наследования и грубые примеры из разряда «я ехал на машине, не останавливаясь вышел покурить и сламал себе ноги, давайте лучше ездить на велосипедах». И, конечно, примеры из разряда: «давайте сделаем как можно повыё, типа функционально у нас все, потом за смузи будет обсуждать этих идиотов джавистов, которые создают гориллу, жующую банан».

    Во-первых, нафиг такое усложнение с Object.assign, которое ломает автодополнение?

    function castSpell () {
      console.log(`${this.name} casts ${spell}!`);
      this.mana--;
    }
    
    function fight () {
      console.log(`${this.name} slashes at the foe!`);
      this.stamina--;
    }
    
    const fighter = (name) => ({{
        name,
        health: 100,
        stamina: 100,
        fight
    })
    
    const mage = (name) => ({
      name,
      health: 100,
      mana: 100,
      castSpell
    })
    
    const paladin = (name) => ({
      name,
      health: 100,
      mana: 100,
      stamina: 100,
      fight,
      castSpell
    });


    Но про такую фигню статью не напишешь, да? Да и бгомеркий this, который используют только грязныё джависты. О, кстати, давайте соблюдать DRY.

    const character = (name) => ({
      name,
      health: 100,
    });
    
    const fighter = (name) => ({
      ...character(name),
      stamina: 100,
      fight
    })
    
    const mage = (name) => ({
      ...character(name),
      mana: 100,
      castSpell
    })
    
    const paladin = (name) => ({
      ...character(name),
      mana: 100,
      fight,
      castSpell
    });


    Опс, наследование получилось! Фигня какая. Это ведь почти горила-жрущая-банан! Упс. Тут или как мерзкие джависты, или по-модному, копипастя самого себя три раза в каждом объекте.
    Я уж молчу, что ваше предложение вообще не гарантирует, что добавив fight в очередной инстанс программист добавит так же stamina.

    найти ложку дегтя в предложенном решении...
    … достаточно зачерпнуть ложкой в любом месте вашего решения.

    Я уж молчу, что последнее решение точно так же делается на классах, как и без них.

    А как гейм-девелопер скажу, что решение — отвратительное и вообще не близко к практике. У мага и паладина вообще разные спелы, а какой-то спел может использовать стамину вместо маны. Более того, персонаж может получить возможность кастовать спелы, подняв «посох безумного огня» и потом внезапно её потерять, когда в этом посохе закончатся заряды. А ещё персонаж может использовать магию со свитков, которые тоже не затрачивают ману, но требуют определенного уровня интеллекта. Я уж молчу о том, что нельзя просто взять и изменить «стамину» — она должна измениться через анимацию.

    Потому такие действия делают отдельными событиями-классами, на которые можно подписаться через эвент-систему и которые статически типизируются, потому что в отличии от клепания одинаковых формочек на редаксе в геймдеве такой быдлокод не пройдет.

    Ну вот, к примеру: habr.com/ru/company/pixonic/blog/413729

    А наследование и композиция просто должны применятся в разных местах. ОБА этих инструмента по-своему полезны.


    1. TheShock
      01.02.2019 09:27

      И кстати, от того, что вы используете грязные процедуры в сокращенном виде, ваш процедурный код аж никак не становится функциональным.

      const canCast = (state) => ({
        cast: (spell) => {
          console.log(`${state.name} casts ${spell}!`);
          state.mana? - ?; // <=== процедурщина 
        }
      }) 


      return Object.assign(
        state, // <=== процедурщина 
        canCast(state)
      ); 


      Так что тег «функциональное программирование» можете смело убирать.


    1. alexprozoroff Автор
      01.02.2019 10:54

      Спасибо за подробный разбор. По поводу совместного использования стамины и маны, безусловно в статье дико упрощенный пример далекий от реальности. Конечно, существует масса логики, усложняющей отношения между характеристиками, но он на то и пример. И, опять же, с анимацией, рассматривается простой пример внутренней бизнес логики приложения, как это будет отображаться и анимироваться — не ее зона ответственности.

      Во-первых, нафиг такое усложнение с Object.assign, которое ломает автодополнение?

      В примере ниже у каждого персонажа будет один и тот же экземпляр функции fight() или cast(). Вероятно, при усложнении логики этих функций потребуется внутреннее состояние, ссылка на оружие(свиток) и т.д. При конкатенации мы имеем уникальный экземпляр функции для каждого юнита.

      Но про такую фигню статью не напишешь, да? Да и бгомеркий this, который используют только грязныё джависты. О, кстати, давайте соблюдать DRY.

      Да, в комментариях ниже предложили более красивое решение с сохранением той же логики.


    1. artalar
      02.02.2019 11:26

      Комментарий полезный, спасибо, но откуда такая агрессия, особенно к самому себе (вы про «джавистов» — это про себя же?) — не понятно. У автора статьи не было никаких наездов с личностными оттенками и он старался использовать ссылки на уважаемые ресурсы.


      1. TheShock
        02.02.2019 18:32

        Потому что: «вот был плохой код с классами, мы отказались от классов, теперь у нас хороший код потому что без классов, а мы пишем в функциональном стиле!»

        Если послушаете всяких фанатов Редакса — поймете, откуда агрессия)

        «Джависты» — это все те устаревшие разработчики, которые продались корпорациям, не пьют смузи и до сих пор используют такие неудобные классы вместо того, чтобы перейти на столь гениальные и удобные подходы без единых изъянов. Я к ним, как вы уже поняли, отношусь


  1. vintage
    01.02.2019 09:53
    +1

    Починил ваш код, чтобы компилятор смог его cоптимизировать:

    const canCast = (state) => {
      state.mana = 100
      state.cast = function() {
        console.log(`${this.name} casts fireball!`);
        this.mana? -- ?;
      }
    }
    
    const character = (state) => {
      state.health = 100
    }
    
    const mage = (name) => {
      let state = { name }
      character(state)
      canCast(state)
      return state;
    }
    


    1. alexprozoroff Автор
      01.02.2019 10:41

      Красота, и читабельно и быстро, особенно в Firefox'e, спасибо за такой вариант.


    1. artalar
      02.02.2019 11:31

      1. cast и fight можно не инлайнить и пересоздавать, а объявить заранее.
      2. тесты ОЧЕНЬ сильно скачут, то одно, то другое быстрее, разница в 2-3 раза иногда


    1. Kuorell
      02.02.2019 16:14

      А зачем пересоздавать функции если все равно this используем?

      function cast() {
        console.log(`${this.name} casts fireball!`);
        this.mana--;
      }
      const canCast = (state) => {
        state.mana = 100
        state.cast = cast
      }
      
      // ну и как альтернатива замыканию
      const boundCast = (state) => {
        state.mana = 100
        state.cast = cast.bind(state)
      }
      


  1. stanislavzabolotnykh
    01.02.2019 10:35

    если немного изменить пример, то станет быстрее и меньше памяти будет потреблять, не больше чем с наследованием из примера

    Код
    const canCast = (state) => {
        state.cast = (spell) => {
            console.log(`${state.name} casts ${spell}!`);
            state.mana--;
        }
    }
    
    const canFight = (state) => {
        state.fight = () => {
            console.log(`${state.name} slashes at the foe!`);
            state.stamina--;
        }
    }
    
    function FighterComp(name) {
        this.name = name;
        this.health = 100;
        this.stamina = 100;
        canFight(this);
    }
    
    function MageComp(name) {
        this.name = name;
        this.health = 100;
        this.mana = 100;
        canCast(this);
    }
    
    function Paladin(name) {
        this.name = name;
        this.health = 100;
        this.mana = 100;
        this.stamina = 100;
        canCast(this);
        canFight(this);
    }
    


    1. alexprozoroff Автор
      01.02.2019 10:42

      Да, так лучше, немного дальше пошли в предыдущем комментарии


  1. bakhirev
    01.02.2019 12:26
    +1

    Мне кажется есть проблемма с пониманием JS и фабрик. Попрубуйте так:

    const allProperties = {
      addCommon(state, name) {
        state.name = name;
        state.health = 100;
      },
      addMagic(state) {
        state.mana = 100;
      },
      addFight(state, name) {
        state.stamina = 100;
      }
    };
    
    const allMethods = {
      cast(spell) {
        console.log(`${this.name} casts ${spell}!`);
        this.mana--;
      },
      fight(spell) {
        console.log(`${this.name} slashes at the foe!`);
        this.stamina--;
      },
    };
    
    function Mage2(name) {
      allProperties.addCommon(this, name);
      allProperties.addMagic(this);
    }
    Mage2.prototype.cast = allMethods.cast;
    
    function Fighter2(name) {
      allProperties.addCommon(this, name);
      allProperties.addFight(this);
    }
    Fighter2.prototype.fight = allMethods.fight;
    


    Результат 57 против 496. То есть в 6ть раз быстрее.


  1. CoolCmd
    02.02.2019 12:15

    а чем плох вариант наследования, описанный в MDN, последний пример (mix-ins)?