Перейти к содержимому
Фёдор Башинский

Прототип

5 min read
Обложка статьи «Прототип»

Прототип — это порождающий паттерн проектирования, который позволяет копировать объекты, не привязываясь к их конкретным классам, вместо того чтобы создавать их заново.

Проблема

Продолжим автомобильную серию из статьи про Строитель. Допустим, характеристики двигателя вычисляются дорого — например, запрашиваются с внешнего стенда измерений:

class Engine
{
    public int $horsePower;
 
    public function __construct(string $model)
    {
        // Дорогая операция: запрос к внешнему сервису измерений.
        $this->horsePower = DynoService::measure($model);
    }
}
 
class Car
{
    public function __construct(
        public readonly Engine $engine,
        public readonly string $tires,
        public string $color,
    ) {}
}

Автосалону нужно 50 тестовых машин с одинаковой базовой комплектацией, отличающихся только цветом. Создавая каждую машину заново — например, через CarDirector и CarBuilder из прошлой статьи, — мы 50 раз пересоздаём Engine, а значит, 50 раз обращаемся к DynoService.

Первая мысль — просто переиспользовать переменную:

$base = new Car(new Engine('V8 Turbo'), 'Michelin', 'Sports Red');
$copy = $base;
$copy->color = 'Blue';
 
echo $base->color; // 'Blue' — исходная машина тоже перекрасилась!

В PHP объекты присваиваются по идентификатору (handle), а не копируются. $copy = $base не создаёт новый объект — обе переменные указывают на один и тот же экземпляр. Чтобы получить независимую копию, объект нужно клонировать.

Решение

PHP умеет клонировать объекты через встроенное ключевое слово clone — оно создаёт новый объект с теми же значениями свойств, не вызывая конструктор:

$copy = clone $base;
$copy->color = 'Blue';
 
echo $base->color; // 'Sports Red' — исходная машина не изменилась.

Engine при этом заново не создаётся и DynoService повторно не вызывается — copy получает то же значение horsePower, что и оригинал.

Ловушка поверхностного клонирования

По умолчанию clone копирует свойства объекта поверхностно: если свойство само является объектом, копия получит ссылку на тот же вложенный объект, а не на его копию:

$sport = new Car(new Engine('V8 Turbo'), 'Michelin', 'Sports Red');
$family = clone $sport;
 
$family->engine->horsePower = 100; // хотим "приглушить" мотор только у семейной версии
 
echo $sport->engine->horsePower; // тоже изменилось — Engine общий на двоих

Чтобы вложенные объекты копировались тоже, нужно определить магический метод __clone() — PHP вызывает его автоматически на новом объекте сразу после поверхностного копирования:

class Car
{
    public function __construct(
        public readonly Engine $engine,
        public readonly string $tires,
        public string $color,
    ) {}
 
    public function __clone(): void
    {
        // На момент вызова $this уже является копией,
        // но $this->engine всё ещё указывает на объект оригинала.
        $this->engine = clone $this->engine;
    }
}

Теперь clone $sport рекурсивно клонирует и вложенный Engine, и изменения в копии больше не задевают оригинал.

Переприсваивание readonly-свойства внутри __clone() стало возможным начиная с PHP 8.3. На более старых версиях $this->engine = ... в этом методе упадёт с Cannot modify readonly property — там engine пришлось бы делать обычным свойством.

Клонирование через интерфейс

Когда клиентский код работает с объектами через абстракцию и не знает их конкретного класса, clone всё равно работает — PHP вызовет __clone() того класса, экземпляр которого клонируют. Явный интерфейс полезен, чтобы зафиксировать это поведение как часть контракта, а не как деталь реализации:

interface CarPrototype
{
    public function cloneCar(): static;
}
 
class Car implements CarPrototype
{
    public function __construct(
        public readonly Engine $engine,
        public readonly string $tires,
        public string $color,
    ) {}
 
    public function __clone(): void
    {
        $this->engine = clone $this->engine;
    }
 
    public function cloneCar(): static
    {
        return clone $this;
    }
}

Глубокое копирование по-прежнему делает __clone() — именно его вызывает оператор clone изнутри. cloneCar() лишь фиксирует это как явный метод интерфейса, а не деталь реализации, которую нужно знать клиенту.

Реестр прототипов

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

class CarPrototypeRegistry
{
    /** @var array<string, Car> */
    private array $prototypes = [];
 
    public function register(string $name, Car $prototype): void
    {
        $this->prototypes[$name] = $prototype;
    }
 
    public function create(string $name): Car
    {
        if (!isset($this->prototypes[$name])) {
            throw new InvalidArgumentException("Unknown prototype: {$name}");
        }
 
        return $this->prototypes[$name]->cloneCar();
    }
}
 
$registry = new CarPrototypeRegistry();
$registry->register('sport', new Car(new Engine('V8 Turbo'), 'Michelin', 'Sports Red'));
$registry->register('family', new Car(new Engine('Hybrid'), 'Standard', 'White'));
 
$orderCar = $registry->create('sport');
$orderCar->color = 'Blue';

DynoService::measure() вызывается один раз при регистрации прототипа — каждый следующий заказ обходится без дорогого пересчёта.

Из чего состоит паттерн

  • Прототип (CarPrototype) — интерфейс с методом клонирования (опционален: clone работает и без него).
  • Конкретный прототип (Car) — реализует __clone(), при необходимости глубоко копируя вложенные объекты.
  • Реестр прототипов (CarPrototypeRegistry, опционально) — хранит именованные готовые экземпляры и выдаёт их копии.
  • Клиент — запрашивает копию вместо того, чтобы создавать объект через конструктор.

Прототип vs Строитель

Оба паттерна дают готовый сложный объект, но разными путями:

СтроительПрототип
Источник объектаСобирается заново, шаг за шагомКопируется с уже существующего экземпляра
Когда выгоднееПодходящего экземпляра ещё нетПохожий экземпляр уже есть и дорог в создании
Что нужно знатьШаги сборкиКак корректно клонировать (в т.ч. вложенные объекты)

Строитель отвечает на вопрос «как собрать объект с нуля», Прототип — «как получить второй такой же, не собирая заново».

Когда применять

  • Создание объекта дороже (по времени, ресурсам, обращениям к внешним системам), чем его копирование.
  • Нужно копировать объект, не зная его конкретного класса — только то, что он реализует интерфейс прототипа.
  • Система должна уметь конфигурировать себя готовыми экземплярами в рантайме, а не жёстко заданными классами (реестр прототипов).

Плюсы и минусы

Прототип избавляет от повторной дорогой инициализации, позволяет копировать объекты полиморфно через интерфейс и уменьшает число фабрик/подклассов, нужных только ради вариаций готового объекта.

Минус — глубокое клонирование сложных графов объектов с циклическими ссылками требует аккуратной реализации __clone(). Объекты, держащие внешние ресурсы (открытый файл, сетевое соединение, файловый дескриптор), в принципе не стоит клонировать бездумно — такие поля в __clone() нужно либо пересоздавать заново, либо явно обнулять.

Итог

Прототип полезен, когда в системе уже есть полностью настроенный экземпляр, а создание нового с нуля стоит дороже, чем его копирование. В PHP это встроенная возможность языка — clone и __clone() — а не решение, которое приходится городить вручную, как в языках без нативной поддержки клонирования.