В этой короткой статье мы рассмотрим, что собой представляют неизменяемые объекты и почему нам следует их использовать. Неизменяемыми называются объекты, чьё состояние остаётся постоянным с момента их создания. Обычно такие объекты очень просты. Наверняка вы уже знакомы с типами enum или примитивами наподобие DateTimeImmutable. Ниже мы увидим, что если делать простые объекты неизменяемыми, то это поможет избежать определённых ошибок и сэкономить немало времени.

При реализации неизменяемых объектов необходимо:

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

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

Итак, в чём заключаются преимущества правильно реализованных неизменяемых объектов:

  • Состояние программы становится более предсказуемым, потому что меньшее количество объектов меняют собственные состояния.
  • Благодаря тому что становятся невозможны ситуации с разделяемыми ссылками (shared references), упрощается отладка.
  • Неизменяемые объекты удобно применять для создания параллельно исполняемых программ (в этой статье не рассматривается).

Примечание: неизменяемость всё же можно нарушить с помощью «отражений», сериализации/десериализации, биндинга анонимных функций или магических методов. Однако всё это довольно непросто реализовать и вряд ли будет использовано случайно.

Перейдём к примеру неизменяемого объекта:

<?php

final class Address
{
    private $city;

    private $house;

    private $flat;

    public function __construct($city, $house, $flat)
    {
        $this->city = (string)$city;
        $this->house = (string)$house;
        $this->flat = (string)$flat;
    }

    public function getCity()
    {
        return $this->city;
    }

    public function getHouse()
    {
        return $this->house;
    }

    public function getFlat()
    {
        return $this->flat;
    }
}

После того как создан, этот объект уже не меняет состояние, поэтому его можно считать неизменяемым.

Пример


Давайте теперь разберём ситуацию с переводом денег на счетах, в которой отсутствие неизменяемости приводит к ошибочным результатам. У нас есть класс Money, который представляет собой некую сумму денег.

<?php

class Money 
{
    private $amount;

    public function getAmount()
    {
        return $this->amount;
    }

    public function add($amount)
    {
        $this->amount += $amount;
        return $this;
    }
}

Используем его следующим образом:

<?php

$userAmount = Money::USD(2);
/**
 * Марк собирается отправить Алексу 2 доллара. Комиссия составляет 3%,
 * и мы прибавляем её к основному переводу.
 */
$processedAmount = $userAmount->add($userAmount->getAmount() * 0.03);
/**
 * Получаем с карты Марка для последующего перевода 2 доллара + 3% комиссии
 */
$markCard->withdraw($processedAmount);
/**
 * Отправляем Алексу 2 доллара
 */
$alexCard->deposit($userAmount);

Примечание: тип float здесь применён только для простоты примера. В реальной жизни для выполнения операции с необходимой точностью вам нужно будет использовать расширение bcmath или какие-то другие библиотеки вендоров.

Всё должно быть в порядке. Но в связи с тем, что класс Money изменяемый, вместо двух долларов Алекс получит 2 доллара и 6 центов (комиссия 3%). Причина в том, что $userAmount и $processedAmount ссылаются на один и тот же объект. В данном случае рекомендуется применить неизменяемый объект.

Вместо модифицирования существующего объекта необходимо создать новый либо сделать копию существующего объекта. Давайте изменим приведённый код, добавив в него создание другого объекта:

<?php

final class Money 
{
    private $amount;

    public function getAmount()
    {
        return $this->amount;
    }
}


<?php

$userAmount = Money::USD(2);
$commission = $userAmount->val() * 3 / 100;
$processedAmount = Money::USD($userAmount->getAmount() + $commission);
$markCard->withdraw($processedAmount);
$alexCard->deposit($userAmount);

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

<?php

final class Money 
{
    private $amount;

    public function getAmount()
    {
        return $this->amount;
    }

    public function add($amount)
    {
        return new self($this->amount + $amount, $this->currency);
    }
}

Используется он точно так же:

<?php

$userAmount = Money::USD(2);
/**
 * Марк собирается отправить Алексу 2 доллара. Комиссия составляет 3%,
 * и мы прибавляем её к основному переводу.
 */
