В рамках описания предметной области распространены понятия с ограниченным числом значений. Для этого лучше всего подходят перечисления. В 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.
matasar
Тема не раскрыта полностью, к примеру, никакой информации по поводу готовых решений в этом направлении, допустим github.com/myclabs/php-enum
В пером примере страшно неопримизированный код, от условия в 4 строки текут кровавые слезы. Дальше, честно, читал по диагонали. Можно было сделать проще: массивом или методом, который возвращает все доступные варианты. Можно было так же реализовать некий интерфейс или абстрактный класс, который бы описал основные возможности такого «Enum» класса.
Slutsky Автор
Эта реализация так-же опирается на механизм рефлексии. В методе toArray() происходит поиск и сохранение всех констант класса. Впоследствии эти значения используются как варианты перечисления.
Оптимизация всегда имеет цель. Примеры оптимизированы не с целью уменьшения количества строк кода, а с целью увеличения очевидности.
matasar
Очевидность? Если вы имеете в виду совсем неопытных ребят, то ваш пример плох еще и тем, что учит писать некрасиво. Если же брать более опытных людей, то они, как правило, вычитывают куда более сложный код на ревью.
Прям не хотелось это писать, но вырвалось, извините.
Schrodinger_Kater
Вся эта реализация просто бессмысленна. А главное преследует цель эстетизации, а не оптимизации. И чем вам помешали массивы? Учитывая, что набор функций для их обработки уже собран. Напоминает типичную ооп'о'фагию…
pbatanov
Мы когда то дошли до вот такого решения. github.com/paillechat/php-enum/blob/master/src/Enum.php
Работают строгие сравнения (===, in_array($value, [MyEnum::NAME1(), MyEnum::NAME2()], true) и пр.)
Косяка из комента ниже нет habr.com/ru/post/517752/#comment_22031208
Food::BEER() === Waste::BEER() не пройдет (это разные инстансы)
Обращения к рефлексии кэшируются в памяти класса и повторно используется мемори кэш вместо рефлексии при обращении к инстансам.
В целом можно было бы сделать и без рефлексии (похожий пример ниже в коментах), но первая версия этой либы работала на текстовых константах (new MyEnum(MyEnum::NAME1) ) и было решено оставить работу со списком значений через константы, а не через массив допустимых значений
Iv38
Ну, кстати, об всём этом написано в статье.
И с какой точки зрения неоптимизирован код первого примера? Он с точки зрения читабельности не очень, но зачастую перечисления никогда не меняются, вероятно, там никогда не станет сто элементов вместо четырёх. Зато он очень лёгкий. Перечисления это сахарок, хотелось бы, чтобы они (раз уж не реализованы на уровне языка), не тратили зря ресурсы. Впрочем, первый пример вообще неудобен в применении и мало что даёт. Дальше лучше.
VolCh
Массив из констант можно было сделать. Или даже ассоциативный self::SUMMER => true и проверять в конструкторе как if (self::VALUES[*value]?? false)
matasar
С точки зрения читабельности, даже, заменить 8 строк 2мя имеет смысл еще какой. Эллементарно, все константы оборачиваются в массив и дальше через
in_array
иimplode
. Такой варинт будет работать так же быстро как и тот, что представлен в примере.Iv38
Да я бы тоже скорее всего написал именно так. Тем более, что преждевременные оптимизации вредны. Просто в данном случае это не слишком важно. И речь в примере не об этом, а об одной из простейших реализаций перечислений.
VolCh
Это не оптимизация же в привычном понимании, а улучшение читаемости
Iv38
Я имел ввиду под оптимизацией то что написано в статье и то что я пытался оправдать.