Noveo

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

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

Fullstack-разработчик Springer Nature Луис Соареш (Luís Soares) в своей статье описывает достоинства объектов-значений на примере языка Kotlin. Однако объекты-значения могут быть не менее полезными и в других языках! Доказательства — в нашем переводе статьи: разработчик Noveo Ян перевел ее на русский, заменив язык примеров на РНР.

Value Objects Noveo Translation

Как убедиться, что два разных компонента «говорят» на одном языке? Как проверить, не разбросаны ли у вас по проекту некорректные данные? Что говорят обычные строки или целые числа о вашей предметной области?

 

Например, в Java (или PHP, начиная с версии 8.1, — прим. пер.) перечисления являются встроенными в язык объектами-значениями. Тем не менее, они не подходят для произвольного количества значений (и являются скорее технической деталью реализации, — прим. пер.). Типичными объектами-значениями являются: адрес электронной почты, пароль, локаль, номер телефона, деньги, IP-адрес, URL, идентификатор сущности, рейтинг, путь до файла, точка, цвет, дата, диапазон дат, расстояние.

 

Объекты-значения часто упоминаются в связке с предметно-ориентированным проектированием. Давайте рассмотрим их некоторые характеристики на примере языка программирования PHP.

Контейнер данных

Объект-значение — это контейнер произвольных данных:

class Email
{
    public function __construct(private string $email) {}

    public function getValue(): string
    {
        return $this->email;
    }
}

Непосредственное значение хранится внутри класса

 

Обычно объект-значение хранит в себе какое-то одно конкретное значение, но вполне может и больше. Например, цвет в формате RGBA содержит четыре целых числа, а точка в пространстве — три десятичных. Так и между параметрами функции часто может существовать логическая связь. В таком случае их стоит выделить в отдельный класс. Плюсы такого подхода:

 

1. Меньшее количество аргументов функции;

2. Явная и однозначная связь между параметрами;

3. Ярче выражена идея того, что вообще делает код.

 

Просто сравните:

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

 

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

 

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

 

Оригинал: https://medium.com/swlh/you-should-be-using-value-objects-568b19b1df8d

 

Если вы нашли ошибку, пожалуйста, выделите фрагмент текста и нажмите Ctrl+Enter.

НазадПредыдущий пост ВпередСледующий пост

Сообщить об опечатке

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