Пример реализация Композиции в JavaScript

Spread the love

В прошлой статье о Композиции было подробно рассказано что это такое и зачем она нужна. В этой статье рассмотрим реальный пример архитектуры с использованием этой парадигмы. Рассмотренный пример композиции очень известный и его вариации часто можно встретить в различных учебных пособиях и в реальном коде. Для лучшего понимания материала рассмотрение композиции будет основано на сравнение ее с классическим наследованием.

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

class Animal() {
  constructor(name, energy) {
    this.name = name
    this.energy = energy
  }

  eat(amount) {
    console.log(`${this.name} is eating`)
    this.energy += amount
  }

  sleep() {
    console.log(`${this.name} is sleeping`)
    this.energy += 1
  }

  play() {
    console.log(`${this.name} is playing`)
    this.energy -= 1
  }
}

class Dog extends Animal {
  constructor(name, energy, breed) {
    super(name, energy)
    this.breed = breed
  }
  bark() {
    console.log('Woof Woof!')
    this.energy -= .1
  }
}

class Cat extends Animal {
  constructor(name, energy, declawed) {
    super(name, energy)
    this.declawed = declawed
  }
  meow() {
    console.log('Meow!')
    this.energy -= .1
  }
}

В этом примере мы абстрагировали общие свойство каждого животного такие как name, energy, eat, sleep и play в базовый класс Animal. И далее для создания индивидуального типа животного такого как собака или кошка мы создали соотвествующие подклассы Dog и Cat.

Что бы нам проще бы дальше работать с архитектурой опишем этот пример в псевдокоде, для визуализации структуры:

Animal
  name
  energy
  eat()
  sleep()
  play()

  Dog
    bread
    bark()

  Cat
    declawed
    meow()

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

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

User
  email
  username
  pets
  friends
  adopt()
  befriend()

Animal
  name
  energy
  eat()
  sleep()
  play()

  Dog
    bread
    bark()

  Cat
    declawed
    meow()

Это структура построена по парадигме классического наследования. И вроде бы ничего плохо не случилось. Все снова должно хорошо работать. Но к сожалению в отличие от примеров из учебников, в реальной жизни невозможно предугадать новые требования и вектор развития программы. Допустим после 6 месяцев работы программы наши менеджер решили что нам нужны новые изменения. Игроки полюбили наше приложение и им понравилось возможность играть за выдуманный персонажей и им захотелось больше игровых возможностей. У нас сейчас только экземпляры классов животных умеют есть (eat), спать (sleep) и играть (play). Было принято решение предоставить эти возможности так же нашим игрокам. Хорошо не проблема мы можем изменить нашу структуру классов.

Но как это лучше сделать? И так нам нужно что бы пользователи то же могли есть (eat), спасть (sleep) и играть (play). На данный момент эта функциональность встроена в базовый класс Animal. Поэтому нам нужно снова выделить общие свойства и создать новый базовый класс. Примерно так:

FarmFantasy
  name
  play()
  sleep()
  eat()

  User
    email
    username
    pets
    friends
    adopt()
    befriend()

  Animal
    energy
  Dog
    breed
    bark()
  Cat
    declawed
    meow()

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

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

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

const eater = () => ({})
const sleeper = () => ({})
const player = () => ({})
const barker = () => ({})
const meower = () => ({})
const adopter = () => ({})
const friender = () => ({})

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

eat(amount) {
  console.log(`${this.name} is eating.`)
  this.energy += amount
}

Обратите внимание что мы с начало выводим в консоль сообщение, а затем увеличиваем свойство текущего экземпляра this.energy на аргумент функции amount. Теперь нам нужно ответить на вопрос как мы сможем оперировать текущим экземпляром из внешней функции. Что если мы передадим экземпляр при вызове функции? Как то так.

const eater = (state) => ({
  eat(amount) {
    console.log(`${this.name} is eating.`)
    state.energy += amount
  }
})

То есть вместо использование контекста this у нас будет передаваться контекст state в виде аргумента при вызове функции. И таким образом через замыкание в каждый момент вызова метода eater мы будем изменения контекст state. Изменим все классы в соответствие с этим шаблоном.

