Антипаттерны и запахи

Неправильная или неполная реализация некоторых шаблонов проектирования или приёмов, а также неправильная иерархия сущностей могут нарушить LSP.

Непредсказуемое изменение поведения

Допустим, мы делаем клон Медиума, где авторы будут публиковать статьи. Статья может находиться в разных состояниях, от которых зависит, что с ней можно делать. Например, удалённую статью нельзя удалить, а опубликованную — снова опубликовать.

Схема доступных действий со статьёй в зависимости от её состояния

Для подобной задачи подходит шаблон Состояние — он позволяет менять поведение объектов в зависимости от их внутреннего состояния. Если он реализован правильно и полно, то LSP он не нарушит. Однако реализация в примере ниже нарушает.

Допустим, статья описывается базовым классом Article:

enum ArticleStatus {
  Draft
  Published
  Deleted
}

class Article {
  status: ArticleStatus

  constructor() {/*...*/}
  edit() {/*...*/}
  delete() {/*...*/}
  restore() {/*...*/}
  unpublish() {/*...*/}

  publish(): void {
    this.status = ArticleStatus.Published
  }
}

Если опубликованная статья при попытке публикации выбрасывает исключение, которое не было описано в базовом классе, то мы нарушаем LSP:

class Published extends Article {
  constructor() {
    super({ status: ArticleStatus.Published })
  }

  publish(): void {
    // Упс!
    throw new Error('article is already published')
  }
}

Чтобы реализация шаблона не нарушала LSP, нам необходимо описать в базовом классе возможность выбросить исключение. Для этого мы введём метод, который будет определять, можно ли статью публиковать:

class ArticleException extends Error {/*...*/}

class Article {
  // ...

  protected canPublish(): boolean {
    return this.status === ArticleStatus.Draft
  }

  publish(): void {
    if (!this.canPublish()) throw new ArticleException()
    // ...
  }
}

Сейчас переопределение метода publish для опубликованной статьи не будет усиливать предусловия, поэтому это не нарушит LSP:

class ArticlePublishedException extends ArticleException {/*...*/}

class Published extends Article {
  // ...

  publish(): void {
    // `ArticlePublishedException` наследуется от `ArticleException`,
    // указанного в классе `Article`, поэтому здесь нарушения нет:

    throw new ArticlePublishedException()
  }
}

Вопросы

Интерфейс, которому нельзя доверять

Более тонкое нарушение LSP — это «пустая» реализация интерфейса.

Если опереться на пример выше, то пустой реализацией было бы описание метода Publish для опубликованной статьи таким образом:

class Published extends Article {
  // ...

  publish(): void {
    return
  }
}

Вроде всё хорошо: метод описывает правильное поведение, усиления предусловия нет. Но если посмотреть на ситуацию в терминах контрактного программирования, то метод publish должен менять состояние статьи с Draft на Published, чего не будет происходить:

class Article {
  // ...

  publish(): void {
    // Проверяем, что состояние позволяет публиковать статью
    // именно эта проверка в `Published` вызовет ошибку:

    this.contract.require(this.canPublish() === true)

    // ...

    // Проверяем, что состояние поменялось на `Published`:
    this.contract.ensure(this.status === ArticleStatus.Published)
  }
}

«Пустая» реализация интерфейса также нарушает принцип разделения интерфейса.

Вопросы

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