Наследование как отношение «является»

Наследование в 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, если нужен код trait
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, если нужен код trait
Три разные связи в объектной модели PHP: контракт через интерфейс, наследование реализации и вставка поведения через trait.
Быстрое повторение
Когда `EmailNotification extends Notification` — это удачное наследование, а когда такой же приём был бы ошибкой?

abstract и 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, сервисов без планируемых наследников и классов, которые должны расширяться через композицию, а не через подмену внутренних шагов.

Быстрое повторение
Что произойдёт, если попытаться создать объект `abstract class` через `new`?

Интерфейсы: контракт без реализации

Интерфейс объявляется через 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 вызывающий код может ссылаться на имя параметра, и несовпадение имён превращает «безобидную» разницу в источник багов.

Быстрое повторение
Почему `CheckoutService` лучше зависеть от `PaymentGateway`, а не напрямую от `StripeGateway`?

Совместимость сигнатур и 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(); // xml

Trait полезен для небольшого общего поведения: 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 — когда расширение не является частью публичного дизайна.

См. также

Источники

  1. PHP Manual: Object Inheritance
  2. PHP Manual: Object Interfaces
  3. PHP Manual: Traits
  4. PHP Manual: Covariance and Contravariance
  5. PHP Manual: Final Keyword
  6. PHP Manual: Class Abstraction
  7. PHP-FIG: PSR-12 Extended Coding Style