$processedAmount = $userAmount->add($userAmount->val() * 0.03);
/**
 * Получаем с карты Марка для последующего перевода 2 доллара + 3% комиссии
 */
$markCard->withdraw($processedAmount);
/**
 * Отправляем Алексу 2 доллара
 */
$alexCard->deposit($userAmount);

В этот раз Алекс получит свои два доллара без комиссии, а с Марка правильно спишут эту сумму и комиссию.

Случайная изменяемость


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

Утечка внутренней ссылки на объект


У нас есть изменяемый класс, и мы хотим, чтобы его использовал неизменяемый объект.

<?php

class MutableX
{
    protected $y;

    public function setY($y)
    {
         $this->y = $y;
    }
}

class Immutable
{
    protected $x;

    public function __construct($x)
    {
        $this->x = $x;
    }

    public function getX()
    {
        return $this->x;
    }
}

У неизменяемого класса есть только геттеры, а единственное свойство присвоено конструктором. На первый взгляд, всё в порядке, верно? Теперь давайте используем это:

<?php

$immutable = new Immutable(new MutableX());
var_dump(md5(serialize($immutable))); // f48ac85e653586b6a972251a85dd6268

$immutable->getX();
var_dump(md5(serialize($immutable))); // f48ac85e653586b6a972251a85dd6268

Объект остался прежним, состояние не изменилось. Прекрасно!

Теперь немного поиграем с Х:

<?php

$immutable->getX()->setY(5);
var_dump(md5(serialize($immutable))); // 8d390a0505c85aea084c8c0026c1621e

Состояние неизменяемого объекта изменилось, так что он на самом деле оказался изменяемым, хотя всё говорило об обратном. Это произошло потому, что при реализации было проигнорировано правило «не хранить ссылки на изменяемые объекты», приведённое в начале этой статьи. Запомните: неизменяемые объекты должны содержать только неизменяемые данные или объекты.

Коллекции


Использование коллекций — явление распространённое. А что, если вместо конструирования неизменяемого объекта с другим объектом мы сконструируем его с коллекцией объектов?

Для начала давайте реализуем коллекцию:

<?php

class Collection
{
    protected $elements = [];

    public function __construct(array $elements)
    {
        $this->elements = $elements;
    }

    public function add($element)
    {
        $this->elements[] = $element;   
    }

    public function get($key)
    {
        return isset($this->elements[$key]) ? $this->elements[$key] : null ;
    }
}

Теперь воспользуемся этим:

<?php

$immutable = new Immutable(new Collection([new XMutable(), new XMutable()]));
var_dump(md5(serialize($immutable))); // 9d095d565a96740e175ae07f1192930f

$immutable->getX();
var_dump(md5(serialize($immutable))); // 9d095d565a96740e175ae07f1192930f

$immutable->getX()->get(0)->setY(5);
var_dump(md5(serialize($immutable))); // 803b801abfa2a9882073eed4efe72fa0

Как мы уже знаем, лучше не держать изменяемые объекты внутри неизменяемого. Поэтому заменим изменяемые объекты скалярами.

<?php

$immutable = new Immutable(new Collection([1, 2]));
var_dump(md5(serialize($immutable))); // 24f1de7dc42cfa14ff46239b0274d54d

$immutable->getX();
var_dump(md5(serialize($immutable))); // 24f1de7dc42cfa14ff46239b0274d54d

$immutable->getX()->add(10);
var_dump(md5(serialize($immutable))); // 70c0a32d7c82a9f52f9f2b2731fdbd7f

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

Наследование


Другая распространённая ситуация связана с наследованием. Мы знаем, что нужно:

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

Давайте модифицируем класс Immutable, чтобы он принимал только Immutable-объекты.

<?php

class Immutable
{
    protected $x;

    public function __construct(Immutable $x)
    {
        $this->x = $x;
    }

    public function getX()
    {
        return $this->x;
    }

}

Выглядит неплохо… пока кто-то не расширит ваш класс:

<?php

class Mutant extends Immutable
{
    public function __construct()
    {
    }

    public function getX()
    {
        return rand(1, 1000000);
    }

