Урок 03

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 соблюдён.

Проверка

Тест подстановки:

  1. Создай родителя и наследника.
  2. Напиши тест для родителя.
  3. Прогони с наследником.
  4. Если тест падает — нарушение LSP.

Или:

  • Прочти контракт родителя.
  • Проверь, что наследник его не ломает.

Типичные ошибки

  1. Наследование ради переиспользования кода.
  2. Классы с разной семантикой, но похожим API.
  3. Мутация логики в переопределённых методах.
  4. instanceof проверки, чтобы обойти поведение.

Признак: в коде есть if ($obj instanceof SubClass) — наследник отличается поведением, LSP нарушен.

Итог

  • LSP — наследник заменяет родителя.
  • Контракт: предусловия, постусловия, инварианты.
  • Ослабляй предусловия, усиливай постусловия.
  • Не бросай новые исключения.
  • Не наследуй, если поведение отличается.
  • Композиция решает многие проблемы.
  • instanceof в коде — красный флаг.

Дальше — SOLID: ISP — разделение интерфейсов.