Изучаем и используем Composition в JavaScript и TypeScript
В статье описано что такое композиция и как ее использовать при создание взаимоотношений объектов в JavaScript и TypeScript.
Оригинальная статья: Chris Noring — Learn and use Composition in JavaScript and TypeScript
Что означает слово композиция? Давайте начнем с того что это глагол, означает compose — то есть что то составить из чего то. При поиске в словаре вы найдете много значений в том числе и что то про сочинение музыки 🙂 Вы также найдете и это определение:
формировать из разных вещей
Вышесказанное, вероятно, ближе к тому, о чем я собираюсь поговорить дальше — Композиция.
Композиция
Идея состоит в том, чтобы создать что-то из других частей. Зачем нам это делать? Что ж, проще построить что-то сложное, если оно состоит из множества маленьких частей, которые мы понимаем. Другая причина — возможность повторного использования. Большие и более сложные вещи могут иногда разделяться на части, из которых они состоят, с другими большими и сложными вещами. Вы когда-нибудь слышали об ИКЕА? 😉
В программировании есть несколько типов композиций! Далее мы рассмотрим что такое Наследование, Композиция функций и Композиция объекта (с классами и без классов)
Композиция против Наследования
Давайте быстро объясним что такое наследование. Идея наследования состоит в том, чтобы наследовать признаки, поля, а также методы от родительского класса. Класс, наследуемый от родительского класса, называется подклассом. Это наследование позволяет обрабатывать группу объектов одинаковым образом. Рассмотрим приведенный ниже пример:
class Shape { constructor(x, y) { this.x = x; this.y = y; } } class Movable extends Shape { move(dx, dy) { this.x += dx; this.y += dy; } } class Hero extends Movable { constructor(x, y) { super(x, y); this.name = 'hero'; } } class Monster extends Movable { constructor(x, y) { super(x, y); this.name = 'monster'; } } const movables = [new Hero(1,1), new Monster(2,2)]; movables.forEach(m => m.move(2, 3));
В примере мы можем обрабатываем Hero и Monster одинаковым образом, поскольку они имеют общий элемент-предок Movable, который позволяет перемещать их с помощью метода move(). Все это основано на принципе отношений IS-A. Hero IS-A Movable, Monster IS-A Movable.
В последнее время в разные источниках много говорилось о том, что желательнее использовать композицию, а не наследование. Почему? Давайте посмотрим на некоторые недостатки наследования:
- Принцип подстановки Лисков, идея этого принципа заключается в том, что код должен работать, даже если заменить что-то общим предком на что-то другое, то есть, заменить Hero на Monster, так как они оба имеют тип Movable. В приведенном выше примере кода эта замена должна работать. Тем не менее, приведенный выше код представляет собой идеальный случай. Реальность намного хуже. В действительности, могут существовать большие кодовые базы, где существует более 20 уровней наследования, и унаследованные методы могут быть не реализованы должным образом, что означает, что некоторые объекты нельзя просто так заменять друг на друга. Рассмотрим следующий код:
class NPC extends Movable { move(dx, dy) { console.log('I wont move') } }
Вышеуказанный код нарушил принцип подстановки Лискова. К счастью наша кодовая база настолько мала, что мы можем легко обнаружить нарушение. В больших кодовых базах вы не сможете определить, когда это произойдет. Представьте, что нарушение происходит на третьем уровне наследования, а всего у вас есть 20 уровней наследования.
- Отсутствие гибкости, в большинстве случаев у нас есть отношения HAS-A по сравнению с IS-A. Легче думать о том, что разные компоненты делают разные вещи, а не имеют ничего общего тогда нам будет легче создавать множество дополнительных классов и цепочек взаимодействий.
Композиция функций
Это математический термин, утверждающий согласно Википедии следующее: композиция функций — это операция, которая принимает две функции f и g и производит функцию h такую, что h (x) = g (f (x)).
В программировать мы можем создать такой пример:
let list = [1,2,3]; take(orderByAscending(list), 3)
Где list это x, f(x) это orderByAscending(list) а g(x) это take() c f(x) входным параметром.
Дело в том, что у вас есть много операций, примененных одна за другой к части данных. По сути, вы применяете разные части для создания более сложного алгоритма, который при вызове вычисляет более сложный результат. Но мы не будем тратить время на обсуждение этой версии композиции, просто знайте, что она существует.
Композиция объекта
Этот тип композиции предназначен для объединения объектов или других типов данных для создания чего-то более сложного, это то с чего мы начали. Это может быть достигнуто различными способами в зависимости от того, какой язык вы используете. В Java и C# у вас есть только один способ создания объектов, используя класс. В других языках, таких как JavaScript, у вас есть объекты, которые можно создавать разными способами, и, таким образом, вы открываете для себя различные способы создания композиции.
Композиция объектов с классами
Использование классов — это класс, ссылающийся на один или несколько других классов через переменные экземпляра. То есть в этом случае класс будет описывать ассоциацию. Что это означает? У человека четыре конечности, у машины может быть четыре колеса и так далее. Представьте, что на эти классы ссылаются как на части, которые дают вам возможность что-то делать. Давайте посмотрим на пример:
class SteeringWheel { steer(){} } class Engine { run(){} } class Car { constructor(steeringWheel, engine) { this.steeringWheel = steeringWheel; this.engine = engine; } steer() { this.steeringWheel.steer(); } run() { this.engine.run(); } } class Tractor { constructor(steeringWheel) { this.steeringWheel = steeringWheel; } steer() { this.steeringWheel.steer(); } }
То, что мы получаем выше, это, во-первых, автомобиль более сложного класса, состоящий из множества частей: рулевого колеса (steeringWheel) и двигателя (engine), и благодаря этому мы получаем способность управлять автомобилем (steer) и двигаться им (runs). Мы также получаем возможность многократного использования, поскольку мы можем использовать SteeringWheel например в тракторе (Tractor).
Композиция объектов без классов
JavaScript немного отличается от C# и Java тем, что в нем можно создавать объекты несколькими способами, например как показано ниже:
- Объектный литерал (Object literal), вы можете создать объект, просто набрав его так:
let myObject = { name: 'a name' }
Object.create()
, Вы можете просто использовать объект, и он будет использоваться в качестве шаблона. Вот так например:
const template = { a: 1, print() { return this.a } } const test = Object.create(template); test.a = 2 console.log(test.print()); // 2
- Используя ключевое слово
new
. Вы можете применить оператор new как к классу, так и к функции, например так:
class Plane { constructor() { this.name = 'a plane' } } function AlsoAPlane() { this.name = 'a plane'; } const plane = new Plane(); console.log(plane.name); // a plane const anotherPlane = new AlsoAPlane(); console.log(anotherPlane) // a plane
Существует небольшая разница между этими подходами. Вам нужно проделать немного больше работы, если вы хотите, чтобы наследование работало для функционального подхода среди прочего. На данный момент мы рады, что существуют разные способы создания объектов с использованием new.
Так как нам на самом деле создавать композицию? Для композиции нам нужен способ выражения поведения. Нам не нужно использовать классы, если мы этого не хотим, и вместо этого мы можем перейти непосредственно к объектам. Мы можем выразить нашу композицию следующим образом:
const steer = { steer() { console.log(`steering ${this.name}`) } } const run = { run() { console.log(`${this.name} is running`) } } const fly = { fly() { console.log(`${this.name} is flying`) } }
и использовать их следующим образом:
const steerAndRun = { ...steer, ...run }; const flyingAndRunning = { ...run, ...fly };
Выше мы использовали оператор spread, чтобы объединить различные свойства из разных классов и поместить их в один класс. Результирующий steerAndRun теперь содержит {steer () {…}, run () {…}}, а flyingAndRunning содержит {fly () {…}, run () {…}}.
Затем, используя метод createVehicle(), мы создадим то, что нам нужно:
function createVehicle(name, behaviors) { return { name, ...behaviors } } const car = createVehicle('Car', steerAndRun) car.steer(); car.run(); const crappyDelorean = createVehicle('Crappy Delorean', flyingAndRunning) crappyDelorean.run(); crappyDelorean.fly();
Конечный результат — два разных объекта с разными возможностями.
Но я использую TypeScript, что тогда
TypeScript интенсивно использует классы и интерфейсы, поэтому рассмотрим способ выполнения композиции объектов с использованием классов.
Могу ли я выполнить композицию способом, аналогичным использованию оператора spread и объектов?
Да, вы можете. Для этого мы используем концепцию под названием MixIns. Давай начнем:
1. Во-первых, нам нужна эта конструкция:
type Constructor<T = {}> = new (...args: any[]) => T
Мы используем эту конструкцию, чтобы создать Constructor.
2. Затем объявим эту функцию:
function Warrior<TBase extends Constructor>(Base: TBase) { return class extends Base { say: string = 'Attaaack'; attack() { console.log("attacking...") } } }
Эта конструкция возвращает класс, унаследованный от Base. Base является входным параметром нашей функции и имеет тип TBase, который использует только что созданный Constructor.
3. Давайте определим класс, который будет использовать вышеуказанную функцию:
class Player { constructor( private name: string ) {} }
4. Теперь вызовим функцию Warrior:
const WarriorPlayerType = Warrior(Player); const warrior = new WarriorPlayerType("Conan"); console.log(warrior.say); // 'Attaaack' warrior.attack(); // 'attacking...'
5. Мы можем продолжать создавать композицию, создав новую функцию, которая содержит другое поведение, которое мы могли бы хотеть:
function Wings<TBase extends Constructor>(Base: TBase) { return class extends Base { fly() { console.log("flying...") } } }
6. Используем следующую композицию
const WingsWarriorPlayerType = Wings(WarriorPlayerType); const flyingWarrior = new WingsWarriorPlayerType("Flying Conan"); flyingWarrior.fly();
Заключение
В этой статье описывается, что такое композиция. Кроме того, в ней говориться о том, что композиция предпочтительнее наследования. Так было рассмотрены разные способы создания композиции. В качестве бонуса мы также рассмотрели подход, который вы можете использовать в TypeScript.
Дальнейшее чтение
Есть несколько замечательных статей на эту тему, которые, я думаю, вы должны прочитать. Посмотрите на следующее:
Барбара Лисков — женщина, соответственно принцип подстановки Лисков
Полезная статься!