const sleeper = (state) => ({
  sleep() {
    console.log(`${state.name} is sleeping.`)
    state.energy += 1
  }
})

const player = (state) => ({
  play() {
    console.log(`${state.name} is playing.`)
    state.energy -= 1
  }
})

const barker = (state) => ({
  bark() {
    console.log(`Woof Woof`)
    state.energy -= .1
  }
})

const meower = (state) => ({
  meow() {
    console.log(`Meow!`)
    state.energy -= .1
  }
})

const adopter = (state) => ({
  adopt(pet) {
    state.pets.push(pet)
  }
})

const friender = (state) => ({
  befriend(friend) {
    state.friends.push(friend)
  }
})

Таким образом у нас есть функции sleeper, player, barker, meower, adopter, и friender. Теперь когда нам нужно добавить выбранную функциональность собакам, кошкам и игрокам мы просто можем добавить эти функции. Давай посмотрим как это выглядит в коде.

function Dog(name, energy, breed) {
  let dog = {
    name,
    energy,
    breed
  }
  return Object.assign(
    dog,
    eater(dog),
    sleeper(dog),
    player(dog),
    barker(dog)
  )
}

const leo = Dog('Leo', 10, 'Goldendoodle')
leo.eat(10) // Leo is eating
leo.bark() // Woof Woof!

Таким образом мы создали нашу собаку Dog у который есть внутренние состояние dog (используя простой объект JavaScript) с атрибутами имя (name), энергия (energy) и порода (breed). А так же используя Object.assign соединили объект dog с необходимыми нам функциями. Таким образом мы изначально ответили на вопрос что собака может делать, а не то что собака представляет из себя. Теперь таким же образом создадим класс кота.

function Cat(name, energy, declawed) {
  let cat = {
    name,
    energy,
    declawed
  }

  return Object.assign(
    cat,
    eater(cat),
    sleeper(cat),
    player(cat),
    meower(cat)
  )
}

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

И так же создадим игроков.

function User(email, username) {
  let user = {
    email,
    username,
    pets: [],
    friends: []
  }

  return Object.assign(
    user,
    eater(user),
    sleeper(user),
    player(user),
    adopter(user),
    friender(user)
  )
}

Ранее мы решили что игроки, так же могут спасть, есть и играть И сейчас процесс добавления отдельного функционала стал максимально простым У игроков есть email, username, pets и friends и они могут есть, спать играть, брать домашних животных (adopter) и дружить друг с другом.

Теперь давай те проверим нашу теорию, что если мы хотим предоставить всем собакам возможность дружить. Этого не было задумано в самом начале, но с композицией это сделать очень просто. Просто добавить нужно функцию и добавить нужный атрибут friends: [] для ее работы.

Заключение

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

Парадигма Композиция настолько стало популярной в последнее время, что в новой версии Vue 3.0 было введено специальный функционал для использования этой парадигмы Composition API

Источник используемый для написание этой статьи:

Была ли вам полезна эта статья?
[4 / 5]

Spread the love
Подписаться
Уведомление о
guest
2 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Константин
Константин
4 лет назад

Оч клево, спасибо.
Я бы пару косметических правок предложил. Понятно, что вкусовщина, но все же:

1. Я бы именовал эти функции с большой буквы. Так будет всегда понятно, что это не просто функции, а по сути — интерфейсы: Eater, Sleeper и т.д.

2. Не во всех случаях подходят имена «Eater», «Sleeper». Допустим, у нас есть сущность Post (в блоге), для которой мы композицией собираем поведение — методы requestReview() и approve().
Тогда «RequestReviewer» и «Approver» выглядят странно. Мне кажется уместнее вместо «-er» добавлять -«Behavior». Получится RequestReviewBehavior, ApproveBehavior, EatBehavior, SleepBehavior…
И тогда понятнее будет добавить сущность, которая не умеет спать. Для нее пишется другая реализация функции sleep. NoSleepBehavior опять же будет лучше отражать суть, чем NoSleeper.

edteam
Администратор
4 лет назад

Спасибо, за комментарий!