Свойства, доступные только для чтения в PHP 8.1

PHP
Свойства, доступные только для чтения в PHP 8.1
Перевод статьи «PHP 8.1: readonly properties»

Написание DTO и VO на PHP с годами стало значительно проще. Взгляните, например, на DTO в PHP 5.6:

1<?php
2 
3class BlogData
4{
5 /** @var string */
6 private $title;
7 
8 /** @var Status */
9 private $status;
10 
11 /** @var \DateTimeImmutable|null */
12 private $publishedAt;
13 
14 /**
15 * @param string $title
16 * @param Status $status
17 * @param \DateTimeImmutable|null $publishedAt
18 */
19 public function __construct(
20 $title,
21 $status,
22 $publishedAt = null
23 ) {
24 $this->title = $title;
25 $this->status = $status;
26 $this->publishedAt = $publishedAt;
27 }
28 
29 /**
30 * @return string
31 */
32 public function getTitle()
33 {
34 return $this->title;
35 }
36 
37 /**
38 * @return Status
39 */
40 public function getStatus()
41 {
42 return $this->status;
43 }
44 
45 /**
46 * @return \DateTimeImmutable|null
47 */
48 public function getPublishedAt()
49 {
50 return $this->publishedAt;
51 }
52}

И сравните с аналогом в PHP 8.0:

1class BlogData
2{
3 public function __construct(
4 private string $title,
5 private Status $status,
6 private ?DateTimeImmutable $publishedAt = null,
7 ) {
8 }
9 
10 public function getTitle(): string
11 {
12 return $this->title;
13 }
14 
15 public function getStatus(): Status
16 {
17 return $this->status;
18 }
19 
20 public function getPublishedAt(): ?DateTimeImmutable
21 {
22 return $this->publishedAt;
23 }
24}

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

1class BlogData
2{
3 public function __construct(
4 public string $title,
5 public Status $status,
6 public ?DateTimeImmutable $publishedAt = null,
7 ) {
8 }
9}

Однако объектно-ориентированным пуристам такой подход не нравится: внутренний статус объекта не должен быть раскрыт напрямую и определённо не может быть изменён извне.

В наших проектах в Spatie есть внутреннее руководство по написанию кода, согласно которому DTO и VO с общедоступными свойствами не должны изменяться извне. Подход, который, кажется, работает вполне неплохо, мы используем его уже довольно давно, не сталкиваясь с какими-либо проблемами.

Однако да, я согласен с тем, что было бы лучше, если бы язык гарантировал, что общедоступные свойства вообще не могут быть переопределены. Что ж, в PHP 8.1 решили эту проблему, добавив ключевое слово readonly:

1class BlogData
2{
3 public function __construct(
4 public readonly string $title,
5 public readonly Status $status,
6 public readonly ?DateTimeImmutable $publishedAt = null,
7 ) {
8 }
9}

Как и предполагает его название, смысл ключевого слова в том, что после того, как свойство установлено, его больше нельзя переопределить:

1$blog = new BlogData(
2 title: 'PHP 8.1: readonly-свойства',
3 status: Status::PUBLISHED,
4 publishedAt: now()
5);
6 
7$blog->title = 'Какой-то другой заголовок'; // Ошибка: Нельзя переопределить свойство, доступное только для чтения BlogData::$title

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

Конечно, по-прежнему нужна возможность клонировать объект и, возможно, изменять некоторые свойства в процессе. Далее мы обсудим, как это сделать со свойствами, доступными только для чтения. Для начала, давайте рассмотрим их подробнее.

Только типизированные свойства

Свойства, доступные только для чтения могут быть только типизированными:

1class BlogData
2{
3 public readonly string $title;
4 
5 public readonly $mixed; // Ошибка: Нельзя использовать не типизированное свойство, доступное только для чтения
6}

Однако вы можете использовать тип mixed для указания типа:

1class BlogData
2{
3 public readonly string $title;
4 
5 public readonly mixed $mixed;
6}

Причина этого ограничения заключается в том, что, опуская тип свойства, PHP автоматически устанавливает значение null, если в конструкторе не было определено явное значение. Такое поведение в сочетании со свойствами, доступными только для чтения вызовет ненужную путаницу.

Обычные объекты и объекты с определением свойств в конструкторе

Вы уже видели примеры и того и другого: readonly можно добавить как к обычному, так и к свойству, определяемому в конструкторе:

1class BlogData
2{
3 public readonly string $title;
4 
5 public function __construct(
6 public readonly Status $status,
7 ) {}
8}

Нет значения по умолчанию

У readonly-свойств не может быть значения по умолчанию:

1class BlogData
2{
3 public readonly string $title = 'Readonly-свойства'; // Ошибка: Нельзя использовать значение по умолчанию
4}

Точнее, если это не свойство, определяемое в конструкторе:

1class BlogData
2{
3 public function __construct(
4 public readonly string $title = 'Readonly-свойства',
5 ) {}
6}

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

1class BlogData
2{
3 public readonly string $title;
4 
5 public function __construct(
6 string $title = 'Readonly-свойства',
7 ) {
8 $this->title = $title;
9 }
10}

Посмотрите, как фактическому свойству не присваивается значение по умолчанию. Причина запрета использования значений по умолчанию для свойств, доступных только для чтения, заключается в том, что в таком виде они ничем не будут отличаться от констант.

Наследование

Нельзя изменять флаг readonly при наследовании:

1class Foo
2{
3 public readonly int $prop;
4}
5 
6class Bar extends Foo
7{
8 public int $prop; // Ошибка: Нельзя изменять флаг readonly
9}

Правило действует в обоих направлениях: вам не разрешено добавлять или удалять флаг readonly при наследовании.

Unset не допускается

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

1$foo = new Foo('value');
2 
3unset($foo->prop); // Ошибка: Нельзя сбросить readonly-свойство

Reflection

Добавлен новый метод ReflectionProperty::isReadOnly(), а также флаг ReflectionProperty::IS_READONLY.

Клонирование

Итак, если нельзя изменить свойство, доступные только для чтения, и, если нельзя их сбросить, каким образом можно создать копию своих DTO или VO и изменить какие-то данные? Также нельзя использовать clone, потому что вы не сможете перезаписать их значения.

На самом деле есть идея добавить в будущем конструкцию clone with, которая допускает такое поведение, но сейчас проблема не решена.

Что ж, можно клонировать объекты с изменёнными свойствами, доступными только для чтения, если полагаться на магию Reflection. Создавая объект без вызова его конструктора (что возможно с помощью Reflection), а затем вручную копируя каждое свойство, иногда перезаписывая значение, вы фактически можете «клонировать» объект и изменить его свойства, доступные только для чтения.

Для этого я разработал небольшой пакет, вот как он выглядит:

1class BlogData
2{
3 use Cloneable;
4 
5 public function __construct(
6 public readonly string $title,
7 ) {}
8}
9 
10$dataA = new BlogData('Title');
11 
12$dataB = $dataA->with(title: 'Another title');

Также я написал специальный пост в блоге, объясняющий всю механику.

Вот и всё, что можно сказать о свойствах, доступных только для чтения. Я думаю, что это отличная возможность, при работе над проектами со множеством DTO и VO и требующими от вас тщательного управления потоком данных во всем коде. Неизменяемые объекты со свойствами, доступными только для чтения очень в этом помогут.