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

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

Контрактное программирование

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

Спецификации могут описывать интерфейсы методов, их пред- и постусловия, описание проверок и критерии соответствия. Такие спецификации называются контрактами.

В примере ниже интерфейс Contract описывает методы для проверки предусловия require и постусловия ensure. Класс ContractAssert реализует этот интерфейс, определяя, какие исключения следует сгенерировать при нарушении условий.

interface Contract {
  require(expression: boolean, msg?: string): void
  ensure(expression: boolean, msg?: string): void
}

class ContractAssert implements Contract {
  require(expression: boolean, msg?: string): void {
    if (!expression) throw new PreconditionException(msg)
  }

  ensure(expression: boolean, msg?: string): void {
    if (!expression) throw new PostconditionException(msg)
  }
}

Опишем исключения, наследуясь от стандартного Error. Класс PreconditionException отвечает за исключение при нарушении предусловия, PostconditionException — за нарушение постусловия.

class ContractException extends Error {
  constructor(msg?: string) {
    super(`contract error: ${msg}`)
  }
}

class PreconditionException extends ContractException {
  constructor(msg?: string) {
    super(`precondition failed, ${msg}`)
  }
}

class PostconditionException extends ContractException {
  constructor(msg?: string) {
    super(`postcondition failed, ${msg}`)
  }
}

Теперь если нам потребуется написать сумматор, который работает только с чётными числами, то мы можем проверять пред- и постусловия через контракт:

class EvenNumbersSummator {
  contract: Contract

  // Создаём контракт для проверки и записываем в `this.contract`:
  constructor(contract: Contract = new ContractAssert()) {
    this.contract = contract
  }

  add(a: number, b: number): number {
    // Перед работой метода проверяем все предусловия:
    this.contract.require(a % 2 === 0, 'first arg is not even')
    this.contract.require(b % 2 === 0, 'second arg is not even')

    const result = a + b

    // Перед тем, как вернуть результат проверяем постусловия:
    this.contract.ensure(result % 2 === 0, 'result is not even')
    this.contract.ensure(result === a + b, 'result is not equal to expected')
    return result
  }
}

Теперь метод не начнёт свою работу, если какое-то из предусловий будет нарушено, как и не вернёт результат, если будет нарушено постусловие.

Вопросы

Извлечение интерфейса, извлечение суперкласса

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

Для подобной работы подходят приёмы извлечения интерфейса и извлечения суперкласса. В примере с иерархией пользователей мы использовали выделение суперкласса.

Если разобрать изменения по шагам, то на первом шаге, чтобы не нарушать LSP, нам бы пришлось сделать Guest отдельным классом. Так мы бы избавились от усиления предусловий в методе updateProfile, но класс Guest начал бы дублировать функциональность, описанную в User:

// Класс не наследуется от `User`;
// метода `updateProfile` уже нет,
// но есть дублирование функциональности:

class Guest {
  constructor() {
    // ...
  }

  getSessionID(): ID {
    return this.sessID
  }

  hasAccess(action: Actions): boolean {
    // ...
    return access
  }
}

На втором шаге, чтобы избавиться от дублирования, мы бы применили выделение суперкласса. Класс BaseUser — и есть выделенный суперкласс.

Вопросы

Композиция, изменение модели наследования

Среди порождающих и структурных паттернов можно условно выделить группу таких, которые используют композицию свойств и методов. Это, например, стратегия и декоратор, которые мы рассмотрели в разделе OCP.

Кроме них «исключить сюрпризы» помогает изменение модели наследования. Не всегда иерархия объектов в программной системе должна копировать иерархию их в реальном мире. ООП — про отношение между сущностями и их поведение, поэтому модель наследования должна зависеть именно от поведения объектов.

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