PHP 8.1: Readonly-свойства / свойства только для чтения

Источник: «PHP 8.1: readonly properties»
PHP 8.1 решает несколько проблем, вводя ключевое слово readonly. Это ключевое слово делает то, что предполагает его название: как только свойство установлено, оно больше не может быть изменено.

Важное примечание: в PHP 8.2 добавлен способ делать целые классы доступными только для чтения: readonly-классы.

Написание объектов передачи данных (DTO) и объектов-значений (VO) в PHP с годами стало значительно проще. Рассмотрим пример DTO в PHP 5.6:

class BlogData
{
/** @var string */
private $title;

/** @var Status */
private $status;

/** @var \DateTimeImmutable|null */
private $publishedAt;

/**
* @param string $title
* @param Status $status
* @param \DateTimeImmutable|null $publishedAt
*/

public function __construct(
$title,
$status,
$publishedAt = null
) {
$this->title = $title;
$this->status = $status;
$this->publishedAt = $publishedAt;
}

/**
* @return string
*/

public function getTitle()
{
return $this->title;
}

/**
* @return Status
*/

public function getStatus()
{
return $this->status;
}

/**
* @return \DateTimeImmutable|null
*/

public function getPublishedAt()
{
return $this->publishedAt;
}
}

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

class BlogData
{
public function __construct(
private string $title,
private Status $status,
private ?DateTimeImmutable $publishedAt = null,
) {}

public function getTitle(): string
{
return $this->title;
}

public function getStatus(): Status
{
return $this->status;
}

public function getPublishedAt(): ?DateTimeImmutable
{
return $this->publishedAt;
}
}

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

class BlogData
{
public function __construct(
public string $title,
public Status $status,
public ?DateTimeImmutable $publishedAt = null,
) {}
}

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

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

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

class BlogData
{
public function __construct(
public readonly string $title,
public readonly Status $status,
public readonly ?DateTimeImmutable $publishedAt = null,
) {}
}

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

$blog = new BlogData(
title: 'PHP 8.1: readonly properties',
status: Status::PUBLISHED,
publishedAt: now()
);

$blog->title = 'Another title';
Error: Cannot modify readonly property Post::$title

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

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

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

Readonly-свойства можно использовать только в сочетании с типизированными свойствами:

class BlogData
{
public readonly string $title;

public readonly $mixed;
}

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

class BlogData
{
public readonly string $title;

public readonly mixed $mixed;
}

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

Как обычные, так и определяемые в конструкторе свойства

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

class BlogData
{
public readonly string $title;

public function __construct(
public readonly Status $status,
) {}
}

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

Readonly-свойства не могут иметь значения по умолчанию:

class BlogData
{
public readonly string $title = 'Readonly properties';
}

То есть, если они не являются свойствами определяемыми в конструкторе:

class BlogData
{
public function __construct(
public readonly string $title = 'Readonly properties',
) {}
}

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

class BlogData
{
public readonly string $title;

public function __construct(
string $title = 'Readonly properties',
) {
$this->title = $title;
}
}

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

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

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

class Foo
{
public readonly int $prop;
}

class Bar extends Foo
{
public int $prop;
}

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

Нельзя удалять readonly-свойство

После того как readonly-свойство установлено, вы не можете его изменить или удалить:

$foo = new Foo('value');

unset($foo->prop);

Reflection

Появился новый метод ReflectionProperty::isReadOnly(), а так же флаг ReflectionProperty::IS_READONLY.

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

Итак, если вы не можете изменять readonly-свойства, и если вы не можете их удалять, то как вы можете создавать копию своих DTO или VO и изменить данные? Вы не можете клонировать их (clone), потому что вы не сможете перезаписать их значение. Есть идея добавить в будущем clone with конструкцию, которая допускает такое поведение, но сейчас это не решает нашу проблему.

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

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

class BlogData
{
use Cloneable;

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

$dataA = new BlogData('Title');

$dataB = $dataA->with(title: 'Another title');

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

Readonly-классы

Наконец, я должен упомянуть добавление readonly-классов в PHP 8.2. В случае, когда все свойства вашего класса доступны только для чтения (что часто происходит с DTO или VO), вы можете пометить сам класс как readonly. Это означает, что вам не нужно объявлять каждое отдельное свойство как readonly — хорошее сокращение!

readonly class BlogData
{
public function __construct(
public string $title,
public Status $status,
public ?DateTimeImmutable $publishedAt = null,
) {}
}

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

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

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

PHP 8.0 и 8.1: Объяснение современных возможностей

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

PHP 8.1: Клонирование и изменение readonly-свойств