Обложка: Используйте объекты-значения

Используйте объекты-значения

Фуллстек-разработчик 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 и сущности. В контексте приложения, я рассматриваю объекты-значения на одном уровне с примитивными типами. В некоторых случаях они помогают компилятору (или интерпретатору, — прим. пер.) отловить ошибки на ранней стадии.

Учитывая небольшие затраты и большую пользу, я рекомендую как можно раньше выделять и создавать объекты-значения на проекте. Побуждающим к действию сигналом будет момент, когда вы начнете жонглировать литералами (парсить, разделять, валидировать, конвертировать и так далее).

Объекты-значения имеют ценность и смысл в большинстве языков программирования, объектно-ориентированных и нет. Самое время погуглить на тему «объекты-значения в [мой язык программирования]».

Оригинал статьи