Наследование и интерфейсы

ООП в PHP даёт три механизма повторного использования: наследование (extends), интерфейсы (implements), трейты (следующий урок). У каждого свой случай - путаница начинается, когда их применяют невпопад. Базу классов и полей мы разобрали в уроке 13.

Базовое наследование

<?php
class Animal
{
    public function __construct(public readonly string $name) {}

    public function describe(): string
    {
        return "Это {$this->name}";
    }
}

class Dog extends Animal
{
    public function bark(): string
    {
        return "{$this->name}: Гав!";
    }
}

$rex = new Dog('Рекс');
echo $rex->describe(); // Это Рекс
echo $rex->bark();     // Рекс: Гав!

Dog extends Animal - Dog получает все public/protected члены Animal. private поля родителя НЕ видны в наследнике - это часть инкапсуляции.

Переопределение методов и parent::

<?php
class Animal
{
    public function describe(): string
    {
        return "Это {$this->name}";
    }
}

class Dog extends Animal
{
    public function describe(): string
    {
        return parent::describe() . ', это собака';
    }
}

echo (new Dog('Рекс'))->describe(); // Это Рекс, это собака

parent::describe() - вызов родительской версии. Сигнатуру (типы аргументов, возвращаемый тип) переопределять нужно совместимо - PHP проверяет это и бросает FatalError, если потомок сужает контракт.

Конструктор в наследнике

Если у наследника свой __construct, PHP не вызывает родительский автоматически - это твоя ответственность:

<?php
class Animal
{
    public function __construct(public readonly string $name) {}
}

class Dog extends Animal
{
    public function __construct(
        string $name,
        public readonly string $breed,
    ) {
        parent::__construct($name);
    }
}

$rex = new Dog('Рекс', 'Лабрадор');
echo $rex->name;  // Рекс
echo $rex->breed; // Лабрадор

Забыл parent::__construct - поле $name останется неинициализированным, и при обращении упадёт Error: Typed property not initialized.

Абстрактные классы

abstract - нельзя создать (new запрещён), наследники обязаны реализовать абстрактные методы:

<?php
abstract class Shape
{
    abstract public function area(): float;

    public function describe(): string
    {
        return sprintf('Площадь: %.2f', $this->area());
    }
}

class Circle extends Shape
{
    public function __construct(private readonly float $radius) {}

    public function area(): float
    {
        return M_PI * $this->radius ** 2;
    }
}

class Square extends Shape
{
    public function __construct(private readonly float $side) {}

    public function area(): float
    {
        return $this->side ** 2;
    }
}

$shapes = [new Circle(5), new Square(3)];
foreach ($shapes as $s) {
    echo $s->describe() . PHP_EOL;
}
// Площадь: 78.54
// Площадь: 9.00

Shape - шаблон с общим поведением (describe()) и пустым местом (area()), которое каждая фигура заполняет по-своему. Это Template Method - паттерн поведения, см. трек GoF.

Интерфейсы

Интерфейс - контракт без реализации. Класс «обещает» иметь определённые методы:

<?php
interface Logger
{
    public function info(string $message): void;
    public function error(string $message): void;
}

class ConsoleLogger implements Logger
{
    public function info(string $message): void
    {
        echo "[INFO] $message" . PHP_EOL;
    }

    public function error(string $message): void
    {
        fwrite(STDERR, "[ERROR] $message" . PHP_EOL);
    }
}

class FileLogger implements Logger
{
    public function __construct(private readonly string $path) {}

    public function info(string $message): void
    {
        file_put_contents($this->path, "[INFO] $message\n", FILE_APPEND);
    }

    public function error(string $message): void
    {
        file_put_contents($this->path, "[ERROR] $message\n", FILE_APPEND);
    }
}

Теперь любая функция может принимать «любой Logger», не зная конкретного типа:

<?php
function processOrder(Logger $log, int $orderId): void
{
    $log->info("Обработка заказа #$orderId");
    // ...
}

processOrder(new ConsoleLogger(), 42);
processOrder(new FileLogger('/var/log/app.log'), 43);

Это dependency inversion: код зависит от абстракции (Logger), а не от конкретики (ConsoleLogger). Завтра ты добавишь SyslogLogger - processOrder не изменится. На этом принципе построены PSR-стандарты и DI-контейнер.

Полиморфизм: имя у принципа, который ты уже применил

То, что произошло в двух последних примерах, называется полиморфизм («много форм») - третий из классических принципов ООП, вместе с инкапсуляцией (урок 13) и наследованием. Определение: код работает с объектами через общий тип (интерфейс или базовый класс), а какая именно реализация выполнится - решается в рантайме по реальному классу объекта.

Смотри, где он уже сработал:

  • $s->describe() в цикле по Shape[] вызывал разные area() - у Circle свою, у Square свою. Один вызов, много форм поведения.
  • processOrder(Logger $log, ...) не знает и не хочет знать, пишет $log в консоль или файл.

Это подтиповой полиморфизм - главный вид в PHP. Есть и второй: параметрический (обобщённый код через шаблоны в PHPDoc: @template T, коллекции array<T> в psalm/phpstan), но в рантайме PHP его нет - только на уровне статического анализа.

