Шаблоны проектирования и приёмы рефакторинга

Соблюдать принцип открытости-закрытости помогают несколько шаблонов проектирования и приёмов рефакторинга.

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

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

Абстрактная фабрика — это фабрика, которая создаёт фабрики.

Этот шаблон позволяет создавать сущности без привязки к конкретным классам — то есть без привязки к реализации. Так добавление новых типов сущностей изменяет минимальное количество модулей.

Рассмотрим применение абстрактной фабрики на том же примере с отчётом, что был у нас в разделе об SRP.

В этом примере работа для абстрактной фабрики — выбор класса, который будет заниматься форматированием.

// часть с интерфейсом Formatter и классами,
// имплементирующими его, остаётся такой же

// добавляется новый интерфейс, описывающий фабрику фабрик
interface FormatterFactory {
  createFormatter(data: ReportData): Formatter
}

// метод createFormatter возвращает абстрактный интерфейс,
// поэтому обе фабрики ниже взаимозаменяемы
class HtmlFormatterFactory implements FormatterFactory {
  createFormatter(data: ReportData) {
    return new HtmlFormatter(data)
  }
}

class TxtFormatterFactory implements FormatterFactory {
  createFormatter(data: ReportData) {
    return new TxtFormatter(data)
  }
}

// при конфигурации приложение выберет нужный тип фабрики и будет работать с ним;
// коду приложения при этом будет не важно, с какой фабрикой он будет работать,
// потому что он будет зависеть от интерфейсов, а не от конкретных классов
class AppConfigurator {
  reportType: ReportTypes

  constructor(reportType: ReportTypes) {
    this.reportType = reportType
  }

  configure(reportData: ReportData): FormatterFactory {
    if (this.reportType === ReportTypes.Html) {
      return new HtmlFormatterFactory(reportData)
    }
    else return new TxtFormatterFactory(reportData)
  }
}

Вопросы

Стратегия

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

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

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

function formatterStrategy(reportType: ReportTypes): FormatterFactory {
  const formatters = {
    [ReportTypes.Html]: HtmlFormatterFactory,
    [ReportTypes.Txt]: TxtFormatterFactory,
    default: TxtFormatterFactory
  }

  return formatters[reportType] || formatters.default
}

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

Вопросы

Декоратор

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

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

В примере мы создаём класс BaseGreeting, метод greet которого будет выводить строку с приветствием пользователя. Декоратор GreetingWithUppercase будет приводить строку с приветствием к верхнему регистру.

interface Greeting {
  username: string
  greet(): string
}

// базовая функциональность описывается в классе,
// который будет обёрнут с помощью декораторов
class BaseGreeting implements Greeting {
  username: string

  constructor(username: string) {
    this.username = username
  }

  greet(): string {
    return `Hello, ${this.username}!`
  }
}

// wrappee — объект, функциональность которого мы будем расширять
interface GreetingDecorator {
  wrappee: Greeting
  greet(): string
}

class GreetingWithUppercase implements GreetingDecorator {
  wrappee: Greeting

  constructor(wrappee: Greeting) {
    this.wrappee = wrappee
  }

  greet(): string {
    // 1. используем базовое поведение
    const baseGreeting = this.wrappee.greet()
    // 2. расширяем его
    return baseGreeting.toUpperCase()
  }
}

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

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

Вопросы

Наблюдатель

Наблюдатель — шаблон, который создаёт механизм подписки, когда некоторые сущности могут реагировать на поведение других.

В примере ниже у нас есть класс SoftwareEngineerApplicant, который реагирует на появление вакансий в экземпляре класса HrAgency.

// интерфейс соискателя описывает его имя и желаемую позицию
interface Applicant {
  name: string
  position: string
}

// интерфейс наблюдателя описывает метод,
// который будет вызываться наблюдаемым объектом при наступлении события
interface Observer {
  update(data: any): void
}

interface NewPositionObserver extends Applicant, Observer {
  update(position: string): void
}

class SoftwareEngineerApplicant implements NewPositionObserver {
  name: string
  position: string

  constructor(name: string, position: string) {
    this.name = name
    this.position = position
  }

  update(position: string) {
    if (position !== this.position) return
    console.log(`Hello! My name is ${this.name}, I would like to apply to a ${position} position`)
  }
}

// базовый интерфейс наблюдаемого объекта
interface Observable {
  subscribe(observer: Observer): void
  unsubscribe(observer: Observer): void
  notify(data: any): void
}

// наблюдаемый объект хранит в себе список наблюдателей,
// которых будет уведомлять о наступлении события
interface NewPositionObservable extends Observable {
  listeners: NewPositionObserver[]
  notify(position: string): void
}

class HrAgency implements NewPositionObservable {
  listeners: NewPositionObserver[] = []

  subscribe(applicant: NewPositionObserver): void {
    this.listeners.push(applicant)
  }

  unsubscribe(applicant: NewPositionObserver): void {
    this.listeners = this.listeners.filter(listener =>
      listener.name !== applicant.name)
  }

  // уведомляем всех наблюдателей, когда появляется новая вакансия
  notify(position: string): void {
    this.listeners.forEach(listener => {
      listener.update(position)
    })
  }
}

Этот шаблон позволяет добавлять новых наблюдателей без необходимости менять код наблюдаемого объекта.

Вопросы

Замена прямого наследования на полиморфизм и композицию

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

Замена прямого наследования на полиморфизм или композицию — это приём рефакторинга, который ослабляет сцепление модулей через введение абстракций: интерфейсов, обёрток и т. д.

Примером может послужить пример из раздела «В идеальном мире», где мы вводили прослойку из интерфейса Shape. Когда класс AreaCalculator начинает зависеть от интерфейса, а не от конкретных классов, изменение в требованиях не затрагивает код класса AreaCalculator.

Вопросы

Материалы к разделу