Принцип подстановки Лисков (Liskov Substitution Principle)
Подтип должен работать везде, где ждут базовый тип: хранилище только для чтения не наследует запись, которую не умеет выполнять.
Проблема
Есть хранилище настроек с чтением и записью. Понадобилось хранилище, которое только читает снимок конфига, и его сделали наследником с "отключённой" записью:
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, без новых исключений.
Структура
Иерархия идёт от меньшего контракта к большему: каждый писатель умеет читать, но не каждый читатель умеет писать. Поэтому MemoryStorage подходит и FeatureFlags, и FeatureSwitch, а ReadOnlyStorage - только первому.
Как это собирается
В приложении два источника флагов: снимок конфига, разобранный при деплое (ReadOnlyStorage), и таблица ручных переопределений (MemoryStorage в примере). FeatureFlags создаётся над любым из них - коду, который спрашивает "включена ли тёмная тема", всё равно, откуда ответ. Админка получает FeatureSwitch, а ему при сборке можно передать только записываемое хранилище: передать туда конфиг не получится ещё до запуска. В примере флаги читаются из обоих источников, затем админ включает beta в переопределениях, и FeatureFlags над тем же хранилищем видит изменение.
Сценарий целиком с выводом: пример на PHP, он же на Go, Python и TypeScript в панели кода.
Реализации
| Язык | Особенность |
|---|---|
| PHP | interface WritableStorage extends ReadableStorage; ReadOnlyStorage - final readonly class без put |
| Go | Встраивание интерфейсов, как io.ReadWriter; поиск возвращает (string, bool) вместо null |
| Python | Protocol с @runtime_checkable; снимок хранится в MappingProxyType - словаре, который нельзя изменить |
| TypeScript | interface 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.
Связанные темы
- SOLID - все пять принципов
- Принцип разделения интерфейсов - узкие контракты, которые делают подстановку честной
- Репозиторий - типичное место, где появляются read-only и read-write варианты хранилища
- Заместитель - обёртка, которая должна быть подставима вместо оригинала