Traits, late static binding и магия

В прошлом уроке мы упёрлись в стену: PHP не поддерживает множественное наследование классов. Если двум разным классам нужен один и тот же кусок поведения, нельзя extends A, B. Решение - traits.

Что такое trait

Trait - это «копипаста, но управляемая». Кусок методов/свойств, который встраивается в класс на этапе компиляции:

<?php
trait Timestampable
{
    public ?int $createdAt = null;
    public ?int $updatedAt = null;

    public function touch(): void
    {
        $this->updatedAt = time();
        if ($this->createdAt === null) {
            $this->createdAt = $this->updatedAt;
        }
    }
}

class Article
{
    use Timestampable;

    public function __construct(public string $title) {}
}

class Comment
{
    use Timestampable;

    public function __construct(public string $body) {}
}

$a = new Article('Привет');
$a->touch();
echo $a->createdAt; // unix-timestamp

use Timestampable; встраивает поля и методы трейта в класс. Это не наследование: Article не «является» Timestampable. Поэтому instanceof Timestampable не работает (для этого нужен интерфейс из урока 14).

Зачем нужны traits

Главный кейс - горизонтальная композиция: одна способность нужна в нескольких несвязанных классах.

<?php
trait Loggable
{
    private array $log = [];

    public function logEvent(string $event): void
    {
        $this->log[] = ['ts' => time(), 'event' => $event];
    }

    public function getLog(): array
    {
        return $this->log;
    }
}

class Order
{
    use Loggable;
}

class Payment
{
    use Loggable;
}

Без trait пришлось бы либо копипастить, либо городить искусственный class LoggableEntity в иерархии, который не имеет смысла.

Trait + interface = настоящий контракт

Trait - реализация без контракта. Интерфейс - контракт без реализации. Вместе - золотая середина:

<?php
interface LogsEvents
{
    public function logEvent(string $event): void;
    public function getLog(): array;
}

trait LoggableTrait
{
    private array $log = [];
    public function logEvent(string $event): void { $this->log[] = $event; }
    public function getLog(): array { return $this->log; }
}

class Order implements LogsEvents
{
    use LoggableTrait;
}

function audit(LogsEvents $entity): void
{
    foreach ($entity->getLog() as $event) {
        echo $event . PHP_EOL;
    }
}

audit(new Order()); // работает: Order реализует контракт через trait

Теперь audit() принимает LogsEvents без знаний о трейте. Это распространённый паттерн в Symfony и Doctrine.

Конфликты имён

Если два трейта определяют метод с одним именем - PHP бросит ошибку. Разруливается через insteadof и as:

<?php
trait A
{
    public function hello(): string { return 'A says hi'; }
}

trait B
{
    public function hello(): string { return 'B says hi'; }
}

final class Greeter
{
    use A, B {
        A::hello insteadof B;  // используем версию A
        B::hello as helloB;     // версия B доступна как helloB()
    }
}

$g = new Greeter();
echo $g->hello();  // A says hi
echo $g->helloB(); // B says hi

На практике до этого редко доходит - конфликт обычно сигнал, что трейты делают слишком много.

Late Static Binding (LSB)

Без LSB статические методы в наследовании ломаются. Сравни:

<?php
class ModelBad
{
    public static function create(): self
    {
        return new self(); // self - всегда ModelBad
    }
}

class User extends ModelBad {}

$u = User::create();
echo get_class($u); // ModelBad (а хотели User)

self зашит на класс, где написан - ModelBad. PHP не знает, что мы вызываем через User. Решение - static (late static binding):

<?php
class Model
{
    public static function create(): static
    {
        return new static(); // static - реальный класс вызова
    }
}

class User extends Model {}

$u = User::create();
echo get_class($u); // User

Возвращаемый тип static (PHP 8.0+) - гарантирует наследникам, что User::create() вернёт User, а не «голый» Model. Без LSB фабрики и query-builder'ы не работают через наследование.

Magic-методы

PHP вызывает «волшебные» методы автоматически в определённых ситуациях. Самые важные:

<?php
final class Config
{
    public function __construct(private readonly array $data) {}

    // вызывается при $cfg->some_property
    public function __get(string $name): mixed
    {
        return $this->data[$name] ?? null;
    }

    // вызывается при $cfg->some_property = ...
    public function __set(string $name, mixed $value): void
    {
        $this->data[$name] = $value;
    }

    // вызывается при isset($cfg->some_property)
    public function __isset(string $name): bool
    {
        return isset($this->data[$name]);
    }

