SOLID: LSP — подстановка Лисков
LSP
Liskov Substitution Principle — объекты наследников должны заменять объекты родителя без нарушения работы программы.
Проще: если код работает с Animal, он должен работать и с Dog.
Автор — Барбара Лисков, 1987.
Формулировка
Если
S— подтипT, объекты типаTможно заменить объектами типаSбез изменения корректности программы.
Нарушение
Классика — прямоугольник и квадрат:
class Rectangle
{
public function __construct(
protected int $width,
protected int $height,
) {}
public function setWidth(int $width): void
{
$this->width = $width;
}
public function setHeight(int $height): void
{
$this->height = $height;
}
public function area(): int
{
return $this->width * $this->height;
}
}
class Square extends Rectangle
{
public function setWidth(int $width): void
{
$this->width = $width;
$this->height = $width; // квадрат!
}
public function setHeight(int $height): void
{
$this->width = $height;
$this->height = $height;
}
}
Код, работающий с Rectangle:
function resize(Rectangle $rect): void
{
$rect->setWidth(10);
$rect->setHeight(5);
// ожидаем 50
assert($rect->area() === 50); // падает с Square!
}
Square ломает контракт Rectangle. Нарушение LSP.
Как избежать
1. Не наследуй, если поведение отличается:
interface Shape
{
public function area(): int;
}
final class Rectangle implements Shape
{
public function __construct(
public int $width,
public int $height,
) {}
public function area(): int
{
return $this->width * $this->height;
}
}
final class Square implements Shape
{
public function __construct(public int $side) {}
public function area(): int
{
return $this->side ** 2;
}
}
Не наследуются — оба Shape. LSP не нарушен.
Контракты
LSP — про контракты.
Контракт класса:
- Постусловия — что гарантирует метод.
- Предусловия — что требует от входа.
- Инварианты — что всегда верно.
Наследник может:
- Ослабить предусловия — принимать больше.
- Усилить постусловия — гарантировать больше.
- Сохранить инварианты.
Наследник НЕ может:
- Усиливать предусловия.
- Ослаблять постусловия.
- Ломать инварианты.
Классические нарушения
Бросание новых исключений
class FileReader
{
public function read(string $path): string
{
return file_get_contents($path);
}
}
class JsonFileReader extends FileReader
{
public function read(string $path): string
{
$content = parent::read($path);
if (!json_validate($content)) {
throw new JsonException('Invalid JSON'); // новое исключение
}
return $content;
}
}
Код, работающий с FileReader, не ожидает JsonException. Нарушение LSP.
Ужесточение предусловий
class Calculator
{
public function divide(int $a, int $b): float
{
return $a / $b;
}
}
class StrictCalculator extends Calculator
{
public function divide(int $a, int $b): float
{
if ($b === 0) {
throw new DivisionByZeroError(); // родитель возвращал INF
}
return parent::divide($a, $b);
}
}
Наследник строже родителя. Нарушение.
Ослабление постусловий
class UserRepository
{
public function find(int $id): User
{
// гарантирует User
}
}
class CachedUserRepository extends UserRepository
{
public function find(int $id): User
{
return $this->cache[$id] ?? null; // может быть null
}
}
Наследник может вернуть null вместо User. Нарушение.
Правила
1. Тип возврата можно сузить:
class Base
{
public function get(): Animal { }
}
class Child extends Base
{
public function get(): Dog { } // ok — Dog это Animal
}
2. Тип параметра можно расширить:
class Base
{
public function set(Dog $dog): void { }
}
class Child extends Base
{
public function set(Animal $animal): void { } // ok
}
3. Не бросай новые исключения.
4. Не ослабляй постусловия.
5. Сохраняй инварианты.
Контравариантность / ковариантность
Ковариантность возврата:
interface Factory
{
public function create(): Animal;
}
final class DogFactory implements Factory
{
public function create(): Dog { } // уже — ок
}
Контравариантность параметров:
interface Comparator
{
public function compare(Dog $a, Dog $b): int;
}
final class AnimalComparator implements Comparator
{
public function compare(Animal $a, Animal $b): int { } // шире — ок
}
PHP поддерживает оба (с 7.4).
Практика: Payment
Плохо:
abstract class Payment
{
abstract public function process(int $amount): void;
}
class CardPayment extends Payment
{
public function process(int $amount): void
{
if ($amount > 100000) {
throw new RuntimeException('Лимит карты'); // наследник строже
}
}
}
Хорошо:
interface Payment
{
public function process(int $amount): bool;
public function supports(string $method): bool;
}
final class CardPayment implements Payment
{
public function process(int $amount): bool
{
// карта не ограничивает жёстко
return true;
}
public function supports(string $method): bool
{
return $method === 'card';
}
}
Контракт одинаковый.
Пример: коллекция
Плохо:
class Collection
{
protected array $items = [];
public function add(mixed $item): void
{
$this->items[] = $item;
}
public function count(): int
{
return count($this->items);
}
}
class UniqueCollection extends Collection
{
public function add(mixed $item): void
{
if (in_array($item, $this->items, true)) return; // тихо игнорирует
parent::add($item);
}
}
Код ожидает, что add добавит. UniqueCollection может не добавить. Нарушение.
Хорошо:
interface Collection
{
public function add(mixed $item): bool; // false, если не добавил
public function count(): int;
}
Практика: Shape
<?php
declare(strict_types=1);
interface Shape
{
public function area(): float;
public function perimeter(): float;
}
final class Circle implements Shape
{
public function __construct(private float $radius) {}
public function area(): float
{
return M_PI * $this->radius ** 2;
}
public function perimeter(): float
{
return 2 * M_PI * $this->radius;
}
}
final class Rectangle implements Shape
{
public function __construct(
private float $width,
private float $height,
) {}
public function area(): float
{
return $this->width * $this->height;
}
public function perimeter(): float
{
return 2 * ($this->width + $this->height);
}
}
final class Triangle implements Shape
{
public function __construct(
private float $a,
private float $b,
private float $c,
) {}
public function area(): float
{
$p = $this->perimeter() / 2;
return sqrt($p * ($p - $this->a) * ($p - $this->b) * ($p - $this->c));
}
public function perimeter(): float
{
return $this->a + $this->b + $this->c;
}
}
function totalArea(array $shapes): float
{
return array_sum(array_map(fn(Shape $s) => $s->area(), $shapes));
}
function totalPerimeter(array $shapes): float
{
return array_sum(array_map(fn(Shape $s) => $s->perimeter(), $shapes));
}
$shapes = [
new Circle(1.0),
new Rectangle(2.0, 3.0),
new Triangle(3.0, 4.0, 5.0),
];
echo totalArea($shapes) . "\n";
Все работают как Shape — LSP соблюдён.
Проверка
Тест подстановки:
- Создай родителя и наследника.
- Напиши тест для родителя.
- Прогони с наследником.
- Если тест падает — нарушение LSP.
Или:
- Прочти контракт родителя.
- Проверь, что наследник его не ломает.
Типичные ошибки
- Наследование ради переиспользования кода.
- Классы с разной семантикой, но похожим API.
- Мутация логики в переопределённых методах.
instanceofпроверки, чтобы обойти поведение.
Признак: в коде есть if ($obj instanceof SubClass) — наследник отличается поведением, LSP нарушен.
Итог
- LSP — наследник заменяет родителя.
- Контракт: предусловия, постусловия, инварианты.
- Ослабляй предусловия, усиливай постусловия.
- Не бросай новые исключения.
- Не наследуй, если поведение отличается.
- Композиция решает многие проблемы.
instanceofв коде — красный флаг.
Дальше — SOLID: ISP — разделение интерфейсов.