PHP и неизменяемость: экземпляры, которые могут быть изменены. Часть 2

Это перевод оригинальной статьи Саймона Холивелла PHP and Immutability. Продолжение — в другом посте. Начало — в этом посте.

В прошлой статье мы научились создавать неизменяемые структуры данных на PHP. Было несколько вещей, которые требовали доработки, но мы справились. Теперь приступим к улучшению неизменяемого класса, чтобы он был более полезным и давал бы проще созавать измененные копии. Заметьте, что это копии оригинального объекта, а не изменения в самом объекте.

Простое изменение параметров

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

$a = new Immutable('Тест');
echo $a->getX(); // Тест
$b = new Immutable($a->getX() . ' опять');
echo $b->getX(); // Тест опять

Так просто… Слишком просто!

Эта техника работает сравнительно хорошо с таким малым количеством данных, но что будет, если у нас каждый раз 5 или 10 параметров для передачи? Вот утрированный пример для иллюстрации:

$a = new Immutable('A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K');
echo $a->getK(); // K
$b = new Immutable(
    $a->getA(), $a->getB(), $a->getC(), $a->getD(), $a->getE(), $a->getF(),
    $a->getG(), $a->getH(), $a->getI(), $a->getJ(), $a->getK() . ' некоторое изменение'
);
echo $b->getK(); // K некоторое изменение

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

Изменение при клонировании

В PHP есть одна очень полезная странность, котрой мы воспользуемся. При помощи нее мы создадим новые измененные копии объекта.

Вместо непосредственного изменения объекта, как вы делали бы в традиционном ООП, мы сделаем клон объекта и изменим его закрытые (приватные) свойства. Да-да, вы прочли это правильно, вы можете изменять приватные свойства экземпляра класса!

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

$a = new Immutable('A', 'B');
echo $a->getB(); // B
$b = clone $a;
$b->B = '22';
// Fatal error: Cannot access private property Immutable::$B

Так… Это не сработало! Я должен был сказать, что, чтобы можно это было так менять,  клонирование нужно выполнять только изнутри метода того же класса.

declare(strict_types=1);

final class Immutable {
    private $x;
    private $mutable = true;
    public function __construct(string $input) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор бы вызван дважды.');
        }
        $this->x = $input;
        $this->mutable = false;
    }
    public function getX(): string {
        return $this->x;
    }
    public function withX(string $input): Immutable {
        $clonedClass = clone $this;
        $clonedClass->x = $input;
        return $clonedClass;
    }
}
$a = new Immutable('TEST');
echo $a->getX(); // TEST
$b = $a->withX('noop');
echo $b->getX(); // noop

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

Предотвращение установки неожидаемых свойств

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

В PHP свойства можно добавлять к классу во время выполнения программы — даже к финальному (final) классу. Этого мы не хотим, так как это изменило бы форму нашего класса и таким образом сделало бы его изменяемым. Простой способ этого избежать — добавить к классу пустую реализацию магического метода __set()  .

public function __set(string $id, $val): void {
        return;
}

Можно также удалить значения свойств при помощи конутркции unset() . Мы опять же можем этого избежать при помощи другого пустого магического метода:

public function __unset(string $id): void {
        return;
}

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

Объединенное изменение при клонировании

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

$a = new MyFantasyImmutable('TEST', 'foo');
echo $a->getX(); // TEST
echo $a->getY(); // foo
$b = $a->withX('noop')->withY('bar');
echo $b->getX(); // noop
echo $b->getY(); // bar

Хотя это и будет работать, есть несколько вещей, которые мне не нравятся в таком подходе. Между вызовами withX()  и withY()  создаётся одноразовый экземпляр класса, так что вам понадобится создавать функцию with*() для каждого свойства, и соединение методов в цепочку быстро начнет раздражать.

Конечно, есть и другой способ.

Во-первых, давайте определим новый неизменяемый класс с несколькими свойствами.

declare(strict_types=1);

