Наследование как отношение «является»
Наследование в PHP связывает классы через extends: дочерний класс получает public и protected методы, свойства и константы родителя и может переопределять часть поведения. Это удобно, когда потомок действительно является более конкретным видом родителя: PdfInvoice является Invoice, MemoryCache является реализацией кэша, AdminUser является пользователем только если все правила обычного пользователя к нему тоже применимы.
<?php
declare(strict_types=1);
class Notification
{
public function __construct(protected string $recipient) {}
public function recipient(): string
{
return $this->recipient;
}
public function channel(): string
{
return 'generic';
}
}
final class EmailNotification extends Notification
{
public function channel(): string
{
return 'email';
}
}
$message = new EmailNotification('admin@example.test');
echo $message->recipient(); // admin@example.test
echo $message->channel(); // emailВ отличие от Классы, объекты и видимость, где главный акцент был на инкапсуляции одного класса, здесь важен контракт между родителем и потомком. Потомок не должен ломать ожидания кода, который работает с родителем. Если функция принимает Notification, ей должно быть всё равно, пришёл Notification или EmailNotification.
classDiagram
class PaymentGateway {
<<interface>>
+charge(int amount, string currency) PaymentResult
}
class StripeGateway
class FakeGateway
class CheckoutService {
-PaymentGateway gateway
+pay() PaymentResult
}
class ReportExporter {
<<abstract>>
+export(array rows) string
#render(array rows) string
}
class JsonReportExporter
class HasTimestamps {
<<trait>>
+markCreated(DateTimeImmutable time) void
+createdAt() DateTimeImmutable
}
PaymentGateway <|.. StripeGateway
PaymentGateway <|.. FakeGateway
CheckoutService --> PaymentGateway : composition
ReportExporter <|-- JsonReportExporter : extends
HasTimestamps ..> JsonReportExporter : use, если нужен код traitabstract и final: где расширять, а где закрывать
abstract class нельзя создать через new. Она задаёт общую основу и оставляет часть деталей потомкам. Абстрактный метод объявляет сигнатуру без тела; конкретный потомок обязан дать реализацию с совместимой сигнатурой.
<?php
declare(strict_types=1);
abstract class ReportExporter
{
final public function export(array $rows): string
{
$normalized = $this->normalize($rows);
return $this->render($normalized);
}
protected function normalize(array $rows): array
{
return array_values($rows);
}
abstract protected function render(array $rows): string;
}
final class JsonReportExporter extends ReportExporter
{
protected function render(array $rows): string
{
return json_encode($rows, JSON_THROW_ON_ERROR);
}
}Здесь export() закрыт через final, потому что это общий алгоритм: сначала нормализация, потом рендеринг. Потомкам оставлена точка расширения render(). Такой паттерн полезен, но им легко злоупотребить: если базовый класс начинает знать слишком много о потомках, иерархия становится хрупкой.
final class запрещает наследование от класса. final у метода запрещает его переопределение. В современных версиях PHP final также встречается у констант и свойств класса. На практике final часто ставят у доменных value object, сервисов без планируемых наследников и классов, которые должны расширяться через композицию, а не через подмену внутренних шагов.
Интерфейсы: контракт без реализации
Интерфейс объявляется через interface, а класс подключает его через implements. Все методы интерфейса публичные. Один класс может реализовать несколько интерфейсов, а один интерфейс может расширять другие интерфейсы.
<?php
declare(strict_types=1);
interface PaymentGateway
{
public function charge(int $amount, string $currency): PaymentResult;
}
final class PaymentResult
{
public function __construct(public readonly string $id) {}
}
final class StripeGateway implements PaymentGateway
{
public function charge(int $amount, string $currency): PaymentResult
{
return new PaymentResult('stripe_123');
}
}
final class FakeGateway implements PaymentGateway
{
public function charge(int $amount, string $currency): PaymentResult
{
return new PaymentResult('fake_123');
}
}
final class CheckoutService
{
public function __construct(private PaymentGateway $gateway) {}
public function pay(): PaymentResult
{
return $this->gateway->charge(1200, 'RUB');
}
}Код CheckoutService зависит не от конкретного Stripe-класса, а от обещания: «у объекта есть метод charge() с такой сигнатурой». Это сильно помогает в тестах из PHPUnit, моки и стабы, при замене провайдера и при автозагрузке классов через Autoloading и PSR-4.
Есть практичная мелочь для PHP 8+: имена параметров в реализации лучше держать такими же, как в интерфейсе. Из-за named arguments вызывающий код может ссылаться на имя параметра, и несовпадение имён превращает «безобидную» разницу в источник багов.
Совместимость сигнатур и variance
Когда класс переопределяет метод родителя или реализует интерфейс, PHP проверяет совместимость сигнатур. Главное правило: потомок должен быть пригоден там, где ожидали родителя или интерфейс.
Возвращаемый тип можно сузить — это ковариантность. Если интерфейс обещает Document, реализация может вернуть PdfDocument, потому что PdfDocument всё ещё является Document.
<?php
class Document {}
final class PdfDocument extends Document {}
interface DocumentFactory
{
public function create(): Document;
}
final class PdfFactory implements DocumentFactory
{
public function create(): PdfDocument
{
return new PdfDocument();
}
}Тип параметра можно расширить — это контравариантность. Если родительский метод принимает PdfDocument, потомок может принимать более общий Document: он готов обработать всё, что точно могло прийти раньше.
<?php
class Printer
{
public function print(PdfDocument $document): void {}
}
final class UniversalPrinter extends Printer
{
public function print(Document $document): void {}
}Со свойствами строже: обычные свойства в наследовании в основном инвариантны, то есть тип нельзя просто «чуть сузить» или «чуть расширить». И ещё одно правило видимости: потомок может ослабить видимость метода, например сделать protected метод публичным, но не должен сужать публичный метод до private. Конструкторы — отдельный особый случай.
Trait: переиспользование кода, не родство
trait решает другую задачу: он позволяет вставить методы, свойства или константы в класс без наследования. Класс может использовать несколько трейтов. Это не «множественное наследование»: trait не задаёт полноценное отношение «является», а просто даёт кусок реализации.
<?php
declare(strict_types=1);
trait HasTimestamps
{
private DateTimeImmutable $createdAt;
public function markCreated(DateTimeImmutable $time): void
{
$this->createdAt = $time;
}
public function createdAt(): DateTimeImmutable
{
return $this->createdAt;
}
}
final class Article
{
use HasTimestamps;
}Порядок приоритета такой: метод в самом классе важнее метода из trait, а метод из trait важнее унаследованного метода родителя. Если два trait приносят метод с одним именем, PHP требует явно разрешить конфликт через insteadof; через as можно добавить alias или изменить видимость импортированного метода.
<?php
trait WritesJson
{
public function format(): string
{
return 'json';
}
}
trait WritesXml
{
public function format(): string
{
return 'xml';
}
}
final class FeedFormatter
{
use WritesJson, WritesXml {
WritesJson::format insteadof WritesXml;
WritesXml::format as xmlFormat;
}
}
$formatter = new FeedFormatter();
echo $formatter->format(); // json
echo $formatter->xmlFormat(); // xmlTrait полезен для небольшого общего поведения: timestamps, soft delete, логирование событий, повторяемые фабричные методы. Если trait требует от класса много скрытых свойств и методов, это уже запах: зависимость есть, но контракт не виден. В таких местах лучше посмотреть на интерфейс, абстрактный класс или отдельный сервис.
Композиция против наследования
Композиция означает «объект содержит другой объект». Наследование говорит «объект является разновидностью другого объекта». В PHP-проектах композиция часто оказывается проще: меньше скрытой связи, легче тестировать, проще менять детали.
<?php
interface Slugger
{
public function slug(string $text): string;
}
final class SimpleSlugger implements Slugger
{
public function slug(string $text): string
{
return strtolower(str_replace(' ', '-', trim($text)));
}
}
final class Post
{
public function __construct(
private string $title,
private Slugger $slugger,
) {}
public function slug(): string
{
return $this->slugger->slug($this->title);
}
}Post не наследуется от SimpleSlugger, потому что пост не является slugger-ом. Он использует slugger. Это обычная граница между моделью и сервисом. В больших кодовых базах такая граница читается лучше, особенно если стиль оформления классов выдержан по PSR-1, PSR-12 и стиль кода, а дополнительные ограничения описаны через PHPDoc, generics и статический анализ.
Короткая эвристика: интерфейс — когда нужен контракт для разных реализаций; абстрактный класс — когда есть общий алгоритм и контролируемые точки расширения; trait — когда нужен небольшой переиспользуемый кусок реализации; композиция — когда объекту нужен помощник; final — когда расширение не является частью публичного дизайна.
См. также
- Классы, объекты и видимость — базовые свойства, методы,
static,readonlyи сравнение объектов. - Неймспейсы и use — как имена классов, интерфейсов и trait живут в namespace.
- Autoloading и PSR-4 — как классы и интерфейсы подгружаются без ручных
require. - Enum в PHP — когда набор вариантов лучше выразить enum, а не иерархией классов.
- Атрибуты и Reflection — как во время выполнения читать интерфейсы, родителей, trait и атрибуты класса.
- PHPDoc, generics и статический анализ — как анализаторы находят проблемы в наследовании и контрактах до запуска кода.
- PSR-1, PSR-12 и стиль кода — как форматировать объявления
extends,implements,useи методы в общем стиле PHP-проектов.