    public function setX($x)
    {
        $this->x = $x;
    }
}


<?php

$mutant = new Mutant();
$immutable = new Immutable($mutant);

var_dump(md5(serialize($immutable->getX()->getX()))); // c52903b4f0d531b34390c281c400abad
var_dump(md5(serialize($immutable->getX()->getX()))); // 6c0538892dc1010ba9b7458622c2d21d
var_dump(md5(serialize($immutable->getX()->getX()))); // ef2c2964dbc2f378bd4802813756fa7d
var_dump(md5(serialize($immutable->getX()->getX()))); // 143ecd4d85771ee134409fd62490f295

Всё опять пошло не так. Вот поэтому неизменяемые объекты должны быть объявлены как final, чтобы их нельзя было расширить.

Заключение


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

  • Объявить класс как final, чтобы его нельзя было переопределить при добавлении методов, изменяющих внутреннее состояние.
  • Объявить свойства как private, чтобы опять же их нельзя было изменить.
  • Избегать сеттеров и использовать конструктор для задания параметров.
  • Не хранить ссылки на изменяемые объекты или коллекции. Если вы внутри неизменяемого объекта храните коллекцию, то она тоже должна быть неизменяемой.
  • Проверять, что, если вам нужно модифицировать неизменяемый объект, вы делали его копию, а не переиспользовали существующий.
Поделиться с друзьями
-->

