Клавиша / esc

Порождающие паттерны проектирования

Почему не надо вызывать «скорую», когда разработчик начинает говорить про фабрики, строителей и одиночек.

Время чтения: 12 мин

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

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

Мы рассмотрим 4 самых частых паттерна:

  • Фабрика
  • Абстрактная фабрика.
  • Билдер или строитель.
  • Синглтон или одиночка.

Фабрика

Скопировано

Фабрика (англ. factory) создаёт объект, избавляя нас от необходимости знать детали создания.

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

Пример

Скопировано

Фабрика в программировании принимает от нас сигнал, что надо создать объект, и создаёт его, инкапсулируя логику создания внутри себя.

        
          
          function createGuitar(stringsCount = 6) {  return {    strings: stringsCount,    frets: 24,    fretBoardMaterial: 'кедр',    boardMaterial: 'клён',  }}
          function createGuitar(stringsCount = 6) {
  return {
    strings: stringsCount,
    frets: 24,
    fretBoardMaterial: 'кедр',
    boardMaterial: 'клён',
  }
}

        
        
          
        
      

В примере мы возвращаем объект гитары из функции-фабрики createGuitar(). Функция принимает количество струн как аргумент и подставляет его в качестве значения для поля strings. Все остальные поля она заполняет самостоятельно.

Нам в этом примере не требуется знать, как именно должно называться поле, описывающее количество струн, мы лишь передаём его в качестве аргумента. Получать объект гитары в этом случае мы будем так:

        
          
          const sixStringsGuitar = createGuitar(6)const sevenStringsGuitar = createGuitar(7)
          const sixStringsGuitar = createGuitar(6)
const sevenStringsGuitar = createGuitar(7)

        
        
          
        
      

Преимущество фабрики в том, что знание о том, как создать объект, находится в одном месте — внутри фабрики. Если схема (интерфейс) объекта поменяется, то изменить код нам нужно будет только в одном месте — в фабрике.

Допустим, нам теперь нужно:

  • Поменять название поля frets на fretsCount.
  • Поменять название поля strings на stringsCount.
  • Сделать по умолчанию 7 струн.
  • Изменить материал грифа на пихту.
        
          
          function createGuitar(stringsCount = 7) {  return {    stringsCount,    fretsCount: 24,    fretBoardMaterial: 'пихта',    boardMaterial: 'клён',  };}
          function createGuitar(stringsCount = 7) {
  return {
    stringsCount,
    fretsCount: 24,
    fretBoardMaterial: 'пихта',
    boardMaterial: 'клён',
  };
}

        
        
          
        
      

Места, где мы на самом деле создаём объекты, то есть вызываем фабрику, остаются без изменений:

        
          
          const sixStringsGuitar = createGuitar(6)const sevenStringsGuitar = createGuitar(7)
          const sixStringsGuitar = createGuitar(6)
const sevenStringsGuitar = createGuitar(7)

        
        
          
        
      

Также мы защищены от ситуации, когда вместо простого объекта нам становится нужно возвращать экземпляры класса:

        
          
          function createGuitar(stringsCount = 6) {  return new Guitar({    strings: stringsCount,    frets: 24,    fretBoardMaterial: 'пихта',    boardMaterial: 'клён',  })}
          function createGuitar(stringsCount = 6) {
  return new Guitar({
    strings: stringsCount,
    frets: 24,
    fretBoardMaterial: 'пихта',
    boardMaterial: 'клён',
  })
}

        
        
          
        
      

Весь остальной код остаётся таким же, как был до этого.

Когда использовать

Скопировано

Используйте фабрику, если создание объекта сложнее, чем 1–2 строки кода.

