[Из песочницы] Перечисления в PHP

?v=1

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


Простейшая реализация

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

class Season
{
    public const SUMMER = 'summer';
    public const AUTUMN = 'autumn';
    public const WINTER = 'winter';
    public const SPRING = 'spring';

    private string $value;

    public function __construct(string $value)
    {
        if (
            self::SUMMER !== $value &&
            self::AUTUMN !== $value &&
            self::WINTER !== $value &&
            self::SPRING !== $value
        ) {
            throw new InvalidArgumentException(sprintf(
                "Wrong season value. Awaited '%s', '%s', '%s' or '%s'.",
                self::SUMMER,
                self::AUTUMN,
                self::WINTER,
                self::SPRING
            ));
        }

        $this->value = $value;
    }

Продемонстрировать процесс создания перечисления можно тестом.

    public function testCreation(): void
    {
        $summer = new Season(Season::SUMMER);
        $autumn = new Season(Season::AUTUMN);
        $winter = new Season(Season::WINTER);
        $spring = new Season(Season::SPRING);

        $this->expectException(InvalidArgumentException::class);

        $wrongSeason = new Season('Wrong season');
    }

Для получения внутреннего состояния можно реализовать метод-запрос. К примеру toValue() или getValue(). В случае, если внутреннее состояние описывается строкой, то для его получения идеально подходит магический метод __toString().

    public function __toString(): string
    {
        return $this->value;
    }

Реализация __toString() даёт возможность исользовать объект-перечисление напрямую в конкатенациях строк и в виде строковых аргументов методов.

    public function testStringConcatenation(): void
    {
        $autumn = new Season(Season::AUTUMN);
        $spring = new Season(Season::SPRING);
        $value = $autumn . ' ' . $spring;

        $this->assertIsString($value);
        $this->assertSame(Season::AUTUMN . ' ' . Season::SPRING, $value);
    }

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

    public function testEquality(): void
    {
        $firstSummer = new Season(Season::SUMMER);
        $secondSummer = new Season(Season::SUMMER);
        $winter = new Season(Season::WINTER);

        $this->assertTrue($firstSummer == $secondSummer);
        $this->assertFalse($firstSummer == $winter);
        $this->assertFalse($firstSummer === $secondSummer);
        $this->assertFalse($firstSummer === $winter);
    }

Для обеспечения интуитивно предсказуемого результата сравнения можно реализовать метод equals.

    public function equals(Season $season): bool
    {
        return $this->value === $season->value;
    }
    public function testEquals(): void
    {
        $firstSummer = new Season(Season::SUMMER);
        $secondSummer = new Season(Season::SUMMER);
        $firstWinter = new Season(Season::WINTER);
        $secondWinter = new Season(Season::WINTER);

        $this->assertTrue($firstSummer->equals($secondSummer));
        $this->assertTrue($firstWinter->equals($secondWinter));
        $this->assertFalse($firstSummer->equals($secondWinter));
    }

Можно обратить внимание на тавтологию при создании экземпляров перечисления: слово Season повторяется дважды. Её можно избежать если для создания использовать статические методы.

Предположим: в магазине аудиотехники продаются микрофоны. В ассортименте представлены модели использующих для подключения xlr 3pin, jack, mini jack и usb разъёмы.

class MicrophoneConnector
{
    public const XLR_3PIN = 'xlr_3pin';
    public const JACK = 'jack';
    public const MINI_JACK = 'mini_jack';
    public const USB = 'usb';

    private string $value;

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

    public function __toString(): string
    {
        return $this->value;
    }

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

    public static function xlr3pin(): self
    {
        return new self(self::XLR_3PIN);
    }

По аналогии необходимо реализовать методы jack, miniJack и usb.

    public function testEquality(): void
    {
        $firstJack = MicrophoneConnector::jack();
        $secondJack = MicrophoneConnector::jack();
        $xlr3pin = MicrophoneConnector::xlr3pin();

        $this->assertTrue($firstJack == $secondJack);
        $this->assertFalse($firstJack == $xlr3pin);
        $this->assertFalse($firstJack === $secondJack);
        $this->assertFalse($firstJack === $xlr3pin);
    }


Перечисление как одиночка

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

Предположим: в организацию приходит заказ на оказание некоторой услуги. Заказ может быть принят, отвергнут либо, при возникновении нестандартной ситуации, решение может быть отложено для выяснения обстоятельств. Решение можно описать как перечисление из значений agree, disagree и hold.

class Decision
{
    public const AGREE = 'agree';
    public const DISAGREE = 'disagree';
    public const HOLD = 'hold';

    private string $value;

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

    private function __clone() { }

    public function __toString(): string
    {
        return $this->value;
    }

С целью предотвращения создания нескольких экземпляров обьекта необходимо заблокировать конструктор и магический метод __clone(). Для каждого варианта перечисления реализуется статический метод.

    private static $agreeInstance = null;

    public static function agree(): self
    {
        if (null === self::$agreeInstance) {
            self::$agreeInstance = new self(self::AGREE);
        }

        return self::$agreeInstance;
    }

По аналогии c agree реализуются методы disagree и hold.

