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-свойствами.