Принципы программирования - Часть 3: Жизненный цикл объектов

В первой статье мы рассмотрели способы снижения сложности функций и методов. Во второй статье мы поговорили о том, как сделать их еще проще, избавившись от null. В этой статье мы поговорим о том, как правильно организовать жизненный цикл объектов.

Немного об объектах

Вот небольшой список технических фактов об объектах. Возможно вы уже знакомы с ними.

  • Объекты - это экземпляры классов.
  • Объекты располагаются в памяти.
  • Объекты имеют атрибуты (данные) и методы (поведение).
  • Объекты скрывают подробности своей реализации за публичным интерфейсом.
  • Объекты могут взаимодействовать между собой с помощью отправки сообщений.
  • Отправка сообщения объекту - это вызов одного из его методов.
  • Реализация (поведение) метода соответствует контракту объекта.
  • Контракт объекта объявляется 1. формально (на уровне языка), в самом объявлении класса (например используя указание типов аргументов функции и ее возвращаемого значения); 2. неформально, используя DocBlocks.

Когда мы говорим, что объект скрывает детали своей реализации за публичным интерфейсом, мы обычно подразумевает только его атрибуты и методы. Мы забываем о его создании, хранении и уничтожении. Все это тоже надо скрывать за интерфейсом.

Если мы не позаботимся об инкапсуляции создания объекта, или забудем позаботиться о других аспектах его жизненного цикла, то потом мы столкнемся с:

  • анемичной доменной моделью,
  • объектами с невалидными данным,
  • объектами с неконсистентными данными,
  • объектами, которые изменяются в разных местах.

Это, естественно, приведет к появлению ошибок и архитектурных проблем.

Создание объектов

Первое, что нам нужно сделать, чтобы получить грамотно спроектированный объект, - инкапсулировать логику создания этого объекта. Нужно следовать принципу "есть возможность создать только валидный объект".

Не должно быть объектов, у которых инициализация происходит после создания (отдельно от него). Например, так:

$circle = new Circle();
$circle->setRadius(10);

Круг не может существовать без радиуса. Поэтому нельзя допускать создание объекта, без указания этого значения. Такая возможность неизбежно приведет к неправильному использованию. Например, такому:

$circle = new Circle();
// Это невозможно обработать корректно:
$circle->calculateArea();

Если объект не может существовать без каких-либо данных, то вы должны предоставить эти данные во время его создания.

$circle = new Circle(10);
// Теперь это действие возможно выполнить:
$circle->calculateArea();

Создавайте объекты понятным способом

Если взглянуть на создание объекта Cyrcle, то не ясно, что за число 10 используется.

$circle = new Circle(10);

Будет лучше, если мы явно покажем пользователю нашего класса, какие именно данные нам нужны при создании объекта. Мы можем сделать это при помощи именованного конструктора:

class Circle
{
    public static function fromRadius($radius)
    {
        return new self($radius);
    }

    private function __construct($radius)
    {
        $this->radius = $radius;
    }
    ...
}

Теперь объект Cyrcle может быть создан следующим образом:

Circle::fromRadius(10);

Так же хорошей идеей будет сделать сам конструктор приватным, чтобы оставить пользователю возможность создавать объекты только через именованные конструкторы.

Создавайте объекты разными способами

Иногда нужна возможность создавать объект несколькими способами, использую разные входные данные. Например, мы можем создавать круг, указывая его диаметр. Для этого вы можете создать еще один именованный конструктор, который будет инкапсулировать логику получения радиуса из полученного диаметра:

class Circle
{
    public static function fromDiameter($diameter)
    {
        $radius = $diameter / 2;
        return new self($radius);
    }
    ...
}

Еще уместно использовать именованные конструкторы, когда для создания объекта не обязательно получать какие-то данные. Например:

class Person
{
    private $partner;

    public static function married(Person $spouse)
    {
        $person = new self();
        $person->partner = $spouse;
        return $person;
    }

    public static function bachelor()
    {
        $person = new self();
        // $person->partner will be left undefined
        return $person;
    }
}

Класс Person не имеет публичного конструктора. Именованные конструкторы заполняют приватные поля объекта перед передачей его клиенту.

Проверяйте корректность аргументов конструктора

Конструктор должен тщательно проверять корректность принимаемых аргументов. Если этого не делать, то появится возможность создания "поломанных" объектов.