Особенно полезно использовать этот шаблон, когда для создания объекта требуется применить расчёты или получить дополнительные данные:

        
          
          function createGuitar(strings = 6, maxWeight = 5) {  const fretBoardMaterial = maxWeight <= 5 ? 'пихта' : 'кедр'  return {    strings,    frets: 24,    fretBoardMaterial,    boardMaterial: 'клён',  }}
          function createGuitar(strings = 6, maxWeight = 5) {
  const fretBoardMaterial = maxWeight <= 5 ? 'пихта' : 'кедр'

  return {
    strings,
    frets: 24,
    fretBoardMaterial,
    boardMaterial: 'клён',
  }
}

        
        
          
        
      

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

Абстрактная фабрика

Скопировано

Абстрактная фабрика (англ. abstract factory) — это фабрика фабрик 😃

Этот шаблон группирует связанные или похожие фабрики объектов вместе, позволяя выбирать нужную в зависимости от ситуации.

Абстрактная фабрика не возвращает конкретный объект, вместо этого она описывает тип объекта, который будет создан.

Пример

Скопировано

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

Инструменты разные, но все их мы можем описать интерфейсом Instrument:

        
          
          interface Instrument {  playNote(note: MusicNote): void;}
          interface Instrument {
  playNote(note: MusicNote): void;
}

        
        
          
        
      

Скрипку или виолончель мы сможем тогда описать так:

        
          
          class Violin implements Instrument {  playNote(note) {    console.log(`Играю ${note} на скрипке!`);  }}class Cello implements Instrument {  playNote(note) {    console.log(`Играю ${note} на виолончели!`);  }}
          class Violin implements Instrument {
  playNote(note) {
    console.log(`Играю ${note} на скрипке!`);
  }
}

class Cello implements Instrument {
  playNote(note) {
    console.log(`Играю ${note} на виолончели!`);
  }
}

        
        
          
        
      

Музыканты оркестра играют строго каждый на своём инструменте, но всех музыкантов мы можем описать интерфейсом Musician:

        
          
          interface Musician {  play(piece: MusicPiece): void;}
          interface Musician {
  play(piece: MusicPiece): void;
}

        
        
          
        
      

