handbookНаписать

principles/solid/liskov-substitution/README.md · тема · 4 языка · 19 тестов

Принцип подстановки Лисков (Liskov Substitution Principle) ​

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

Проблема ​

Есть хранилище настроек с чтением и записью. Понадобилось хранилище, которое только читает снимок конфига, и его сделали наследником с "отключённой" записью:

php
class Storage
{
    /** @var array<string, string> */
    protected array $values = [];

    public function find(string $key): ?string
    {
        return $this->values[$key] ?? null;
    }

    public function put(string $key, string $value): void
    {
        $this->values[$key] = $value;
    }
}

final class ReadOnlyStorage extends Storage
{
    public function put(string $key, string $value): void
    {
        throw new LogicException('storage is read-only');
    }
}

function turnOn(Storage $storage, string $feature): void
{
    $storage->put("feature.{$feature}", 'on'); // LogicException, if a ReadOnlyStorage came in
}

Тип говорит "это Storage, у него есть put", а объект на вызове put падает. Ошибка всплывает не при сборке и не в статическом анализе, а в продакшене, в том месте, которое ни о каком ReadOnlyStorage не знает. Чтобы защититься, вызывающий код начинает проверять instanceof ReadOnlyStorage - и наследование теряет смысл: подтип нельзя подставить вместо родителя, не зная, кто он.

Решение ​

Наследовать только то, что подтип действительно выполняет. Контракт делится на два: ReadableStorage умеет find, WritableStorage расширяет его методом put. MemoryStorage реализует оба, ReadOnlyStorage - только чтение. Клиенты просят ровно то, что им нужно: FeatureFlags читает и принимает любой ReadableStorage, FeatureSwitch пишет и требует WritableStorage.

Теперь "только для чтения" выражено типом, а не исключением. Передать снимок конфига в FeatureSwitch не даёт компилятор или статический анализ, а любой ReadableStorage честно выполняет контракт чтения: найденное значение возвращается, отсутствующее - null, без новых исключений.

Структура ​

«interface»

ReadableStorage

+find(key) : string?

«interface»

WritableStorage

+put(key, value)

MemoryStorage

ReadOnlyStorage

FeatureFlags

+enabled(feature) : bool

FeatureSwitch

+turnOn(feature)

Иерархия идёт от меньшего контракта к большему: каждый писатель умеет читать, но не каждый читатель умеет писать. Поэтому MemoryStorage подходит и FeatureFlags, и FeatureSwitch, а ReadOnlyStorage - только первому.

Как это собирается ​

В приложении два источника флагов: снимок конфига, разобранный при деплое (ReadOnlyStorage), и таблица ручных переопределений (MemoryStorage в примере). FeatureFlags создаётся над любым из них - коду, который спрашивает "включена ли тёмная тема", всё равно, откуда ответ. Админка получает FeatureSwitch, а ему при сборке можно передать только записываемое хранилище: передать туда конфиг не получится ещё до запуска. В примере флаги читаются из обоих источников, затем админ включает beta в переопределениях, и FeatureFlags над тем же хранилищем видит изменение.

Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.

Реализации ​

ЯзыкОсобенность
PHPinterface WritableStorage extends ReadableStorage; ReadOnlyStorage - final readonly class без put
GoВстраивание интерфейсов, как io.ReadWriter; поиск возвращает (string, bool) вместо null
PythonProtocol с @runtime_checkable; снимок хранится в MappingProxyType - словаре, который нельзя изменить
TypeScriptinterface WritableStorage extends ReadableStorage, снимок - ReadonlyMap; передача в писателя ловится tsc

Запуск: make test T=principles/solid/liskov-substitution

Плюсы и минусы ​

Главный тест - контрактный: один и тот же набор проверок прогоняется на каждой реализации ReadableStorage, и обе его проходят. Так и проверяется подстановка: тест не знает, какое хранилище ему дали. Второй тест показывает, что ReadOnlyStorage не реализует WritableStorage и не имеет put вовсе, то есть сломать запись ему нечем. В TypeScript попытка передать его в FeatureSwitch помечена @ts-expect-error: если компилятор вдруг её примет, упадёт tsc.

  • ✅ Ошибка "записали в read-only" ловится при сборке, а не в продакшене.
  • ✅ Код, работающий с базовым типом, не проверяет, какой подтип ему пришёл.
  • ✅ Контрактные тесты на базовый тип автоматически проверяют каждую новую реализацию.
  • ❌ Иерархия интерфейсов вместо одного класса: больше типов.
  • ❌ Если язык или фреймворк навязывает общий базовый класс (ORM, SDK), разделить контракт бывает нельзя - остаётся документировать и тестировать.

Когда не применять ​

  • Ограничение действительно временное и динамическое (хранилище стало read-only на время миграции) - это состояние, его честнее выразить исключением и проверкой isWritable(), а не типом.
  • Наследования нет и не планируется: принцип касается подтипов, без них ему нечего проверять.
  • Метод в подтипе намеренно делает меньше, но в рамках контракта (кэш, который может вернуть null, если контракт это разрешает) - это не нарушение.

Отличия между языками ​

  • PHP - нарушение LSP с исключением в put статический анализ не видит, а с разделёнными интерфейсами PHPStan ловит передачу ReadOnlyStorage в FeatureSwitch. Отсутствие записи тест проверяет через ReflectionClass.
  • Go - наследования классов нет, есть только встраивание интерфейсов. Тип либо имеет метод Put, либо нет, и "заглушить" метод у родителя не получится. Проверка any(x).(WritableStorage) в тесте показывает, что read-only хранилище под запись не подходит.
  • Python - типы проверяются только mypy или pyright, поэтому @runtime_checkable даёт проверку и во время работы. MappingProxyType делает снимок неизменяемым даже для кода внутри класса.
  • TypeScript - структурная типизация: лишний метод put, бросающий исключение, сделал бы ReadOnlyStorage "совместимым" с писателем. Без метода компилятор отказывает, что и проверяет @ts-expect-error.

Связанные темы ​

© 2014-2026 Тимур ТурдыевСайт и справочник работают на VPS от AdminVPS