    // вызывается при $cfg->someMethod(...)
    public function __call(string $method, array $args): mixed
    {
        if (str_starts_with($method, 'get')) {
            $key = lcfirst(substr($method, 3));
            return $this->data[$key] ?? null;
        }
        throw new BadMethodCallException("Unknown method: $method");
    }

    // вызывается при (string)$cfg или "$cfg"
    public function __toString(): string
    {
        return json_encode($this->data);
    }
}

$cfg = new Config(['name' => 'app', 'port' => 8080]);
echo $cfg->name;       // app   (__get)
echo $cfg->getPort();  // 8080  (__call)
echo $cfg;             // {"name":"app","port":8080}  (__toString)
`__get`/`__call` ломают автодополнение IDE и статический анализ: ты не видишь, какие методы у объекта есть, пока не запустишь код. Используй магию только в библиотечном коде (ORM, fluent-builder, конфиги) - в бизнес-логике делай явные методы.

Здесь мы разобрали только «перегрузочную» магию. Полный арсенал - __clone и ловушка shallow copy, __toString/Stringable, безопасная сериализация через __serialize, цена магии в производительности - в отдельном уроке про магические методы.

__invoke: объект как функция

<?php
final class PriceFormatter
{
    public function __construct(private readonly string $currency) {}

    public function __invoke(int $cents): string
    {
        return sprintf('%.2f %s', $cents / 100, $this->currency);
    }
}

$format = new PriceFormatter('RUB');
echo $format(1999); // 19.99 RUB
echo $format(100);  // 1.00 RUB

__invoke превращает объект в callable. Это используют для action-классов (одно действие = один класс) в Symfony контроллерах и middleware.

Как это в Symfony

В Symfony трейты - рабочая лошадка. Самый известный - ContainerAwareTrait (исторический; сейчас autowiring):

<?php
// упрощённый Symfony\Bundle\FrameworkBundle\Controller\AbstractController
abstract class AbstractController
{
    use TargetPathTrait;  // навигация после логина
}

И invokable controllers через __invoke - главный паттерн single-action controllers:

<?php
#[Route('/users/{id}', methods: ['GET'])]
final class ShowUserController
{
    public function __construct(private readonly UserRepository $users) {}

    public function __invoke(int $id): Response
    {
        $user = $this->users->find($id) ?? throw new NotFoundHttpException();
        return new JsonResponse($user);
    }
}

Один класс - одно действие, один публичный метод (__invoke). Меньше связности, проще тесты, проще DI.

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

  1. Trait вместо интерфейса. Trait - реализация. Если тип ожидает «что-то с методом logEvent» - это интерфейс, а trait - лишь удобный способ его реализовать.
  2. Trait как способ протащить состояние между классами. Trait встраивается копированием - каждое поле в каждом классе своё, общего «глобального» состояния не получится.
  3. __call ради «динамического API». Хочешь fluent-builder с произвольными вызовами - оборачивай явные методы, не делай $user->whereNameAndEmailIs(...).
  4. self:: там, где нужен static::. В фабриках и абстрактных методах используй static::class, static::create(), static::method(). Иначе наследование молча сломается.

Best practices

  • Объединяй trait и interface: trait даёт реализацию, interface - контракт для type hints и тестовых моков.
  • В фабричных методах и наследовании используй static:: вместо self:: - иначе наследник получит экземпляр родительского класса.
  • Ограничивай __get, __call и другие magic-методы библиотечным кодом (ORM, билдеры): в бизнес-логике пиши явные методы, IDE не видит «магию».
  • Предпочитай __invoke для single-action классов - один класс, один публичный метод, легко тестировать и инжектировать.
  • Конфликт имён между двумя трейтами - сигнал, что каждый трейт делает слишком много; рассмотри разбивку на меньшие.

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

  • Trait Hashable с методом hash(): string (через md5(serialize($this->toArray()))); интерфейс Hashable с hash(): string и toArray(): array
  • Два класса User и Article реализуют Hashable через trait
  • Класс QueryBuilder с фабрикой static::for(string $table): static и методом where(). Наследник UserQueryBuilder - UserQueryBuilder::for('users') должен вернуть UserQueryBuilder
  • Invokable-класс HttpClient с __invoke(string $url): string (внутри file_get_contents), используется как функция: $http = new HttpClient(); $http('https://example.com');
  • Класс Settings с __get/__set/__isset поверх массива; убедись, что isset($s->missing) возвращает false

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