Комментарии (42)


  1. Delphinum
    19.05.2016 15:18

    Думаю в качестве примера использования «неизменяемых объектов» прекрасно подошел бы шаблон «Value Object» Фаулера.


    1. Fesor
      19.05.2016 16:50

      Собственно в статье оно и приводится, потому как это самые настоящие объекты-значения. Вот только VO не обязательно должны быть имутабельными, во всяком случае это только рекомендуется. Так же могут быть коллизии с другой литературой, где под VO подразумеваются DTO.


      1. Delphinum
        19.05.2016 16:55

        В статье приводятся «неизменяемые объекты», ни слова о VO в статье нет, а ведь пример с VO был бы более показательным.


        1. SamDark
          19.05.2016 17:26
          +1

          Ни слова о VO в статье нет потому что статья не про VO, а про неизменяемость в целом.


          1. Delphinum
            19.05.2016 18:09
            -2

            Давно ли на хабре стало принято отвечать не читая ветку комментов?


            1. Fesor
              19.05.2016 19:41

              Александр написал ровно то что я хотел, так что в чем суть притензии. Если что продублирую.


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


              1. Delphinum
                19.05.2016 19:44
                -1

                Думаю в качестве примера использования «неизменяемых объектов» прекрасно подошел бы шаблон «Value Object» Фаулера

                Перечитайте еще пару раз.


                1. SamDark
                  19.05.2016 22:13
                  +2

                  Он там и есть в примере. Просто не написано «это VO».


                  1. Delphinum
                    20.05.2016 14:54

                    В статье абстрактный (логически, а не программно) пример реализации «неизменяемых объектов». От того, что VO тоже должны быть неизменяемыми, не значит, что они тоже есть в примерах, поверьте.


                    1. Fesor
                      20.05.2016 15:43
                      +1

                      От того, что VO тоже должны быть неизменяемыми, не значит, что они тоже есть в примерах, поверьте.

                      VO не должны быть неизменяемыми, они могут таковыми быть и желательно что бы они таковыми были, но имутабельность это отдельный вопрос.


                      Ну и из ваших слов не понятно. то есть вы признаете реализацию паттерна только если рядом напишут название? Как-то это глупо.


                      В целом вы придираетесь к названиям, хотя это не важно. Суть в статье передана верно, в чем притензия?


                      1. Delphinum
                        20.05.2016 15:45

                        Ну и из ваших слов не понятно. то есть вы признаете реализацию паттерна только если рядом напишут название? Как-то это глупо

                        В целом вы придираетесь к названиям, хотя это не важна. Суть в статье передана верно, в чем притензия?

                        Я не буду против, если вы назовете пример, при чтении которого у читателя не возникнет вопроса «зачем» хотя бы трамваем, мне все равно. Говорю о VO я здесь по двум причинам:
                        1. Прекрасно подходит в качестве примера
                        2. Все его знают (я надеюсь)

                        Боюсь вы размышляете не о том, о чем говорю я.


                        1. Fesor
                          20.05.2016 16:29

                          Прекрасно подходит в качестве примера

                          Первый же пример с Address. Просто в статье не говорится о том что это value object, а просто объект. Что более чем просто понятно и не нагружает мозг читателя лишними терминами.


                          1. Все его знают (я надеюсь)


                          знаете, вот из общения с PHP разработчиками — далеко не все. А многие под VO подразумевают только DTO. Да и Эванса многие не читали и используют active record с публичными пропертями. А еще есть приличный процент тех кто используют массивчики и глобальное состояние в сингелтонах.


                          1. Delphinum
                            20.05.2016 16:39

                            Просто в статье не говорится о том что это value object

                            А ведь как было бы здорово, если бы говорилось.
                            у читателя не возникнет вопроса «зачем»


                            Что более чем просто понятно и не нагружает мозг читателя лишними терминами

                            Все его знают (я надеюсь)


                            знаете, вот из общения с PHP разработчиками — далеко не все

                            Вот с этого и надо было начинать, а то вас куда то не в ту глуш увело.


                            1. Fesor
                              20.05.2016 17:00

                              Вот с этого и надо было начинать, а то вас куда то не в ту глуш увело.

                              нет, вы зациклились на том что бы в статье упоминалось VO. Я пытался объяснить что статья НЕ про VO, хотя темы пересекаются. А дальше пошла рекурсия.


                              1. Delphinum
                                20.05.2016 23:15

                                нет, вы зациклились на том что бы в статье упоминалось VO

                                Я не циклился, я предложил VO как хороший пример использования «неизменяемых объектов». Пример, отвечающий на вопрос «зачем».
                                Я пытался объяснить что статья НЕ про VO

                                А кто спорит то? )


                1. Fesor
                  19.05.2016 22:22

                  Перечитайте еще пару раз.

                  вы тоже перечитайте еще пару раз. Пример с Address это классический VO. Просто сам термин VO имеет несколько значений в зависимости от контекста. Да, интерпритация Фаулера и Эванса популярна, но это не подавляющее большинство. Да и зачем усложнять то? Если у вас объект имутабельный — он автоматически становится VO как бы мы не крутили.


                  1. Delphinum
                    20.05.2016 14:55
                    -1

                    он автоматически становится VO как бы мы не крутили

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


                    1. Fesor
                      20.05.2016 15:40
                      +1

                      VO это тоже не цель — это просто средство.


                      Конкретно в этой статье речь только про имутабельноть и как это важно для борьбы с сайд эффектами. VO это более обширная тема и имутабельность это не только их характеристика.


                      Это бесполезный спор. Хотите — напишите статью про VO.


                      1. Delphinum
                        20.05.2016 15:46

                        Согасен, мы говорим о разном судя по всему.


                  1. VolCh
                    27.05.2016 06:24

                    Если у вас объект имутабельный — он автоматически становится VO как бы мы не крутили.


                    Нет. Определяющая характеристика VO — сравнение по значению всех полей, а не по идентифицирующим. Пример — объект финансовой транзакции по какой-то сущности. Иммутабельный (чёрную бухгалтерию не рассматриваем), изменение баланса сущности производится выравнивающими/сторнирующими транзакциями, но обладающий чёткой идентичностью — даже если все значимые значения (суммы, даты с точностью до наносекунд, контрагенты и т. п.) равны, то всё равно каждая транзакция обладает собственной идентичностью. С другой стороны, иммутабельность не является даже необходимым признаком VO. Это лишь рекомендуемая характеристика для избежания необходимости отслеживания побочных эффектов.


    1. Borro
      19.05.2016 21:43
      +1

      Так же в качестве примера неизменяемых объектов можно было бы привести PSR-7


  1. misterio
    19.05.2016 17:00
    -5

    А давайте писать все примеры на PHP 7 и двигать его в массы


    1. SamDark
      19.05.2016 17:23
      +2

      Все примеры работают на PHP 7.


      1. misterio
        20.05.2016 22:09

        я имел ввиду использовать типизацию, return types и т.д.


        1. SamDark
          21.05.2016 00:43

          Можно, но смысл от этого не поменяется. В следующих статьях предложу это Марку.


        1. Fesor
          21.05.2016 01:18

          использовать типизацию

          вы имели в виду тайп хинтинг для скаляров? Можно, но это излишнее усложнение.


          return types и т.д.

          опять же можно, но в половине примеров это бесполезно без дженериков и nullable types (последние появятся только в 7.1)


  1. mnv
    19.05.2016 19:34

    На мой взгляд для полноты примера было бы полезно описать код Money::USD().


    1. mnv
      19.05.2016 19:40

      И Money::val(). Он чем-то отличается от Money::getAmount()?


      1. Fesor
        19.05.2016 19:47
        -1

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


        1. mnv
          19.05.2016 20:10
          +3

          Это принятое обозначение, чтобы сослаться на метод класса, не важно статический он или нет, вот например, тут так делают. Или я совсем туплю под вечер? :)


          1. VolCh
            19.05.2016 21:33

            Да, общепринятое.


    1. Fesor
      19.05.2016 19:43

      это просто статический метод-фабрика. Именованный конструктор если хотите. Тут не так давно перевод поста Матиаса Верраеса проскакивала на эту тему.


      1. mnv
        19.05.2016 19:47
        +1

        Суть-то понятна, но код этого метода — 1 строка, не обязательно на ней было экономить


        1. Fesor
          19.05.2016 19:48
          +2

          public static function USD($val) {
              return new static($val, static::CURRENCY_USD);
          }
          


          1. crocodile2u
            20.05.2016 00:00
            +2

            В зачем здесь Late Static Binding?


            1. Fesor
              20.05.2016 01:39

              Если класс final — то точно не нужно, а так не вижу проблемы. Если вы их видите — предлагаю обсудить, мне будет полезно.


              1. Finesse
                20.05.2016 11:54

                Список аргументов конструктора может измениться при наследовании


                1. mnv
                  20.05.2016 12:30

                  final наследовать нельзя.


                  1. Finesse
                    20.05.2016 12:32

                    Несомненно. Fesor сказал «Если класс final — то точно не нужно, а так не вижу проблемы». Как я понял, под «а так» подразумевалось, что класс не финальный.


                1. Fesor
                  20.05.2016 12:43

                  Это называется "сломать обратную совместимость". Да и потом частенько при применении статических фабрик конструктор вообще делают приватным, так что вполне себе можно сделать так и точно об этом не беспокоиться:


                  public static function USD($amount) {
                      $money = new static();
                      $money->amount = $amount;
                      $money->currency = static::CURRENCY_USD;
                  
                      return $money;
                  }

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


                  Собственно в этом соль статических методов-фабрик. Мы можем объявлять для объекта разные "конструкторы" со своими ограничениями. Вроде у нас есть два способа создать объект, и для одного нужно два аргумента а для другого 4 и не меньше.


  1. nepster09
    20.05.2016 10:07
    +1

    <?php
    
    final class Money 
    {
        private $amount;
    
        public function getAmount()
        {
            return $this->amount;
        }
    
        public function add($amount)
        {
            return new self($this->amount + $amount, $this->currency);
        }
    }
    


    Я что-то не понял? Или откуда тут взялся $this->currency?


    1. mnv
      20.05.2016 10:16
      +2

      Это валюта, в которой хранится сумма. Конструктора просто не хватает в примере. Что-то типа такого должно быть


      final class Money 
      {
          const CURRENCY_USD = 1;
      
          private $amount;
          private $currency;
      
          private function __construct($amount, $currency)
          {
              $this->amount = $amount;
              $this->currency = $currency;
          }
      
          public static function USD($amount) {
              return new static($amount, self::CURRENCY_USD);
          }
      
          public function getAmount()
          {
              return $this->amount;
          }
      
          public function add($amount)
          {
              return new self($this->amount + $amount, $this->currency);
          }
      }