Фуллстек-разработчик Springer Nature Луис Соареш (Luís Soares) описал достоинства объектов-значений на примере языка Kotlin. Но объекты-значения могут быть такими же полезными и в других языках! Доказательства — в нашем переводе статьи: разработчик Noveo Ян перевёл её на русский, заменив язык примеров на РНР.
Как убедиться, что два разных компонента «говорят» на одном языке? Как проверить, не разбросаны ли у вас по проекту некорректные данные? Что говорят обычные строки или целые числа о вашей предметной области?
Например, в Java (или PHP, начиная с версии 8.1, — прим. пер.) перечисления являются встроенными в язык объектами-значениями. Тем не менее, они не подходят для произвольного количества значений (и являются скорее технической деталью реализации — прим. пер.). Типичными объектами-значениями являются: адрес электронной почты, пароль, локаль, номер телефона, деньги, IP-адрес, URL, идентификатор сущности, рейтинг, путь до файла, точка, цвет, дата, диапазон дат, расстояние.
Объекты-значения часто упоминаются в связке с предметно-ориентированным проектированием. Давайте рассмотрим их некоторые характеристики на примере языка программирования PHP.
Контейнер данных
Объект-значение — это контейнер произвольных данных:
class Email
{
public function __construct(private string $email) {}
public function getValue(): string
{
return $this->email;
}
}
Непосредственное значение хранится внутри класса.
Обычно объект-значение хранит в себе какое-то одно конкретное значение, но вполне может и больше. Например, цвет в формате RGBA содержит четыре целых числа, а точка в пространстве — три десятичных. Так и между параметрами функции часто может существовать логическая связь. В таком случае их стоит выделить в отдельный класс. Плюсы такого подхода:
- Меньшее количество аргументов функции;
- Явная и однозначная связь между параметрами;
- Ярче выражена идея того, что делает код.
Просто сравните:
function distance(float $x1, float $x2, float $y1, float $y2): float
{
//
}
function distance(Point $p1, Point $p2): float
{
//
}
В некоторых языках программирования также возможна передача объектов-значений по значению, а не по ссылке. Объекты-значения — спасение от «одержимости примитивами», реальное значение в них — лишь деталь реализации. Например, если вы захотите поменять тип идентификатора сущности User с целого числа на строку, то после этого вам не придётся изменять классы, использующие этот идентификатор, или сигнатуры вроде таких:
function findById(UserId $userId): ?User
{
// все вызовы метода останутся без изменений
}
Одинаковое значение? Значит равны
Доставая витаминку из упаковки, мы обычно не задумываемся о том, чем одна из них отличается от другой. Так и с объектами-значениями. Пока они несут в себе одинаковое внутреннее значение (или несколько значений), они и сами всегда будут считаться одинаковыми. Даже если ссылки указывают на разное место в оперативной памяти:
class Email
{
public function __construct(private string $email) {}
public function getValue(): string
{
return $this->email;
}
public function equals(Email $other): bool
{
return $this === $other || $this->email === $other->email;
}
}
// Тест
use PHPUnit\Framework\TestCase;
class EmailTest extends TestCase
{
public function testEquals()
{
$emailOne = new Email('foo@bar.com');
$emailTwo = new Email('foo@bar.com');
static::assertTrue($emailOne->equals($emailTwo));
}
}
Объекты, равные по значению свойств, называются объектами-значениями, — Мартин Фаулер
Неизменяемость
Неизменяемость — главная характеристика объектов-значений и очень важное свойство в функциональном программировании. Такие языки программирования, как Clojure, предлагают объекты-значения в качестве одной из встроенных возможностей языка. В остальных случаях необходимо немного потрудиться, чтобы сделать объекты неизменяемыми — в частности, для предотвращения багов, связанных с алиасингом.
В этом параграфе я решил показать другой пример, чтобы не повторяться с Email, — прим. пер.
Возьмем для примера точку на плоскости. Логично, что однажды созданная точка не может быть изменена, потому что при любом изменении координат это будет уже совершенно другая точка!
class Point
{
public function __construct(private int $x, private int $y) {}
public function getX(): int
{
return $this->x;
}
public function getY(): int
{
return $this->y;
}
public function equals(Point $other): bool
{
return $this === $other ||
($this->x === $other->x && $this->y === $other->y);
}
}
Обратите внимание, что в этом примере мы определили только геттеры — методы доступа к координатам точки.
Неизменяемые значения можно свободно передавать из функции в функцию без надобности создавать копии. При этом конкурентный доступ к таким значениям безопасен по умолчанию. Тестирование сводится к созданию отдельных объектов класса через конструктор и проверку возвращаемых значений его методов. Неизменяемые объекты позволяют писать функции без побочных эффектов, а те, в свою очередь, имеют собственные преимущества, — «Объекты-значения», Флориан Бенц
Без идентичности и без истории
Объекты-значения не имеют смысла сами по себе. Они существуют в контексте сущности. Вы могли бы создать объект-значение, например, для запроса вроде findByEmail, но в целом они существуют лишь в качестве данных сущностей.
Сущность обладает идентичностью и жизненным циклом за счет свойств и их изменения со временем. Индивидуальность объекта-значения заключается в его данных. А поскольку эти данные не подвержены изменениям, можно сказать, что собственной «жизни» у объектов-значений нет.
Объектно-ориентированное программирование простым языком — объясняют эксперты
Сущности пребывают, так сказать, в континууме. Они обладают историей изменений в течение времени жизни, даже если мы эту историю нигде не храним. Объекты-значения, напротив, имеют нулевую продолжительность жизни. Мы с легкостью создаем и уничтожаем их. Мы не храним такие значения отдельно. Единственный способ их сохранить — сделать частью сущности, — «Сущность vs Объект-значение», Владимир Хориков
Самопроверка
Как убедиться, например, в том, что все телефонные номера в приложении корректны? С помощью объектов-значений можно предотвратить существование некорректных данных в принципе. Валидация данных на этапе создания объекта способствует инкапсуляции соответствующей логики в одном месте вместо её разброса по всему приложению. Зачастую подобная логика также несёт в себе ценную информацию о предметной области.
final class Email
{
private string $email;
public function __construct(string $email)
{
if (!preg_match('/^\S+@\S+$/', $email)) {
throw new InvalidEmailException(sprintf(
"'%s' is not a valid e-mail address",
$email
));
}
$this->email = $email;
}
public function getValue(): string
{
return $this->email;
}
}
// Тесты
use PHPUnit\Framework\TestCase;
final class EmailTest extends TestCase
{
public function testConstructorShouldThrowException(): void
{
$this->expectException(InvalidEmailException::class);
new Email('not.an.email');
}
public function testConstructorShouldCreateValidEmail(): void
{
$email = new Email('some@email.com');
self::assertEquals('some@email.com', $email->getValue());
}
}
Могут содержать логику
Нет ничего страшного в том, чтобы помещать логику в объект-значение, если эта логика непосредственно связана с ним:
final class Email
{
private string $email;
// ...конструктор с проверками и т.д.
public function getHost(): string
{
return substr($this->email, strpos($this->email, '@') + 1);
}
}
// Тесты
use PHPUnit\Framework\TestCase;
final class EmailTest extends TestCase
{
public function testGetHost(): void
{
$email = new Email('some@email.com');
self::assertEquals('email.com', $email->getHost());
}
}
В оригинальной статье следующий пример с точками использует перегрузку операторов в Kotlin. Так как в PHP такой возможности нет, мы определим метод add, — прим. пер.:
final class Point
{
public function __construct(private int $x, private int $y) {}
public function add(Point $other): Point
{
return new Point(
$this->x + $other->x,
$this->y + $other->y
);
}
public function equals(Point $other): bool
{
return $this === $other ||
($this->x === $other->x && $this->y === $other->y);
}
}
// Тесты
use PHPUnit\Framework\TestCase;
final class PointTest extends TestCase
{
public function testAdd(): void
{
self::assertTrue(
(new Point(1, 2))
->add(new Point(-4, 7))
->equals(new Point(-3, 9))
);
}
}
Несут смысл
Примитивные типы не содержат никакой полезной информации относительно предметной области. С другой стороны, объекты-значения обогащают систему типов, выражают специфику предметной области и оперируют её терминологией, позволяют API и сущностям четко обозначить смысл своих действий, являясь самодокументируемыми кусочками кода.
Давайте рассмотрим несколько примеров. Пусть у нас есть сущность Customer. Большинство её свойств можно представить в виде объектов-значений:
// с использованием примитивных типов
class Customer
{
private int $id;
private string $email;
private string $salutation;
private string $firstName;
private string $lastName;
private string $language;
private float $createdAt;
}
// с использованием объектов-значений
final class CustomerId
{
//
}
final class Email
{
//
}
final class Salutation
{
private function __construct(private string $salutation) {}
public static function mr(): self
{
return new self('Mr');
}
public static function ms(): self
{
return new self('Ms');
}
public function getSalutation(): string
{
return $this->salutation;
}
}
final class Name
{
public function __construct(private string $name) {}
public function first(): string
{
return explode(' ', $this->name)[0];
}
public function last(): string
{
return explode(' ', $this->name)[1];
}
}
final class Locale
{
public function __construct(private string $locale) {}
public function getValue(): string
{
return $this->locale;
}
}
class Customer
{
private CustomerId $id;
private Email $email;
private Salutation $salutation;
private Name $name;
private Locale $language;
private \DateTimeImmutable $createdAt;
}
Далее, сравните сигнатуры:
function notifyClient(string $email)
vs
function notifyClient(Email $recipient)
Параметр в первой сигнатуре напоминает мне венгерскую нотацию, плохой стиль программирования на современном языке. Вторая сигнатура фокусируется на смысле параметра — поэтому указание типа в его названии излишне.
Сравните вызовы ниже — второй из них не допускает неясности относительно порядка аргументов:
sendSms('test123', 'text');
vs
sendSms(new UserId('test123'), 'text');
Объекты-значения ещё более важны в функциональных языках программирования, так как там очень часто используются лямбда-выражения. Также объекты-значения улучшают читабельность кода в языках с поддержкой обобщенного программирования (пример на языке программирования Kotlin, — прим. пер.):
val articleMappings: Map<String, String>
vs
val articleMappings: Map<ArticleCategory, Region>
Объекты-значения явно выражают предметную область. Пример — обертка Money вместо двух полей типа BigDecimal и String <…>. Читатели кода поймут, что речь идёт о деньгах, а разработчики не смогут передавать в методы нечто, не представляющее собой деньги. Помимо этого, такая обертка может содержать валидацию, что значит дополнительные сведения о предметной области и повышение безопасности, — «Объект-значения», Флориан Бенц
Бонус: нормализация
Вероятно, однажды вам понадобится снизить строгость относительно пользовательского ввода ради гибкости и во избежание странных багов. Например, вы могли бы автоматически конвертировать » John.Doe@Gmail.com » в «john.doe@gmail.com»:
final class Email
{
private string $email;
public function __construct(string $email)
{
$email = trim(mb_strtolower($email));
if (!preg_match('/^\S+@\S+$/', $email)) {
throw new InvalidEmailException(sprintf("'%s' is not a valid e-mail address", $email));
}
$this->email = $email;
}
public function getValue(): string
{
return $this->email;
}
public function equals(Email $other): bool
{
return $this === $other || $this->email === $other->email;
}
}
// Тесты
use PHPUnit\Framework\TestCase;
final class EmailTest extends TestCase
{
public function testEquals(): void
{
$emailOne = new Email('lower@vw.org');
static::assertTrue($emailOne->equals(new Email('lower@vw.org')));
static::assertTrue($emailOne->equals(new Email(' Lower@vw.org ')));
}
}
Нормализация — часть парсинга и находится в зоне ответственности I/O. При этом объекты-значения, согласно «Чистой архитектуре», являются частью предметной области. Поэтому нормализация как часть реализации объекта-значения является спорной, необязательной и зависит от конкретной ситуации.
Вы можете исключить «самонормализацию» объектов-значений. Но не забывайте о валидации непосредственно в них. Иначе (как пример) может оказаться, что ваша база данных заполнена одними и теми же адресами электронной почты, отличающимися лишь регистром букв.
Заключение
Объект-значение — это обёртка над неизменяемыми данными, определяемая исключительно своими данными. Сущность, помимо данных, имеет идентификатор. И объекты-значения, и сущности могут содержать бизнес-логику.
«Одержимость примитивами» в коде — это почти так же плохо, как пользовательская форма с текстовыми полями для всего или база данных, сплошь заполненная строками: теряется смысл и допускается ввод некорректных данных. Объекты-значения привносят семантику в API и сущности. В контексте приложения, я рассматриваю объекты-значения на одном уровне с примитивными типами. В некоторых случаях они помогают компилятору (или интерпретатору, — прим. пер.) отловить ошибки на ранней стадии.
Учитывая небольшие затраты и большую пользу, я рекомендую как можно раньше выделять и создавать объекты-значения на проекте. Побуждающим к действию сигналом будет момент, когда вы начнете жонглировать литералами (парсить, разделять, валидировать, конвертировать и так далее).
Объекты-значения имеют ценность и смысл в большинстве языков программирования, объектно-ориентированных и нет. Самое время погуглить на тему «объекты-значения в [мой язык программирования]».