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

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

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

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

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

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

Фабрика

Секция статьи "Фабрика"

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

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

Пример

Секция статьи "Пример"

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

        
          
          function createGuitar(stringsCount = 6) {  return {    strings: stringsCount,    frets: 24,    fretBoardMaterial: "cedar",    boardMaterial: "maple",  };}
          function createGuitar(stringsCount = 6) {
  return {
    strings: stringsCount,
    frets: 24,
    fretBoardMaterial: "cedar",
    boardMaterial: "maple",
  };
}

        
        
          
        
      

В примере мы возвращаем объект гитары из функции-фабрики 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: "fir",    boardMaterial: "maple",  };}
          function createGuitar(stringsCount = 7) {
  return {
    stringsCount,
    fretsCount: 24,
    fretBoardMaterial: "fir",
    boardMaterial: "maple",
  };
}

        
        
          
        
      

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

        
          
          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: "fir",    boardMaterial: "maple",  });}
          function createGuitar(stringsCount = 6) {
  return new Guitar({
    strings: stringsCount,
    frets: 24,
    fretBoardMaterial: "fir",
    boardMaterial: "maple",
  });
}

        
        
          
        
      

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

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

Секция статьи "Когда использовать"

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

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

        
          
          function createGuitar(strings = 6, maxWeight = 5) {  const fretBoardMaterial = maxWeight <= 5 ? "fir" : "cedar";  return {    strings,    frets: 24,    fretBoardMaterial,    boardMaterial: "maple",  };}
          function createGuitar(strings = 6, maxWeight = 5) {
  const fretBoardMaterial = maxWeight <= 5 ? "fir" : "cedar";

  return {
    strings,
    frets: 24,
    fretBoardMaterial,
    boardMaterial: "maple",
  };
}

        
        
          
        
      

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

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

Секция статьи "Абстрактная фабрика"

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

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

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

Пример

Секция статьи "Пример"

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

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

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

        
        
          
        
      

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

        
          
          class Violin implements Instrument {  playNote(note) {    console.log(`Playing ${note} on violin!`);  }}class Cello implements Instrument {  playNote(note) {    console.log(`Playing ${note} on cello!`);  }}
          class Violin implements Instrument {
  playNote(note) {
    console.log(`Playing ${note} on violin!`);
  }
}

class Cello implements Instrument {
  playNote(note) {
    console.log(`Playing ${note} on cello!`);
  }
}

        
        
          
        
      

Музыканты оркестра играют строго каждый на своём инструменте, но всех музыкантов мы можем описать интерфейсом 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));  // Playing A# on violin!  // Playing C on violin!  // ...}class Cellist implements Musician {  private instrument: Instrument = new ViolinCello();  play = (piece) => piece.forEach((note) => this.instrument.playNote(note));  // Playing A# on cello!  // Playing C on cello!  // ...}
          class Violinist implements Musician {
  private instrument: Instrument = new Violin();

  play = (piece) => piece.forEach((note) => this.instrument.playNote(note));
  // Playing A# on violin!
  // Playing C on violin!
  // ...
}

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

  play = (piece) => piece.forEach((note) => this.instrument.playNote(note));
  // Playing A# on cello!
  // Playing C on cello!
  // ...
}

        
        
          
        
      

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

        
          
          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() {}  static get instance() {    // Если объект был создан ранее, возвращаем его:    if (this.instance) return this.instance;    // Иначе создаём новый экземпляр:    this.instance = new this();    return this.instance;  }}
          class Sun {
  // Держим ссылку на созданный объект:
  static instance = null;

  // Делаем конструктор приватным:
  #constructor() {}

  static get instance() {
    // Если объект был создан ранее, возвращаем его:
    if (this.instance) return this.instance;

    // Иначе создаём новый экземпляр:
    this.instance = new this();
    return this.instance;
  }
}

        
        
          
        
      

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

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

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

        
        
          
        
      

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

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

Секция статьи "Когда использовать"

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

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

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

Секция статьи "С чем нельзя путать"

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

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

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

Секция статьи "Другие паттерны"

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

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

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