В примере с кругом не достаточно просто убедиться, что радиус является float значением. Помимо этого нужно проверить, что его значение больше 0. Будем проверять это внутри конструктора и бросать исключение, если значение радиуса некорректно. Лучше выбросить исключение, чем создать некорректный объект:

class Circle
{
    public function __construct($radius)
    {
        if (!is_float($radius) || $radius <= 0) {
            throw new \InvalidArgumentException(
                'Radius should be a float and larger than 0'
            );
        }
        $this->radius = $radius;
    }

    ...
}

Изменение объектов

Иногда объект должен быть изменен. Для примера рассмотрим объект (программный), который является доменным объектом. У него есть некоторое внутреннее состояние, которое можно менять через публичный интерфейс - public методы, предназначенные для этого. Например:

$invoice = new Invoice(...);
// Когда производится оплата, меняется внутреннее состояние объекта.
$invoice->processPayment(100);

После произведения оплаты, счет(Invoice), остается все тем же счетом, но уже с другим состоянием.

Проверяйте аргументы методов

Как и в случае с конструктором нужно внимательно проверять входные данные обычных методов (такие как размер оплаты). Если этого не делать, есть опасность получить объект с некорректным внутренним состоянием.

class Invoice
{
    private $payments = [];

    public function processPayment($amount) {
        if (!is_int($amount) || $amount <= 0) {
            // we use integers to prevent rounding problems with floats
            throw new \InvalidArgumentException(
                'Paid amount should be an integer and more than 0'
            );
        }
        $this->payments[] = $payment;
    }
}

Естественно такие проверки влекут за собой дублирование кода. Я рекомендую использовать для них специальные библиотеки, например beberlei/assert.

Изменяйте объекты последовательно

Методы, меняющие состояние, должны требовать все данные, необходимые для корректной смены этого состояния. Например, будет не корректно сменить улицу в адресе компании без указания нового номера дома. Поэтому надо требовать оба этих значения.

class Company
{
    public function updateAddress($street, $number /*, ... */)
    {
        ...
    }
}

Предпочитайте неизменяемые объекты

Изменение объектов - это удобно, но так же оно вносит ряд сложностей. Например:

  • После смены состояния изменится и поведение объекта при вызове его методов. Они могут начать возвращать другие значения, выбрасывать исключения и т.п.
  • Когда один объект используется несколькими клиентами, изменение его одним клиентов скажется на другом клиенте. Это порождает множество неочевидных и трудноуловимых ситуаций.

Поэтому при работе с объектами всегда руководствуйтесь принципом "Объект не должен меняться, если это возможно".

Когда вы проектируете объект, вам надо попытаться определить роль этого объекта и подумать, обязательно ли ему меняться для выполнения своей роли? В общем случае, единственный тип объектов, который нуждается в изменении состояния - это сущности (entities).

Все остальные объекты могут быть созданы и больше никогда не меняться. Это справедливо для разного рода сервисов, диспетчеров, валидаторов, роутов, контроллеров и т.д. Так же это относится и к объектам-значениям (value objects).

Немного об объектах-значениях

Объекты-значения неизменяемы по определению. В DDD объекты-значения используются для описания отдельных свойств сущностей (например длинs, возрастf, адресf и т.д.). В отличие от обычных объектов, объекты-значения считаются равными не когда они ссылаются на один и тот же участок памяти, а когда их поля равны. Объекты-значения не изменяются, они просто выбрасываются - заменяются на аналогичные объекты, которые более точно описывают сущность. Если новый объект имеет такие же значения полей, что и старый, то это аналогично тому, что объект не менялся вообще.

Использование объектов-значений не ограничено одним только доменным уровнем. Вы можете использовать их везде, где хотите явно указать тип значения, тем самым гарантируя корректность данных. Например:

class Server
{
    private $host;

    public function __construct($host, $port)
    {
        \Assert\that($host)->string();
        \Assert\that($port)->integer();
        ...
    }
}

Объекты-значения как пользовательские типы

Объекты-значения могут быть использованы как и обычные типы. Они могут специализировать доступные типы самого языка. Например, когда мы хотим сохранить URL в переменную, мы обычно используем тип string. Функция, принимающая эту строку, должна проверить, что строка содержит валидный URL.

function getHost($url) {
    \Assert\that($url)
        ->string()
        ->url();
    ...
}

Вместо этого мы можем гарантировать корректность передаваемого URL, используя для него специальный тип:

function getHost(Url $url) {
    ...
}

