PHP и неизменяемость. Часть 1

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

Как у динамического языка со слабой типизацией, у PHP нет встроенной концепции неизменяемости (immutability). Конечно, все мы знаем заслуженные define()  и КОНСТАНТЫ, но они ограничены в функционале. Хоть PHP и поставляется с как минимум одним неизменяемым классом в своей стандартной библиотеке, DateTimeImmutable , но не существует какого-то внятного способа создавать собственные неизменяемые объекты.

Константы

Единственное неизменяемое хранилище данных, доступное в PHP, это константы, которые устанавливаются в PHP-классе при помощи или define()  , или ключевого слова const . Однако, имеется существенная разница между этимя двумя опциями.

Константы класса (const ) присваиваются при написании кода, и они не могут быть изменены в ходе выполнения программы. Это свойство делает из них наиболее неизменяемую пользовательскую структуру в PHP. Если б вы захотели присвоить другое значение по какому-то условию или присвоить значение другой переменной, считайте, что вам не повезло.

class Immutable {
    const TRICK = 'kickflip';
    
    public function __construct() {
        echo static::TRICK;     // kickflip
        static::TRICK = 'HHHH'; // Parse error unexpected '='
    }
}
new Immutable();

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

$skater = 'Mullen';

if ($skater === 'Mullen') {
    define('TRICK', $skater . ' создал flatground Ollie');
} else if ($skater === 'Hawk') {
    define('TRICK', $skater . ' изобрел Kickflip McTwist');
}
echo TRICK; // Mullen создал flatground Ollie
define('TRICK', 'nothing'); // Notice: Constant TRICK already defined
echo TRICK; // Mullen создал flatground Ollie;

Как можно видеть, если вы попытаетесь изменить константу, то получите уведомление, при этом само значение останется неизменным. Проверить, не была ли константа уже определена, вы можете функцией defined()  — обратите внимание на d  на конце!

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

define('TRICKS', [
    'Ollie',
    'Darkslide',
    'Heelflip',
    'Nollie'
]);
// Warning: Constants may only evaluate to scalar values

Если же вы чувствуете себя круто, потому что работаете с PHP 7, тогда у меня плохие новости — с массивами все получится, но с объектами не особо!

define('TRICKS', new stdClass());
// Warning: Constants may only evaluate to scalar values or arrays

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

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

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

Оба эти метода дают в итоге странный синтаксис, тогда как намного более простой синтаксис для переменных был бы более подходящим и предсказуемым.

Произвольные неизменяемые классы

Вообще, вы можете писать собственные неизменяемые сущности, применяя некоторые простые и хитрые техники на PHP. Мы не будем предъявлять слишком больших требований к данным, чтобы следовать примерам в статье было проще. Для иллюстрации я воспользуюсь профессиональными скейтбордистами и трюками, которые они привнесли в мир.

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

class Immutable {
    private $skater, $trick;

    public function __construct($skater, $trick) {
        $this->skater = $skater;
        $this->trick = $trick;
    }
    
    public function getSkater() {
        return $this->skater;
    }

    public function getTrick() {
        return $this->trick;
    }
}

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

$x = new Immutable('Hawk', 'Frontside 540');
echo $x->skater = 'Mullen'; // Fatal error: Cannot access private property Immutable::$skater

Чтобы обеспечить внешний доступ к значениям, привязанным к объекту Immutable, можно просто написать открытый (public ) метод, который возвращает требуемое значение. В нашем примере есть два свойства класса, к которым мы хотели бы иметь доступ, так что я добавил getSkater() , чтоб получать имя скейтбордиста, и getTrick() , чтоб получать название трюка, который он придумал.

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

Избавляемся от изменчивости

Важно, что к Immutable  не было добавлено никаких методов, которые бы позволили менять значения внутри класса. Очевидно, вы не захотите писать в классе открытых методов наподобие setSkater() или setTrick()  , поскольку они позволили бы клиенту изменять наш неизменяемый класс.

class Immutable 
{ 
... // Неправильно: не делайте так! 
    public function setSkater($skater) 
    { 
        $this->skater = $skater; 
    } 
    ... 
} 
$x = new Immutable('Mullen', '50-50 Sidewinder'); 
echo $x->getSkater(); // Mullen 
$x->setSkater('Hawk'); // О, нет! Вы МЕНЯЕТЕ! 
echo $x->getSkater(); // Hawk

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

Блокируем обходные пути

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

Перезаписываем изменяемую переменную

Самый легкий способ обойти неизменяемость в PHP — просто перезаписать переменную, которой присвоен неизменяемый объект класса.

$a = new Immutable('Mullen', 'Casper slide'); 
$a = new TrickRecord('Mullen', 'Airwalk');

При запуске кода $a$a  будет без особых уведомлений перезаписано новым значением new TrickRecord  вместо того, чтобы вызвать ошибку, как мы могли надеяться. Это привет из истории PHP, такое поведение ожидаемо. Конечно, если бы вы были злонамеренным разработчиком, то вам следовало бы убедиться, что они не сделали TrickRecord неизменяемым!

Что до записи, то не существует никакого способа в PHP ее предотвратить. Как мы уже знаем, объекты не могут быть присвоены константе (только скаляры, а в PHP 7 — еще и массивы). В JavaScript у нас теперь есть ключевое слово const  , которое может запретить запись, но в PHP нет такого счастья.