final class Bike {
    private $engineCc, $brakes, $tractionControl;
    private $mutable = true;
    public function __construct(int $engineCc, string $brakes, bool $tractionControl) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор был вызван дважды.');
        }
        $this->engineCc = $engineCc;
        $this->brakes = $brakes;
        $this->tractionControl = $tractionControl;
        $this->mutable = false;
    }
    public function __get($property) {
        if (property_exists($this, $property)) {
            return $this->$property;
        }
    }
    public function __set(string $id, $val): void {
        return;
    }
    
    public function __unset(string $id): void {
        return;
    }
}

Чтобы пример был короче, я применил маленький магический метод __get()  вместо написания get-метода для каждого свойства класса. Вам нужно было бы писать по одному на каждое свойство, что привело бы к функциям вроде getEngineCc() , getBrakes()  иgetTractionControl() . Вместо этого вы обращаетесь к ним напрямую, как к свойствам.

$zx9r = new Bike(900, '2 плавающих поршневых диска', false);
echo $zx9r->engineCc; // 900
echo $zx9r->brakes; // 2 плавающих поршневых диска

$cagivaRaptor = new Bike(1000, '2 плавающих поршневых диска', false);
var_dump($cagivaRaptor->tractionControl); // bool(false)

Но вернемся к изменениям! Чтобы дать легко манипулировать свойствами класса при клонировании мы можем добавить к классу простой метод.

    public function with(array $args): Bike {
        $clonedClass = clone $this;
        foreach($args as $property => $value) {
            if (property_exists($clonedClass, $property)) {
                $clonedClass->$property = $value;
            }
        }
        return $clonedClass;
    }

Теперь, когда вам понадобится новый класс с модификациями — может, когда вы выпустите новую модель мопеда — вы можете просто вызвать with()  и передать в него ассоциативный массив.

$zx9r = new Bike(900, 'Плавающий 2-поршневой', false);
$zx10r = $zx9r->with(['engineCc' => 1000, 'tractionControl' => true]);
echo $zx10r->engineCc; // 1000
echo $zx10r->brakes; // Плавающий 2-поршневой
var_dump($zx10r->tractionControl); // bool(true)

Хотя это работает нормально, вы могли заметить, что фактически мы забрали у PHP возможность проверять тип ввода. Мы больше не заполняем класс через конструктор.

Это плохо, потому что туда могут быть переданы нескалярные значения (больше информации, почему это плохо, в будущей статье).

 

Один способ решения этой проблемы — заменить with() на функцию, которая использует Reflection, чтобы разобраться с порядком следования параметров конструктора и слить в него новые значения.

    public function with(array $args): Bike {
        $reflection = new ReflectionMethod($this, '__construct');
        $new_parameters = array_map(function($param) use ($args) {
            $x = $param->name;
            return (array_key_exists($x, $args))
                ? $args[$x] // используем только что переданные значения
                : $this->$x; // откат к текущему состоянию
        }, $reflection->getParameters());
        return new self(...$new_parameters);
    }

Когда создается новый экземпляр класса, новые значения передаются в конструктор, что даёт гарантию, что их тип правильно проверяется.

Вызывайте этот метод так же, как и последнюю реализацию with() . Он желает предположение, что свойства класса имеют те же имена, как и параметры конструктора (например, $this->engineC  — то же самое, что и параметр конструктора $engineCc ).

Это даст нам окончательный класс Bike :

declare(strict_types=1);

final class Bike {
    private $engineCc, $brakes, $tractionControl;
    private $mutable = true;
    public function __construct(int $engineCc, string $brakes, bool $tractionControl) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор был вызван дважды.');
        }
        $this->engineCc = $engineCc;
        $this->brakes = $brakes;
        $this->tractionControl = $tractionControl;
        $this->mutable = false;
    }
    public function __get($property) {
        if (property_exists($this, $property)) {
            return $this->$property;
        }
    }
    public function with(array $args): Bike {
        $reflection = new ReflectionMethod($this, '__construct');
        $new_parameters = array_map(function($param) use ($args) {
            $x = $param->name;
            return (array_key_exists($x, $args))
                ? $args[$x] // используем только что переданные значения
                : $this->$x; // откат к текущему состоянию
        }, $reflection->getParameters());
        return new self(...$new_parameters);
    }
    public function __set(string $id, $val): void {
        return;
    }
    
    public function __unset(string $id): void {
        return;
    }
}

