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)
Здесь мы разобрали только «перегрузочную» магию. Полный арсенал - __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.
Типичные ошибки
- Trait вместо интерфейса. Trait - реализация. Если тип ожидает «что-то с методом
logEvent» - это интерфейс, а trait - лишь удобный способ его реализовать. - Trait как способ протащить состояние между классами. Trait встраивается копированием - каждое поле в каждом классе своё, общего «глобального» состояния не получится.
__callради «динамического API». Хочешь fluent-builder с произвольными вызовами - оборачивай явные методы, не делай$user->whereNameAndEmailIs(...).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