«Назовите принципы ООП» - ждут: **инкапсуляция** (скрытие деталей за контрактом, [урок 13](./13-oop-classes.md)), **наследование** (`extends`, этот урок), **полиморфизм** (эта секция). Часто добавляют четвёртый - **абстракцию**: выделение существенного в интерфейс/абстрактный класс, отбрасывание деталей. Сильный ответ - не определения, а по примеру на каждый принцип из своего кода.

Практическое следствие полиморфизма - Liskov Substitution Principle (буква L в SOLID): наследник обязан быть подставим вместо родителя без сюрпризов. Если Square extends Rectangle ломает ожидания кода, работавшего с Rectangle, - иерархия спроектирована неверно, сколько бы «логичной» она ни казалась.

Множественные интерфейсы

Класс может реализовать сколько угодно интерфейсов:

<?php
interface Countable
{
    public function count(): int;
}

interface JsonSerializable
{
    public function jsonSerialize(): mixed;
}

class Cart implements Countable, JsonSerializable
{
    private array $items = [];

    public function add(string $item): void { $this->items[] = $item; }
    public function count(): int { return count($this->items); }
    public function jsonSerialize(): mixed { return $this->items; }
}

Countable и JsonSerializable - настоящие интерфейсы из PHP-стандартной библиотеки: count($cart) и json_encode($cart) будут работать автоматически.

final: запрет наследования

<?php
final class UserId
{
    public function __construct(public readonly string $value) {}
}

// class CustomUserId extends UserId {} // Error: Class cannot extend final

final стоит ставить по умолчанию для классов, которые не задумывались как базовые. Это защищает: если кто-то наследует твой класс ради «маленькой правки», он может сломать инварианты, которые ты гарантировал. Открытые для наследования классы - это публичный контракт, который тяжело менять.

Когда наследовать, а когда - нет

СитуацияРешение
Несколько классов с общим поведениемАбстрактный класс
Несколько классов с общим контрактом, но разной реализациейИнтерфейс
Нужен «миксин» - кусок кода в нескольких классах без is-a связиTrait (урок 15)
Хочется переиспользовать код одного класса в другом, не связанномКомпозиция (поле + делегирование), не наследование

Правило большого пальца - composition over inheritance. Наследование заводит в тупик, когда иерархия растёт: class AdminUser extends LoggedInUser extends GuestUser extends User - это путь в ад. Композиция (class User { private Authentication $auth; }) гибче.

Как это в Symfony

Один из самых известных интерфейсов Symfony - Symfony\Component\EventDispatcher\EventSubscriberInterface. Любой класс с этим интерфейсом автоматически подписывается на события (через autoconfigure):

<?php
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

final class UserRegisteredSubscriber implements EventSubscriberInterface
{
    public static function getSubscribedEvents(): array
    {
        return [
            UserRegistered::class => 'onUserRegistered',
        ];
    }

    public function onUserRegistered(UserRegistered $event): void
    {
        // отправить welcome-email и т.п.
    }
}

Контейнер Symfony при старте проходит по всем классам с EventSubscriberInterface и регистрирует их в EventDispatcher. Интерфейс работает как «магический тег» - это и есть «программирование от контрактов».

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

  1. Глубокие иерархии (3+ уровня). Каждый extends добавляет связанности - найти, где определён метод, становится квестом. Сплющивай в композицию.
  2. extends ради экономии 10 строк. Если class B extends A только чтобы переиспользовать helperMethod(), и B не является A логически, - это нарушение Liskov Substitution Principle. Сделай статический хелпер или композицию.
  3. Интерфейс с одним методом, который никогда не реализуют по-другому. Не плоди контракты «на будущее». Интерфейс полезен, когда есть ≥2 реальные реализации.
  4. abstract методы в обычном классе. Так нельзя - PHP бросит FatalError. Если в классе есть abstract, сам класс тоже должен быть abstract.

Best practices

  • Предпочитай композицию наследованию: class User { private Auth $auth; } гибче, чем User extends Auth - иерархию тяжело менять.

  • Помечай классы final по умолчанию - открытый для наследования класс это публичный контракт, который дорого менять.

  • Заводи интерфейс только при наличии двух или более реальных реализаций, не «на будущее» - лишние абстракции усложняют код без пользы.

  • В конструкторе наследника всегда вызывай parent::__construct() - PHP не делает это автоматически, и поля родителя останутся неинициализированными.

  • Иерархия глубже двух уровней - сигнал переработать на композицию: найти метод через три extends это квест.

  • Python - ABC и Protocol: абстрактные классы и duck typing с типами - как те же концепции реализованы в Python: @abstractmethod, Protocol (duck typing)

  • GoF - Паттерны без боли: как не выучить 23 штуки зря - наследование и интерфейсы как основа GoF паттернов

Мини-задание

  • Интерфейс interface Repository { public function findById(int $id): ?array; public function save(array $entity): int; }
  • Реализация InMemoryRepository implements Repository (массив private array $items)
  • Реализация PdoRepository implements Repository (использует PDO из урока 9)
  • Абстрактный class Notifier с abstract send(string $to, string $msg): void и общим методом notifyAdmin(string $msg)
  • Наследники EmailNotifier, SmsNotifier (тела имитируй через echo)
  • Функция function notify(Notifier $n, string $admin, string $msg) - работает для обеих реализаций

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.