Единственный способ предотвратить запись — это использовать явное указание типов всюду, где ожидается Immutable , но, как мы еще увидим, существуют способы обойти и это!

Множественные вызовы конструктора

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

$x = new Immutable('Hawk', 'Frontside 540'); 
echo $x->getSkater(); // Hawk 
$x->__construct('Song', 'Frontside 540'); 
echo $x->getSkater(); // Song

Очевидно, это проблема, к тому же очень неприятная. К счастью, мы можем обойти эту проблему путем включения в наш класс некоего флага, чтобы избежать изменяемости. Я бы сказал, что Daewon Song — великий скейтбордист, однако, он не изобретал frontside 540 , так что мы не можем это разрешать.

class Immutable {
    private $skater, $trick;
    private $mutable = true;

    public function __construct($skater, $trick) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор вызван дважды.');
        }
        $this->skater = $skater;
        $this->trick = $trick;
        $this->mutable = false;
    }
    
    public function getSkater() {
        return $this->skater;
    }

    public function getTrick() {
        return $this->trick;
    }
}

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

$x = new Immutable('Hawk', 'Frontside 540');
echo $x->getSkater(); // Hawk
$x->__construct('Song', 'Darkslide');
// Fatal error: Uncaught BadMethodCallException: Конструктор вызван дважды.

Расширяем класс Immutable

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

Как только у него будет свой конструктор, он сможет присвоить $skater  и $trick  каким-то своим свойствам класса. Это поломает неизменяемость класса, так как открытые (public ) свойства могут быть изменены. Подобным образом он сможет добавить новые методы, чтоб присваивать свойствам произвольные значения.

class NaughtyDev extends Immutable {
    public $mySkater, $myTrick;

    public function __construct($skater, $trick) {
        $this->mySkater = $skater;
        $this->myTrick = $trick;
    }

    public function setTrick($trick) {
        $this->myTrick = $trick;
    }
}

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

$x = new NaughtyDev('Hawk', '900');
$x instanceof Immutable; // true

function onlyGiveMeAnImmutable(Immutable $z) {
    return $z;
}
onlyGiveMeAnImmutable($x); // не вызывает ошибку типа

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

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

С помощью одного небольшого изменения нашего класса Immutable  мы можем избежать атаки на наш класс.

final class Immutable {
    private $skater, $trick;
    private $mutable = true;

    public function __construct($skater, $trick) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор вызван дважды.');
        }
        $this->skater = $skater;
        $this->trick = $trick;
        $this->mutable = false;
    }
    
    public function getSkater() {
        return $this->skater;
    }

    public function getTrick() {
        return $this->trick;
    }
}

class NaughtyDev extends Immutable {

}
// Fatal error: Class NaughtyDev may not inherit from final class (Immutable)

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

Наконец, более полный пример

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

declare(strict_types=1);

final class SkateboardTrick {
    private $inventor, $trickName;
    private $mutable = true;
    
    public function __construct(string $skater, string $trick) {
        if (false === $this->mutable) {
            throw new \BadMethodCallException('Конструктор вызван дважды.');
        }
        $this->inventor = $skater;
        $this->trickName = $trick;
        $this->mutable = false;
    }

    public function getInventor(): string {
        return $this->inventor;
    }

    public function getTrickName(): string {
        return $this->trickName;
    }
}

Строгий режим объявления типов и его применение для аргументов функций еще больше усиливает неизменяемый класс. Например, мы не получим неожиданно объекта в качестве аргумента. Это было бы плохо, потому что такой объект мог бы быть изменен где-то еще, что изменило бы содержимое нашего неизменяемого объекта — это плохо! Больше об этом в следующей статье.

Заключение

Методы и свойства, определенные в final  классе SkateboardTrick  дают гарантию, что объект неизменяем и не может быть дополнен. Это, естественно, и есть наша цель.

$x = new SkateboardTrick('Mullen', '540 Shove-it');
echo $x->getInventor(); // Mullen
echo $x->getTrickName(); // 540 Shove-it

$x->inventor = 'Hawk'; // Fatal error: Cannot access private property SkateboardTrick::$inventor

$x->__construct('Hawk', $x->getTrickName());
// Fatal error: Uncaught BadMethodCallException: Конструктор вызван дважды.

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

$x = new SkateboardTrick('Mullen', 'Ollie');
echo $x->getInventor(); // Mullen
echo $x->getTrickName(); // Ollie

$z = new SkateboardTrick(
    $x->getInventor(),
    $x->getTrickName() . ' fingerflip'
);
echo $z->getInventor(); // Mullen
echo $z->getTrickName(); // Ollie fingerflip

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

1 комментарий

  1. На самом деле в PHP можно запретить переопределение переменной. Достигается это с помощью расширения SPL Types, которое доступно из PECL репозитория. Оное содержит класс SplEnum — единственная альтернатива типу enum в PHP. Этот класс можно наследовать и перечислить свои константы, а дальше пользоваться как обычным объектом. Но где магия? При повторном переопределении переменной, которой присвоен SplEnum, будет выбрасываться исключение UnexpectedValueException, посмотрите сами: https://git.io/vQKVg

Оставить комментарий

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

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