Также держите в голове, что Reflection API, предоставляемое PHP, не особенно шустрое, так что если вы сторонник микрооптимизаций, то лучше его избегать. Но если вы все же готовы держать удар, то безопасность от проверки на тип стоит того.

Использование специального строителя для генерации неизменяемых объектов

Другой способ решения этой конкретной проблемы с неизменяемыми объектами может заключаться в использовании второго класса для генерирования таких объектов. Это даст вам возможность избегнуть использования Reflection API, но по-прежнему предоставит преимущество проверки на тип. Есть доля иронии в том, что мы будем использовать изменяемого строителя, чтобы создавать неизменяемый объект, но оставйтесь со мной…

Во-первых, нам нужно определить неизменяемый объект, который будет производить наш строитель. Я здесь также убираю магический __get(),  так как наша цель в облегчении статического анализа нашего кода. Это поможет IDE в подсказках при вводе кода и инструментам, обеспечивающим качество кода — прочесть наш код и предположительно сделать его легче для понимания.

 

declare(strict_types=1);

final class Bike {
    private $engineCc, $brakes, $tractionControl;
    private $mutable = true;
    public function __construct(int $engineCc, string $brakes, bool $tractionControl) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор был вызван дважды.');
        }
        $this->engineCc = $engineCc;
        $this->brakes = $brakes;
        $this->tractionControl = $tractionControl;
        $this->mutable = false;
    }
    public function getEngineCc(): int {
        return $this->engineCc;
    }
    public function getBrakes(): string {
        return $this->brakes;
    }
    public function getTractionControl(): bool{
        return $this->tractionControl;
    }
    public function __set(string $id, $val): void {
        return;
    }
    
    public function __unset(string $id): void {
        return;
    }
}

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

class BikeGenerator {
    private $engineCc, $brakes, $tractionControl;
    public static function create(): self {
        return new self;
    }
    public static function with(Bike $oldBike): self {
        $generator = new self;
        $generator->setEngineCc($oldBike->getEngineCc());
        $generator->setBrakes($oldBike->getBrakes());
        $generator->setTractionControl($oldBike->getTractionControl());
        return $generator;
    }
    
    public function setEngineCc(int $cc): self {
        $this->engineCc = $cc;
        return $this;
    }
    public function setBrakes(string $brakes): self {
        $this->brakes = $brakes;
        return $this;
    }
    public function setTractionControl(bool $tractionControl): self {
        $this->tractionControl = $tractionControl;
        return $this;
    }
    public function build(): Bike {
        return new Bike($this->engineCc, $this->brakes, $this->tractionControl);
    }
}

BikiGenerator  дублирует часть кода оригинального класса и в действительности служит в качестве прославленной очереди. Мы ставим в очередь до тех пор, пока не удовлетворимся, затем выполняем build() , чтобы получить заново заполненный экземпляр Bike .

$zx9r = BikeGenerator::create()
  ->setEngineCc(900)
  ->setBrakes('2 плавающих поршневых диска')
  ->setTractionControl(false)
  ->build();

echo $zx9r->getEngineCc(); // 900
echo $zx10r->getBrakes(); // 2 плавающих поршневых диска
var_dump($zx10r->getTractionControl()); // bool(false)

$zx10r = BikeGenerator::with($zx9r)
  ->setEngineCc(1000)
  ->setBrakes($zx9r->getBrakes() . ' ABS')
  ->setTractionControl(true)
  ->build();
  
echo $zx10r->getEngineCc(); // 1000
echo $zx10r->getBrakes(); // 2 плавающих поршневых диска ABS
var_dump($zx10r->getTractionControl()); // bool(true)

Этот пример иллюстрирует паттерн строитель в деле генерирования готового к использованию экземпляра неизменяемого класса Bike . Дальше вы можете вызвать ::with() , чтобы создать новую модифицированную версию существующего объекта.

Присваивание ещё большего количества свойств

