Программирование — это решение задач. Часть задач в повседневной работе повторяется от проекта к проекту. У таких задач, как правило, уже есть решения — такие решения называются паттернами или шаблонами проектирования.
Порождающие паттерны (или шаблоны) проектирования помогают решать задачи, связанные с созданием сущностей или групп похожих сущностей. Они убирают дублирование кода и делают процесс создания объектов короче и прямолинейнее.
Мы рассмотрим 4 самых частых паттерна:
- Фабрика
- Абстрактная фабрика.
- Билдер или строитель.
- Синглтон или одиночка.
Фабрика
СкопированоФабрика (англ. factory) создаёт объект, избавляя нас от необходимости знать детали создания.
Например, если нам нужна гитара, мы можем выпилить деку, самостоятельно сделать струны из никеля, склеить корпус, сделать гриф, расставить лады и натянуть струны... А можем сходить в магазин и взять гитару, созданную на фабрике — в этом случае нам уже не требуется знать, что именно надо сделать, чтобы гитару создать.
Пример
СкопированоФабрика в программировании принимает от нас сигнал, что надо создать объект, и создаёт его, инкапсулируя логику создания внутри себя.
function createGuitar(stringsCount = 6) { return { strings: stringsCount, frets: 24, fretBoardMaterial: 'кедр', boardMaterial: 'клён', }}
function createGuitar(stringsCount = 6) { return { strings: stringsCount, frets: 24, fretBoardMaterial: 'кедр', boardMaterial: 'клён', } }
В примере мы возвращаем объект гитары из функции-фабрики create
. Функция принимает количество струн как аргумент и подставляет его в качестве значения для поля strings
. Все остальные поля она заполняет самостоятельно.
Нам в этом примере не требуется знать, как именно должно называться поле, описывающее количество струн, мы лишь передаём его в качестве аргумента. Получать объект гитары в этом случае мы будем так:
const sixStringsGuitar = createGuitar(6)const sevenStringsGuitar = createGuitar(7)
const sixStringsGuitar = createGuitar(6) const sevenStringsGuitar = createGuitar(7)
Преимущество фабрики в том, что знание о том, как создать объект, находится в одном месте — внутри фабрики. Если схема (интерфейс) объекта поменяется, то изменить код нам нужно будет только в одном месте — в фабрике.
Допустим, нам теперь нужно:
- Поменять название поля
frets
наfrets
.Count - Поменять название поля
strings
наstrings
.Count - Сделать по умолчанию 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 на практике».
Другие паттерны
СкопированоМы рассмотрели самые частые из порождающих паттернов проектирования. Их немного больше, но остальные используются реже.
Кроме порождающих также существуют и другие виды паттернов проектирования:
- Структурные — помогают решать задачи с тем, как совмещать и сочетать сущности вместе.
- Поведенческие — распределяют ответственности между модулями и определяют, как именно будет происходить общение.