Клонирование readonly свойств в PHP 8.3

Источник: «Cloning readonly properties in PHP 8.3»
В PHP 8.3 добавлена возможность перезаписи значения readonly свойств при клонировании объекта. Не заблуждайтесь: вы не можете клонировать любой объект и перезаписывать его readonly значения из любого места. Эта функция касается только очень специфического (но важного) пограничного случая.

В идеальном мире мы могли бы клонировать классы с readonly свойствами, на основе определённого пользователем набора значений. Так называемый синтаксис clone with (которого не существует):

readonly class Post
{
public function __construct(
public string $title,
public string $author,
public DateTime $createdAt,
) {}
}

$post = new Post(
title: 'Hello World',
// …
);

// Это невозможно!
$updatedPost = clone $post with {
title: 'Another One!',
};

Читая заголовок текущего RFC: Readonly свойства могут быть повторно инициализированы во время клонирования — вы могли подумать, что теперь возможно что-то вроде clone with. Однако… это не так. RFC разрешает только одну конкретную операцию: перезаписывать readonly значения в магическом методе __clone:

readonly class Post
{
public function __construct(
public string $title,
public string $author,
public DateTime $createdAt,
) {}

public function __clone()
{
$this->createdAt = new DateTime();
// Это разрешено,
// несмотря на то, что `createdAt` является readonly свойством
}
}

Это полезно? Да! Скажем, вы хотите клонировать объекты с вложенными объектами — т.е. создание глубоких клонов; затем этот RFC позволяет вам клонировать и эти вложенные объекты и перезаписывать их во вновь созданном клоне, даже если они являются readonly свойствами.

readonly class Post
{
public function __clone()
{
$this->createdAt = clone $this->createdAt;
// Создаёт новый объект DateTime,
// вместо повторного использования ссылки
}
}

Без этого RFC вы могли бы клонировать $post, но он по-прежнему содержал бы ссылку на исходный объект $createdAt. Скажем, вы вносите изменения в этот объект (что возможно, поскольку readonly только предотвращает изменение назначенного свойства, а не изменение его внутренних значений):

$post = new Post(/* … */);

$otherPost = clone $post;

$post->createdAt->add(new DateInterval('P1D'));

$otherPost->createdAt === $post->createdAt; // true :(

Тогда вы получите изменение даты $createdAt для обоих объектов!

Благодаря этому RFC мы можем создавать настоящие клоны со всеми вложенными свойствами, даже если это readonly свойства:

$post = new Post(/* … */);

$otherPost = clone $post;

$post->createdAt->add(new DateInterval('P1D'));

$otherPost->createdAt === $post->createdAt; // false :)

От себя, лично

Я думаю, хорошо, что PHP 8.3 делает возможным глубокое клонирование readonly свойств. Однако у меня смешанные чувства по поводу этой реализации. Представьте на секунду, что clone with существовало в PHP, тогда всё вышеперечисленное было бы ненужным. Взглянем:

// Опять, это не возможно!
$updatedPost = clone $post with {
createdAt: clone $post->createdAt,
};

А теперь представьте, что clone with добавляется в PHP 8.4 — чистая спекуляция, конечно. Это означает, что у нас будет два способа сделать одно и то же в PHP. Не знаю, как вам, а мне не нравится, когда языки или фреймворки предлагают несколько способов сделать одно и то же. Насколько я понимаю, это в лучшем случае не оптимальный дизайн языка.

Это, конечно, при условии, что clone with сможет автоматически сопоставлять значения со свойствами без необходимости вручную реализовывать логику сопоставления в __clone. Я также предполагаю, что clone with может иметь дело с видимостью свойств: он может изменять только публичные свойства извне, но может изменять защищённые и приватные при использовании внутри класса.

Некоторое время назад я писал, что внутри PHP кажется разделённым: одна группа предлагает одно решение, а другая группа хочет использовать другой подход. На мой взгляд, это явный недостаток разработки комитетом.

Полное раскрытие информации — RFC упоминает clone with в качестве будущей области применения:

Ни одна из предполагаемых идей на будущее не противоречит предложениям в этом RFC. Таким образом, их можно было бы рассматривать отдельно позже.

Но я склонен не согласиться с этим утверждением, по крайней мере, предполагая, что clone with будет работать без необходимости реализации какого-либо пользовательского кода. Если бы следовали тенденциям текущего RFC, я мог бы представить, что кто-то предлагает добавить clone with только как способ передачи данных в __clone, и пользователи сами справятся с этим:

readonly class Post
{
public function __clone(...$properties)
{
foreach ($properties as $name => $value) {
$this->$name = $value;
}
}
}

Тем не менее я действительно надеюсь, что это не тот способ, которым реализуется clone with; потому что вам придётся добавить реализацию __clone для каждого readonly класса.

Итак, если предположить, что в лучшем случае добавляется clone with, а когда он сможет автоматически сопоставлять значения; тогда функциональность этого текущего RFC аннулируется, и у нас есть два способа сделать одно и тоже. Это смутит пользователей, потому что ставит перед ними ещё одно решение при кодировании. Я думаю, что PHP и так стал достаточно запутанным, и я хотел бы увидеть как это изменится.

С другой стороны, я хочу отметить, что не выступаю против этого RFC сам по себе. Я думаю, что Nicolas и Máté отлично поработали, найдя надёжное решение реальной проблемы.

P.S: на случай, если кто-то хочет привести аргумент в пользу текущего RFC, потому что вам нужно реализовать __clone только один раз для каждого объекта и больше не беспокоиться об этом. В этих изолированных примерах отсутствует одна очень важная деталь: глубокое копирование не происходит с помощью простого вызова clone. В большинстве случаев используются такие пакеты, как deep-copy, и, таким образом, потенциальные накладные расходы, связанные с моим примером clone with, уже устранены этими пакетами и не беспокоят конечных пользователей.

Дополнительные материалы

Предыдущая Статья

SQL-инъекции: UNION атаки

Следующая Статья

Развёртывание Laravel приложения с GitHub Actions