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