Тогда, например, скрипачей и виолончелистов мы сможем представить так:

        
          
          class Violinist implements Musician {  private instrument: Instrument = new Violin()  play = (piece) => piece.forEach((note) => this.instrument.playNote(note))  // Играю A# на скрипке!  // Играю C на скрипке!  // ...}class Cellist implements Musician {  private instrument: Instrument = new Cello()  play = (piece) => piece.forEach((note) => this.instrument.playNote(note))  // Играю A# на виолончели!  // Играю C на виолончели!  // ...}
          class Violinist implements Musician {
  private instrument: Instrument = new Violin()

  play = (piece) => piece.forEach((note) => this.instrument.playNote(note))
  // Играю A# на скрипке!
  // Играю C на скрипке!
  // ...
}

class Cellist implements Musician {
  private instrument: Instrument = new Cello()

  play = (piece) => piece.forEach((note) => this.instrument.playNote(note))
  // Играю A# на виолончели!
  // Играю C на виолончели!
  // ...
}

        
        
          
        
      

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

        
          
          class ViolinReservation {  reserveViolin = () => new Violin()  notifyPlayer = () => new Violinist()}class CelloReservation {  reserveCello = () => new Cello()  notifyPlayer = () => new Cellist()}
          class ViolinReservation {
  reserveViolin = () => new Violin()
  notifyPlayer = () => new Violinist()
}

class CelloReservation {
  reserveCello = () => new Cello()
  notifyPlayer = () => new Cellist()
}

        
        
          
        
      

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

        
          
          // В аргументе можно использовать объединение типов,// но если добавится ещё какой-то класс,// придётся обновлять и это объединение тоже :–(function reserve(reservation: ViolinReservation | CelloReservation): void {  // Уведомить музыканта, допустим, мы можем:  reservation.notifyPlayer()  // А вот для вызова метода резервирования инструмента,  // потребуется знать, какой перед нами класс :–(  if (reservation instanceof ViolinReservation) {    reservation.reserveViolin()  } else if (reservation instanceof CelloReservation) {    reservation.reserveCello()  }}
          // В аргументе можно использовать объединение типов,
// но если добавится ещё какой-то класс,
// придётся обновлять и это объединение тоже :–(
function reserve(reservation: ViolinReservation | CelloReservation): void {
  // Уведомить музыканта, допустим, мы можем:
  reservation.notifyPlayer()

  // А вот для вызова метода резервирования инструмента,
  // потребуется знать, какой перед нами класс :–(
  if (reservation instanceof ViolinReservation) {
    reservation.reserveViolin()
  } else if (reservation instanceof CelloReservation) {
    reservation.reserveCello()
  }
}

        
        
          
        
      

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

Для решения этой задачи как раз подойдёт абстрактная фабрика:

        
          
          // Общий интерфейс:interface ReservationFactory {  reserveInstrument(): Instrument;  notifyPlayer(): Musician;}// Реализации под разные инструменты:class ViolinReservation implements ReservationFactory {  reserveInstrument = () => new Violin()  notifyPlayer = () => new Violinist()}class CelloReservation implements ReservationFactory {  reserveInstrument = () => new Cello()  notifyPlayer = () => new Cellist()}
          // Общий интерфейс:
interface ReservationFactory {
  reserveInstrument(): Instrument;
  notifyPlayer(): Musician;
}

// Реализации под разные инструменты:
class ViolinReservation implements ReservationFactory {
  reserveInstrument = () => new Violin()
  notifyPlayer = () => new Violinist()
}

class CelloReservation implements ReservationFactory {
  reserveInstrument = () => new Cello()
  notifyPlayer = () => new Cellist()
}

        
        
          
        
      

Тогда функция reserve() станет прямолинейнее и менее хрупкой:

        
          
          function reserve(reservation: ReservationFactory): void {  reservation.notifyPlayer()  reservation.reserveInstrument()}
          function reserve(reservation: ReservationFactory): void {
  reservation.notifyPlayer()
  reservation.reserveInstrument()
}

        
        
          
        
      

Так как интерфейс остаётся одинаковым, мы можем использовать его при работе с любыми инструментами. Мы таким образом уходим от создания конкретных объектов, заменяя их абстракцией — их типом или интерфейсом.

Когда использовать

Скопировано

Если в приложении есть общая логика создания связанных или похожих, но не одинаковых объектов, абстрактная фабрика поможет избавиться от дублирования и инкапсулировать правила создания в себе.

Билдер, или Строитель

Скопировано

Билдер, или строитель, (англ. builder) позволяет создавать объекты, добавляя им свойства по заданным правилам. Он полезен, когда при создании объекта нужно выполнить много шагов, часть из которых могут быть необязательными.

Пример

Скопировано

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

        
          
          class Drink {  constructor(settings) {    const { base, milk, sugar, cream } = settings    this.base = base    this.milk = milk    this.sugar = sugar    this.cream = cream  }}
          class Drink {
  constructor(settings) {
    const { base, milk, sugar, cream } = settings

    this.base = base
    this.milk = milk
    this.sugar = sugar
    this.cream = cream
  }
}

        
        
          
        
      

Мы можем добавить молоко, сахар и сливки.

Чтобы было удобно создавать объекты напитков, мы будем указывать билдеру шаг за шагом — что добавить к кофе:

        
          
          class DrinkBuilder {  settings = {    base: 'espresso',  }  addMilk = () => {    this.settings.milk = true    return this  }  addSugar = () => {    this.settings.sugar = true    return this  }  addCream = () => {    this.settings.cream = true    return this  }  addSyrup = () => {    this.settings.syrup = true    return this  }  build = () => new Drink(this.settings)}
          class DrinkBuilder {
  settings = {
    base: 'espresso',
  }

  addMilk = () => {
    this.settings.milk = true
    return this
  }

  addSugar = () => {
    this.settings.sugar = true
    return this
  }

  addCream = () => {
    this.settings.cream = true
    return this
  }

  addSyrup = () => {
    this.settings.syrup = true
    return this
  }

  build = () => new Drink(this.settings)
}

        
        
          
        
      

По умолчанию в настройки мы добавляем только эспрессо, но при вызове методов add...() добавляем в настройки новый ингредиент. При вызове build() возвращаем собранный напиток:

        
          
          const latte = new DrinkBuilder().addMilk().build()const withSugarAndCream = new DrinkBuilder().addSugar().addCream().build()
          const latte = new DrinkBuilder().addMilk().build()
const withSugarAndCream = new DrinkBuilder().addSugar().addCream().build()

        
        
          
        
      

Обратите внимание, что мы можем собирать методы add...() в цепочку, завершая вызовом build(). Это возможно потому, что каждый из add...() методов возвращает текущий экземпляр билдера.

        
          
          // ...addMilk = () => {  this.settings.milk = true  // Возвращаем текущий билдер:  return this};
          // ...

addMilk = () => {
  this.settings.milk = true

  // Возвращаем текущий билдер:
  return this
};

        
        
          
        
      

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

Когда использовать

Скопировано

Если в приложении требуется создавать объекты с разными особенностями, или процесс создания объекта делится на отдельные шаги, то билдер помогает не засорять код условиями и проверками.

Синглтон, или Одиночка

Скопировано

Синглтон, или одиночка, (англ. singleton) — это шаблон, который позволяет создать лишь один объект, а при попытке создать новый возвращает уже созданный.

Пример

Скопировано

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

Если по каким-то причинам в приложении есть код, пытающийся создать Солнце заново, то наш класс будет возвращать существующий объект, а не создавать ещё один.

        
          
          class Sun {  // Держим ссылку на созданный объект:  static #instance = null  // Делаем конструктор приватным:  constructor() {    // Если объект был создан ранее, возвращаем его:    if (Sun.#instance) {      return Sun.#instance    }    // Иначе присваиваем объекту текущее значение this:    Sun.#instance = this  }}
          class Sun {
  // Держим ссылку на созданный объект:
  static #instance = null

  // Делаем конструктор приватным:
  constructor() {
    // Если объект был создан ранее, возвращаем его:
    if (Sun.#instance) {
      return Sun.#instance
    }

    // Иначе присваиваем объекту текущее значение this:
    Sun.#instance = this
  }
}

        
        
          
        
      

Использовать такой синглтон мы тогда будем так:

        
          
          // При первом вызове создастся новый объект:const sun = new Sun()// В дальнейшем instance будет возвращать// ранее созданный объект:const sun1 = new Sun()const sun2 = new Sun()console.log(sun === sun1)// trueconsole.log(sun === sun2)// true
          // При первом вызове создастся новый объект:
const sun = new Sun()

// В дальнейшем instance будет возвращать
// ранее созданный объект:
const sun1 = new Sun()
const sun2 = new Sun()

console.log(sun === sun1)
// true
console.log(sun === sun2)
// true

        
        
          
        
      

Когда использовать

Скопировано

Когда требуется обеспечить строго один экземпляр объекта на всё приложение. Чаще всего это не нужно.

Перед использованием синглтона стоит подумать об изменении дизайна программы, чтобы нужды использовать синглтон не было.

С чем нельзя путать

Скопировано

Синглтон, как шаблон, и синглтон, как тип жизненного цикла объектов во внедрении зависимостей — разные вещи.

Первый отвечает за ограничение количества объектов, второй — за то, какие объекты и как попадут в виде зависимостей в другие объекты. Подробнее о внедрении зависимостей можно прочесть в статье «Dependency Injection с TypeScript на практике».

Другие паттерны

Скопировано

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

Кроме порождающих также существуют и другие виды паттернов проектирования:

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