Написание DTO и VO на PHP с годами стало значительно проще. Взгляните, например, на DTO в PHP 5.6:
<?php
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:
<?php
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, в котором добавили определение свойств в конструкторе. Я предпочитаю использовать общедоступные свойства вместо написания методов чтения:
<?php
class BlogData
{
public function __construct(
public string $title,
public Status $status,
public ?DateTimeImmutable $publishedAt = null,
) {
}
}
Однако объектно-ориентированным пуристам такой подход не нравится: внутренний статус объекта не должен быть раскрыт напрямую и определённо не может быть изменён извне.
В наших проектах в Spatie есть внутреннее руководство по написанию кода, согласно которому DTO и VO с общедоступными свойствами не должны изменяться извне. Подход, который, кажется, работает вполне неплохо, мы используем его уже довольно давно, не сталкиваясь с какими-либо проблемами.
Однако да, я согласен с тем, что было бы лучше, если бы язык гарантировал, что общедоступные свойства вообще не могут быть переопределены. Что ж, в PHP 8.1 решили эту проблему, добавив ключевое слово readonly
:
<?php
class BlogData
{
public function __construct(
public readonly string $title,
public readonly Status $status,
public readonly ?DateTimeImmutable $publishedAt = null,
) {
}
}
Как и предполагает его название, смысл ключевого слова в том, что после того, как свойство установлено, его больше нельзя переопределить:
<?php
$blog = new BlogData(
title: 'PHP 8.1: readonly-свойства',
status: Status::PUBLISHED,
publishedAt: now()
);
$blog->title = 'Какой-то другой заголовок'; // Ошибка: Нельзя переопределить readonly-свойство BlogData::$title
Знание, что когда объект инициализирован, он больше не будет меняться, даёт нам определённый уровень уверенности и спокойствия при написании кода: целый ряд непредвиденных изменений данных просто не может произойти.
Конечно, по-прежнему нужна возможность клонировать объект и, возможно, изменять некоторые свойства в процессе. Далее мы обсудим, как это сделать с readonly-свойствами. Для начала, давайте рассмотрим их подробнее.
Только типизированные свойства
Readonly-свойства могут быть только типизированными:
<?php
class BlogData
{
public readonly string $title;
public readonly $mixed; // Ошибка: Нельзя использовать не типизированное readonly-свойство
}
Однако вы можете использовать тип mixed
для указания типа:
<?php
class BlogData
{
public readonly string $title;
public readonly mixed $mixed;
}
Причина этого ограничения заключается в том, что, опуская тип свойства, PHP автоматически устанавливает значение null
, если в конструкторе не было определено явное значение. Такое поведение в сочетании с readonly-свойством вызовет ненужную путаницу.
Обычные объекты и объекты с определением свойств в конструкторе
Вы уже видели примеры и того и другого: readonly
можно добавить как к обычному, так и к свойству, определяемому в конструкторе:
<?php
class BlogData
{
public readonly string $title;
public function __construct(
public readonly Status $status,
) {}
}
Нет значения по умолчанию
У readonly-свойств не может быть значения по умолчанию:
<?php
class BlogData
{
public readonly string $title = 'Readonly-свойства'; // Ошибка: Нельзя использовать значение по умолчанию
}
Точнее, если это не свойство, определяемое в конструкторе:
<?php
class BlogData
{
public function __construct(
public readonly string $title = 'Readonly-свойства',
) {}
}
Причина, по которой это разрешено для свойств, определяемых в конструкторе, заключается в том, что значение по умолчанию в этом случае используется не в качестве значения по умолчанию для свойства класса, а только для аргумента конструктора. Под капотом приведённый выше код будет преобразован в этот:
<?php
class BlogData
{
public readonly string $title;
public function __construct(
string $title = 'Readonly-свойства',
) {
$this->title = $title;
}
}
Посмотрите, как фактическому свойству не присваивается значение по умолчанию. Причина запрета использования значений по умолчанию для readonly-свойств, заключается в том, что в таком виде они ничем не будут отличаться от констант.
Наследование
Нельзя изменять флаг readonly
при наследовании:
<?php
class Foo
{
public readonly int $prop;
}
class Bar extends Foo
{
public int $prop; // Ошибка: Нельзя изменять флаг readonly
}
Правило действует в обоих направлениях: вам не разрешено добавлять или удалять флаг readonly
при наследовании.
Unset не допускается
После того как readonly-свойство установлено, вы не можете его изменить и даже сбросить:
<?php
$foo = new Foo('value');
unset($foo->prop); // Ошибка: Нельзя сбросить readonly-свойство
Reflection
Добавлен новый метод ReflectionProperty::isReadOnly()
, а также флаг ReflectionProperty::IS_READONLY
.
Клонирование
Итак, если нельзя изменить readonly-свойства, и, если нельзя их сбросить, каким образом можно создать копию своих DTO или VO и изменить какие-то данные? Также нельзя использовать clone
, потому что вы не сможете перезаписать их значения.
На самом деле есть идея добавить в будущем конструкцию clone with
, которая допускает такое поведение, но сейчас проблема не решена.
Что ж, можно клонировать объекты с изменёнными readonly-свойствами, если полагаться на магию Reflection. Создавая объект без вызова его конструктора (что возможно с помощью Reflection), а затем вручную копируя каждое свойство, иногда перезаписывая значение, вы фактически можете «клонировать» объект и изменить его readonly-свойства.
Для этого я разработал небольшой пакет, вот как он выглядит:
<?php
class BlogData
{
use Cloneable;
public function __construct(
public readonly string $title,
) {}
}
$dataA = new BlogData('Title');
$dataB = $dataA->with(title: 'Another title');
Также я написал специальный пост в блоге, объясняющий всю механику.
Вот и всё, что можно сказать о readonly-свойствах. Я думаю, что это отличная возможность, при работе над проектами со множеством DTO и VO и требующими от вас тщательного управления потоком данных во всем коде. Неизменяемые объекты с readonly-свойствами очень в этом помогут.
Комментарии (15)
Nnnnoooo
20.09.2021 15:36+3Основная проблема с такой реализацией ридонли — это невозможность делать клонирование.
Да, я в курсе что можно делать в юзерспейсе используя костыли, но простое добавление ридонли проперти на уровне приложения может поломать код где-то во внутренностях сторонней либы или фреймворка. Особенно клонировать любят сторонние либы работающие по принципам иммутабельности. Т.е. получается с одной стороны ридонли как раз хорошо подходят для иммутабельных объектов, и по идее должно хорошо работать с либами на принципах иммутабельности, но только вот внутри часто используется clone и в итоге получаем что, то что по идее должно работать вместе — работать не будет :(
KAndy
21.09.2021 00:50во-первых, либа которая ожидает что любой объект можно клонировать по умолчанию - broken by design
во-вторых, клонирование не поломано https://3v4l.org/iH8qW#v8.1rc2, просто проперти остается рид-онлиNnnnoooo
21.09.2021 01:44Да я криво написал. Сам clone не сломан, но это не меняет того что этот код не будет будет работать:
public function withX(float $x): static { // This implementation does not: $clone = clone $this; $clone->x = $x; return $clone; }
А это абсолютно стандартный подход в очень многих сторонних либах. Т.е. явно не хватает конструкции clone with, которую грозятся добавить в какой-то из будущих версий.
Хотя если честно с этой реализацией меньше проблем чем было с writeonce properties rfc
oxidmod
10.11.2021 20:59можно же вот так
public function withX(float $x): static { return new self($x, $this->y, $this->z); }
Dier_Sergio_Great
26.09.2021 15:57<?php
class Koshka {
public readonly int $prop;
}
class Pushistik extends Koshka {
public readonly int $prop = "Пушистая кошка";
}Ой как было бы прекрасно в наследуемых классах иметь значения по умолчанию.
galliard
10.11.2021 18:10А зачем было придумывать новое ключевое слово "readonly"? В пыхе уже есть ключевое слово "final", которое можно было бы заюзать для этих целей.
Опять же зачем было запрещать значения по умолчанию? Ну было бы это свойство де-факто константой, ну и что? Наоборот удобно, можно было бы вешать на них аннотации сериалайзера, которые на обычные константы не повесишь. И в строку с двойными ковычкаи их пихать можно, в отличии от текущих констант.SerafimArts
11.11.2021 20:30+11) final означает запрет переопределения дочерними элементами при наследовании (как в случае констант) или запрет наследования (как с классами). В любом случае — это работа с запретами и наследованием. У readonly никакого наследования в принципе нет.
2) Потому что значение по умолчанию означает наличие инициализации поля класса. В случае же с readonly — допускается запись только в случае инициализации поля.
2.1) Аннотации уже второй год как устаревшая конструкция. Используйте атрибуты, которые можно вешать запросто на константы.galliard
12.11.2021 02:29Ну то есть мы видим два разных поведения в зависимости от того, к чему применен финал - для классов одно, для методов - другое. Вполне логично, если для полей оно будет третьим.
Не понял, в чем тут принципиальная разница.
Атрибуты до сих пор не всеми библиотеками поддерживаются.
SerafimArts
12.11.2021 05:50- Не согласен. И для классов, и для методов, и для констант — final предоставляет совершенно идентичное поведение: Запрет изменений в потомке. Как оно указано в родителе — так и остаётся навсегда.
- Да, согласен. Хотел всё свалить на блокировку записи после инициализации поля, но не подумал о том, что в пыхе так же предусмотрена и инициализация полей дефолтными значениями. Так что действительно непонятно почему запретили это поведение.
- Ну это проблема этих морально устаревших библиотек, разве нет? И хоть JMS (насколько я понимаю речь идёт именно про него) довольно популярен, но по-мне это оверинжинеринг. С таким же успехом можно создать класс прослойки для сериализации, отличий особо не будет, кроме того, что подобный класс будет на порядки быстрее, гибче и отделён от объекта модели. Накрайняк можно воспользоваться фракталом.
galliard
12.11.2021 22:42+1Можно еще чуть обобщить, до "запрет изменений", тогда применение финала к полям впишется в ту же логику.
3. И все-таки у варианта с классами есть один существенный недостаток - придется писать много кода. По классу на каждый вариант представления + все по классу на каждую вложенную структуру. А так же решать, куда это все добро размещать и как именовать. И билдить это добро каждый раз. А чем отличается фрактал от обычного json_encode - я вообще пока не догнал.
SerafimArts
12.11.2021 23:53Можно еще чуть обобщить, до "запрет изменений", тогда применение финала к полям впишется в ту же логику.
Ну если с этой стороны посмотреть, то да. Тогда подходит.
И все-таки у варианта с классами есть один существенный недостаток — придется писать много кода.
Ну не так уж и много. Деклейр, неймспейс, класс и метод — 4 строчки, плюс фигурные скобочки. С другой стороны всё становится понятнее, чище, быстрее и гибче.
А фрактал я тоже не понимаю, совершенно неудобная и непрактичная какая-то штука особо. Проще самому написать тоже самое.
topuserman
Для полного счастья остаются дженерики и get/set из шарпа.
И желательно выкинуть текущий union-типы и сделать их в виде отдельной сущности, например как в С++.
SerafimArts
Вы про то, что надо добавить в PHP свойства? Так RFC есть на это (https://wiki.php.net/rfc/property_accessors), но во время обсуждения возникли проблемы с реализацией (переусложнение кода), так что решили пока что, если я ничего не перепутал, просто добавить readonly.