Класс Url сам проверит корректность значения при создании:

class Url
{
    private $url;

    private function __construct($url)
    {
        $this->url = $url;
    }

    public static function fromString($url)
    {
        \Assert\that($url)
            ->string()
            ->url();
        return new self($url);
    }
}

Объекты-значения обычно содержат логику, которая раньше была размазана по коду. Например, мы можем поместить логику извлечения имени хоста из URL в сам класс Url:

class Url
{
    ...
    public function getHost()
    {
        ...
    }
}

Освобождение объектов

В PHP объект уничтожается после удаления всех ссылок на него. Когда объект-значение заменяется другим, то старый автоматически уничтожается. То же самое касается сервисов: если они больше не используются, они будут освобождены. Но иногда бывает лучше сохранить ранее созданный сервис. Если вы хорошо спроектировали ваши сервисы (в том числе сделали их неизменяемыми), то они смогут выполнять свою работу раз за разом. И не будет необходимости раз за разом создавать новые объекты. При очередном вызове они будут вести себя точно так же, как и раньше. Таким образом мы приходим к очень важному принципу: "Проектируйте сервисы так, чтобы они могли работать вечно".

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

Восстановление объектов

После того, как вы освободили объект, вам может понадобиться снова получить его. Например, вы захотите восстановить его из базы данных или десериализовать его. Это добавляет новых проблем. В этой статье мы пришли к выводу, что нужно скрывать внутреннюю реализацию объекта. Мы используем именованные конструкторы для корректного создания объекта. Для его изменения мы используем специальные методы. Но для реконструкции или десериализации объекта нам придется проигнорировать этот принцип и "залезть внутрь" объекта.

Сохранение

При сохранении объекта, включая все изменения в нем (речь идет о сущностях), обычно возможно соблюдать его границы. Например в случае использования рефлексии для сбора и реконструкции внутреннего состояния объекта.

При проектировании объектов не надо ставить персистентность на первое место. Думая об этом, вы будете думать не о том, что важно. Например, вы будуте подстраивать свои доменные модели под популярные библиотеки. Начните с проектирования API ваших объектов. Убедитесь, что возможно создать только корректный объект и только после этого думайте о персистентности. Это потребует лишь незначительных изменений.

(Де)сериализация

Сериализация и десериализация (например в/из JSON или XML строки) - это частая задача, для которой обычно используют библиотеки (которые использую рефлексию) или специальные методы в своих классах:

class Country
{
    private $countryCode;

    public function serialize()
    {
        return [
            'country_code' => $this->countryCode
        ];
    }

    public static function deserialize(array $data)
    {
        $country = new self();
        $country->countryCode = $data['country_code'];
        return $country;
    }
}

Метод serialize() нормализует данные, чтобы потом их можно было сохранить, как обычный текст. При реконструировании эта строка передается в метод deserialize(), который создает новый объект и заполняет его данными. Здесь полученные данные не проверяются т.к. мы ожидаем получить данные корректных структур и типов.

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

class Country
{
    private $countryCode;

    public static function fromCountryCode($countryCode)
    {
        if (/* $countryCode невалиден */) {
            throw new \InvalidArgumentException(...);
        }
        $country = new self();
        $county->countryCode = $countryCode;
        return $country;
    }

    public function serialize()
    {
        return [
            'country_code' => $this->countryCode
        ];
    }

    public static function deserialize(array $data)
    {
        \Assert\that($data)
            ->arrayKeyExists('country_code');
        return Country::fromCountryCode($data['country_code']);
    }
}

Теперь метод deserialize() проверяет корректность данных и создает объект через стандартный метод Country::fromCountryCode(). А в случае получения некорректных данных он бросает исключение.

Нам нужен способ получше

Приведенное решение не идеально: мы бросаем исключение при первой же возникшей проблеме. Вместо этого мы могли бы вернуть клиенту полный перечень ошибок в полученных данных. Но главная проблема в том, что мы используем одинаковый подход к созданию объекта как из сериализованных данных, так и из полученных от пользователя. Вместо этого мы должны сначала десереализовать данные в DTO, а уже потом на основе этого DTO создать доменный объект. Подробней об этом мы поговорим в следующей статье.

Это вольный перевод статьи "Programming guidelines - Part 3: The life and death of objects", которую написал Matthias Noback.

Коментарии

Используйте Markdown

Спасибо за коментарий!
Ваше сообщение будет доступно после проверки.