    public function testEquality(): void
    {
        $firsAgree = Decision::agree();
        $secondAgree = Decision::agree();
        $firstDisagree = Decision::disagree();
        $secondDisagree = Decision::disagree();

        $this->assertTrue($firsAgree == $secondAgree);
        $this->assertTrue($firstDisagree == $secondDisagree);
        $this->assertFalse($firsAgree == $secondDisagree);
        $this->assertTrue($firsAgree === $secondAgree);
        $this->assertTrue($firstDisagree === $secondDisagree);
        $this->assertFalse($firsAgree === $secondDisagree);
    }

Такая реализация позволяет сравнивать перечисления напрямую. А в купе с реализацией __toString() обеспечивает интуитивную работу с перечислением как с простым типом. Но у такого подхода есть серьёзный недостаток: всё равно есть возможность создать новый экземпляр объекта с помощью десериализации. Для обеспечения корректной десериализации придётся добавить механизм создания экземпляра из произвольного значения.

    public static function from($value): self
    {
        switch ($value) {
            case self::AGREE:
                return self::agree();

            case self::DISAGREE:
                return self::disagree();

            case self::HOLD:
                return self::hold();

            default:
                throw new InvalidArgumentException(sprintf(
                    "Wrong decision value. Awaited '%s', '%s' or '%s'.",
                    self::AGREE,
                    self::DISAGREE,
                    self::HOLD
                ));
        }
    }

Предположим: простейший заказ можно описать как пояснение к заказу и решение принятое по нему. Между запросами существует необходимость сохранять заказ в кэш. Для этого можно воспользоваться стандартным механизмом сериализации: реализовать магические методы __sleep() и __wakeup(). В методе __sleep() перечислить поля для сериализации. В методе __wakeup() восстановить конкретный экземпляр при помощи статического метода from() класса Decision.

class Order
{
    private Decision $decision;
    private string $description;

    public function getDecision(): Decision
    {
        return $this->decision;
    }

    public function getDescription(): string
    {
        return $this->description;
    }

    public function __construct(Decision $decision, string $description)
    {
        $this->decision = $decision;
        $this->description = $description;
    }

    public function __sleep(): array
    {
        return ['decision', 'description'];
    }

    public function __wakeup(): void
    {
        $this->decision = Decision::from($this->decision);
    }
}

Процесс сериализации/десериализации можно представить в виде теста.

    public function testSerialization(): void
    {
        $order = new Order(Decision::hold(), 'Some order description');

        $serializedOrder = serialize($order);
        $this->assertIsString($serializedOrder);

        /** @var Order $unserializedOrder */
        $unserializedOrder = unserialize($serializedOrder);
        $this->assertInstanceOf(Order::class, $unserializedOrder);

        $this->assertTrue($order->getDecision() === $unserializedOrder->getDecision());
    }


Перечисление с большим числом вариантов

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

class Season
{
    public const SEASONS = ['summer', 'autumn', 'winter', 'spring'];

    private string $value;

    public function __construct(string $value)
    {
        if (!in_array($value, self::SEASONS)) {
            throw new InvalidArgumentException(sprintf(
                "Wrong season value. Awaited one from: '%s'.",
                implode("', '", self::SEASONS)
            ));
        }

        $this->value = $value;
    }

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

Предположим: в магазине одежды продают одежду разных размеров. Тогда возможные размеры можно описать перечислением.

class Size
{
    public const SIZES = ['xxs', 'xs', 's', 'm', 'l', 'xl', 'xxl'];

    private string $value;

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

    public function __toString(): string
    {
        return $this->value;
    }

    public static function __callStatic($name, $arguments)
    {
        $value = strtolower($name);
        if (!in_array($value, self::SIZES)) {
            throw new BadMethodCallException("Method '$name' not found.");
        }

        if (count($arguments) > 0) {
            throw new InvalidArgumentException("Method '$name' expected no arguments.");
        }

        return new self($value);
    }
}
    public function testEquality(): void
    {
        $firstXxl = Size::xxl();
        $secondXxl = Size::xxl();
        $firstXxs = Size::xxs();
        $secondXxs = Size::xxs();

        $this->assertTrue($firstXxl == $secondXxl);
        $this->assertTrue($firstXxs == $secondXxs);
        $this->assertFalse($firstXxl == $secondXxs);
        $this->assertFalse($firstXxl === $secondXxl);
        $this->assertFalse($firstXxs === $secondXxs);
        $this->assertFalse($firstXxl === $secondXxs);
    }

Недостатком использования такого подхода является, то, что синтаксический анализатор IDE не может распознать методы вызываемые через __callStatic(). Методы приходится описывать в DocBlock’ах, что практически сводит на нет пользу от обобщения кода.

/**
 * @method static Size xxs()
 * @method static Size xs()
 * @method static Size s()
 * @method static Size m()
 * @method static Size l()
 * @method static Size xl()
 * @method static Size xxl()
 */
class Size
{


Критика других реализаций

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

В PECL-пакете SPL есть класс SplEnum. Однако SPL пакет может быть не установлен в исполняющей среде (либо его и не хочется ставить).


Заключение

В PHP нет специальных конструкций для описания перечисления, однако их можно имитировать при помощи объектно-ориентированного подхода. Использование перечисления как одиночки хоть и даёт некоторые преимущества, имеет критический недостаток значительно усложняющий реализацию. Стоит заметить, что это не столько проблема реализации перечисления, сколько общая проблема реализации паттерна «одиночка» в PHP. Обобщенные решения практически не дают преимуществ, так как конкретные методы всё равно приходится описывать в DocBlock’ах.

Все примеры на GitHub.

© Habrahabr.ru