Вы не так уж много можете сделать для устранения скукчной работы со многими значениями в неизменяемых сущностях в PHP. Одним способом избавиться от неё может быть передача параметров в виде массива значений, которые проверяются и хранятся в неизменяемой сущности.

declare(strict_types=1);

final class Config {
    private $properties = [
        // свойство => тип данных
        // если тип не задан = строка
        'name',
        'version'   => 'int',
        'released'  => 'bool',
        'licence',
        'private'   => 'bool',
        'url',
        'repo',
        'downloads' => 'int'
    ];
    private $data = [];
    private $mutable = true;
    public function __construct(array $values) {
        if (false === $this->mutable) {
            throw new \Exception('Конструктор был вызван дважды.');
        }
        $this->set($values);
        $this->mutable = false;
    }
    
    public function __get($property) {
        if (array_key_exists($property, $this->data)) {
            return $this->data[$property];
        }
        throw new \Exception('Свойство ' . $property . ' не существует');
    }
    
    private function set(array $values) {
        foreach($this->properties as $prop => $type) {
            if (!is_string($prop)) {
                // считаем за строку, если у свойства явно не задан тип
                $prop = $type;
                $type = 'string';
            }
            
            if (array_key_exists($prop, $values)) {
                $this->setValue($prop, $type, $values[$prop]);
            }
        }
    }
    
    private function setValue($prop, $type, $value) {
        $check = 'is_' . $type; // eg. is_int()
        if ($check($value)) {
            $this->data[$prop] = $value;
        } else {
            throw new \InvalidArgumentException('Передан неверный тип для свойства "' . $prop . '" - ожидался ' . $type . ' , а получили ' . gettype($prop));
        }
    }
    public function __set(string $id, $val): void {
        return;
    }
    
    public function __unset(string $id): void {
        return;
    }
}

Мы вынуждены были отказаться от системы типов в пользу маленькой «ручной» проверки на тип в свойстве класса $properties  и проверяемой в методе setValue() .

$c = new Config([
    'name' => 'foo',
    'version' => '10'
]);
// Uncaught InvalidArgumentException: Передан неверный тип для свойства "version" - ожидался int , а получили string

Тот способ, при помощи которого это работает, также облегчает управление опциональными аргументами при создании экземпляра — до сих пор все аргументы были обязательными. Здесь вы можете задавать массив, в котором пропущено одно или больше свойств, и они просто не будут установлены в массиве $this->data .

$c = new Config([
    'name' => 'foo',
    'version' => 10
]);
echo $c->name; // foo
echo $c->version; // 10
echo $c->repo; // Uncaught Exception: Свойство repo не существует

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

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

Объединение ещё больших коллекций свойств

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

    public function with(array $values): Config {
        return new self(array_merge($this->data, $values));
    }

Этим можно пользоваться так:

$c = new Config([
    'name' => 'foo',
    'version' => 10,
    'repo' => 'github.com',
]);
echo $c->name; // foo
echo $c->version; // 10
echo $c->repo; // github.com

$c2 = $c->with(['name' => 'bar', 'version' => 12]);
echo $c2->name; // bar
echo $c2->version; // 12
echo $c2->repo; // github.com

Когда мы пишем код таким образом, мы также фактически получаем нашу собственную маленькую реализацию именованных параметров. Это, конечно, замечательно, но с присущими такому подходу окольными путями мы также теряем некоторую ясность и подсказку типов в IDE.

Ну в любом случае, до вас дошло, что манипуляции неизменяемыми сущностями в PHP могут очень быстро начать раздражать. Похоже, что нет по-настоящему простых путей избегнуть набора большего количества кода на клавиатуре и/или дурного влияния на возможности IDE подсказывать при вводе кода. Здесь были представлены некоторые техники избежать фрустрации, но, конечно, всегда есть место для усовершенствования.

3 комментария

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

  2. Если у вас в коде появляются такие решения — по-ходу вы пишите что-то не то ))

Добавить комментарий для Евгений Пястолов Отменить ответ

Ваш адрес email не будет опубликован. Обязательные поля помечены *

Этот сайт использует Akismet для борьбы со спамом. Узнайте, как обрабатываются ваши данные комментариев.