toobo / enum
PHP 单类、受 Rust 启发的枚举实现
Requires
- php: >=7.2
Requires (Dev)
- inpsyde/php-coding-standards: 0.*
- phan/phan: ^1
- phpunit/phpunit: ^8
- squizlabs/php_codesniffer: 3.2.*
This package is auto-updated.
Last update: 2024-08-29 05:07:58 UTC
README
PHP 的单类、受 Rust 启发的枚举实现。
基本枚举
具体的实现必须定义公共常量,这些常量将成为相应枚举变体的工厂方法。例如这样
use Toobo\Enum; class PostStatus extends Enum { public const PUBLISH = 'publish'; public const DRAFT = 'draft'; public const TRASH = 'trash'; }
之后,我们可以通过“魔法”工厂方法获取枚举的实例,如下所示
/** @var PostStatus $publish */ $publish = PostStatus::PUBLISH();
并且我们可以在类型声明中使用这个值
interface Post { public function postStatus(): PostStatus; public function changeStatusTo(PostStatus $postStatus): Post; }
因为工厂方法是通过 __callStatic()
实现的,所以可能希望通过 @method
注解获得 IDE 自动完成。例如
use Toobo\Enum; /** * @method static PostStatus PUBLISH() * @method static PostStatus DRAFT() * @method static PostStatus TRASH() */ class PostStatus extends Enum { public const PUBLISH = 'publish'; public const DRAFT = 'draft'; public const TRASH = 'trash'; }
带参数的枚举
Rust 枚举实现中的一个很酷的功能是能够向枚举添加参数。
这个库提供了类似的功能,创建带参数的枚举就像实现一个 hydrate()
方法 一样简单。
例如
use Toobo\Enum; /** * @method static Move LX(int $steps) * @method static Move RX(int $steps) * @method static Move FW(int $steps) * @method static Move BW(int $steps) */ class Move extends Enum { public const LX = 'left'; public const RX = 'right'; public const FW = 'forward'; public const BW = 'backward'; public $steps = 0; public function hydrate(int $steps) { $this->steps = $steps; } }
有了这样的类,我们可以这样创建 Move
的实例
$moveTenStepsLeft = Move::LX(10); $moveOneStepForward = Move::FW(1); assert($moveTenStepsLeft->steps === 10); assert($moveOneStepForward->steps === 1);
传递给“魔法”工厂方法的参数会直接传递给 hydrate
,这意味着只要 hydrate
使用了参数类型声明,它就是类型安全的。
有时可能希望不同的枚举变体接受不同的参数,或者只有一些变体接受参数。
这可以通过通过在 hydrate
后追加变体常量名的 大写字母 版本命名的 特定变体的 hydrate
方法 来实现。
例如,对于一个常量为 FOO_BAR
的变体,特定变体的 hydrate 方法应该命名为 hydrateFooBar
。
魔法方法将搜索要调用的特定变体的 水解方法,如果未找到,将搜索通用的 hydrate
方法。如果都没有找到,则不调用任何方法。
如果传递了一些参数,但没有找到水解方法,则该软件包将引发异常。如果找到了方法,则它将 总是 调用,这意味着如果它定义了任何必需的参数而没有传递参数,则 PHP 将引发异常。
带有子类的枚举
枚举的最后一种也是最复杂的形式是使用子类构建的。
一个例子
use Toobo\Enum; /** * @method static Result OK($thing) * @method static Error ERROR(string $message) */ class Result extends Enum { public const OK = 'ok'; public const ERROR = Error::class; private $wrapped; /** * Param is optional: `Result::OK()` and `Result::OK($thing)` are both fine. */ public function hydrateOk($thing = null) { $this->wrapped = $thing; } public function unwrap() { return $this->wrapped; } public function isError(): bool { return false; } }
和引用的 Error
类
final class Error extends Result { /** * Param is required: only `Result::ERROR("Some message")` is allowed. */ public function hydrateError(string $thing) { $this->wrapped = new \Error($thing) } public function unwrap() { throw $this->wrapped; } public function isError(): bool { return true; } }
在上述片段中要注意的是
- 常量
Result::ERROR
的值是Error
类的全限定名称(FQN),即Error
是Result
类本身的子类。 - 两个水解方法(
Result::hydrateOk()
和Error::hydrateError()
)具有不同的签名,这就是为什么我们使用特定变体的水解方法的原因。 Result::OK(...)
将返回一个Result
实例,而Result::ERROR(...)
将返回一个Error
实例,但因为是Result
类的子类,它将满足任何期望父类的类型声明。
例如,我们可以这样做
function safeJsonDecode(string $thing): Result { $decoded = @json_decode($thing); if (json_last_error()) { return Result::ERROR(json_last_error_msg()); } return Result::OK($decoded); }
枚举方法
(无论是否为“基本”、带参数或带子类)枚举类继承了一些方法。
获取器
有几个获取器
Enum::variant()
:返回变体常量的 值,如果是子类枚举,将是类的 FQN。Enum::key()
:返回变体常量的 键,例如对于Result::ERROR
将返回字符串"ERROR"
。Enum::variantClass()
:返回变体类的 FQN,如果是子类枚举,这是子类的 FQN。Enum::enumClass()
:返回枚举类的 FQN,即使是子类枚举,它也始终返回“父”枚举类。Enum::describe()
/Enum::__toString()
这两种方法等价。它们都返回枚举的字符串表示形式,考虑了枚举类、变体以及任何参数的类型。例如,对于通过Thing::FOO("x", 123)
创建的枚举,这两个方法都会返回"Thing::FOO(string, int)"
;而对于通过Thing::BAR()
创建的枚举,返回值将是"Thing::BAR"
。
枚举身份检查
Enum::isVariant()
接受一个字符串,如果它与当前枚举变体匹配则返回 true。基本上,$enum->isVariant(Thing::FOO)
与 $enum->variant() === Thing::FOO
相同。
Enum::isAnyVariant()
是 Enum::isVariant()
的可变参数版本,参数通过 OR
逻辑组合,即调用 $this->isAnyVariant($a, $b, $c)
等同于调用:$this->isVariant($a) || $this->isVariant($b) || $this->isVariant($c)
。
Enum::is()
接受一个枚举实例作为参数,如果调用该方法上的枚举与给定的枚举相等(即它们代表相同的“变体”),则返回一个布尔值,此时为 true,如果枚举接受参数,它们也有相同的参数。
assert( Move::LX(10)->is(Move::LX(10)) ); assert( ! Move::LX(10)->is(Move::LX(5)) ); assert( ! Move::LX(10)->is(Move::RX(10)) );
还有 Enum::isAnyOf()
,这是 Enum::is()
的可变参数版本。
自定义身份检查
Enum::is()
在大多数情况下按预期工作,但可能希望使用自定义逻辑,特别是对于参数为对象的枚举。
实际上,对于标量或数组参数,Enum::is()
进行严格比较(===
),但对于对象则进行宽松比较(==
)。
这确保了“相似”的对象(具有不同的实例)被视为相等。例如,如果枚举类将 Datetime
对象作为参数,并且两个实例表示相同的时间点,这两个枚举将通过 Enum::is()
被认为是匹配的。
然而,可能希望如果两个实例中的 日期 相同,则认为这些实例是匹配的,忽略时间。这可以通过重写 Enum::is()
来实现,如下所示
public function is(Enum $enum): bool { $areSame = $this->looksLike($enum); if ($areSame !== null) { return $areSame; } // Make sure we compare date in the same timezone $enumDate = $enum->date->setTimezone($this->date->getTimezone()); return $enumDate->format('Ymd') === $this->date->format('Ymd'); }
我们使用 Enum::looksLike()
来检查两个枚举是否“相似”:它们共享相同的枚举具体类,要么它们共享相同的变体,要么其中任何一个都是通配符(关于这一点下面会详细介绍)。
Enum::looksLike()
是一个 protected
方法,返回类型为 bool|null
:当它能够确定(确定地)两个枚举是否匹配而不查看构造参数时,它返回 true
或 false
,当确定身份需要解析构造参数时,它返回 null
。
在后一种情况下,上述方法通过要求比较日期来比较日期。
通配符枚举和通配符参数
Enum
提供了一个“通配符”静态方法 _()
,可以用来创建被认为与任何变体等效的实例。
assert( Move::LX(10)->is(Move::_()) ); assert( Move::RX(5)->is(Move::_()) ); assert( Move::_()->is(Move::FW(1)) );
直接在 Enum
上而不是在子类上调用通配符方法将创建一个将匹配 任何 枚举的实例,因为 Enum
是所有这些枚举的父类。
此外,Enum
有一个 _
常量,可以用作“通配符参数”,用于构造接受参数的枚举实例。
assert( Move::LX(10)->is(Move::LX(Enum::_)) ); assert( Move::RX(2)->is(Move::RX(Enum::_)) ); assert( ! Move::RX(2)->is(Move::LX(Enum::_)) );
如果枚举接受更多参数,则可以使用通配符参数仅针对其中一些参数。
assert( Move::LX(10, 5)->is(Move::LX(Enum::_, 5)) ); assert( Move::RX(2, 8)->is(Move::RX(2, Enum::_)) ); assert( ! Move::RX(3, 8)->is(Move::RX(2, Enum::_)) );
当然,可以使用通配符参数为所有参数,这基本上等同于使用 Enum::isVariant()
。
assert( Move::LX(10, 5)->is(Move::LX(Enum::_, Enum::_)) ); assert( Move::LX(10, 5)->isVariant(Move::LX) );
值得注意的是,某些获取器方法对通配符枚举的行为不同
Enum::variant()
返回 nullEnum::enumClass()
返回 nullEnum::key()
返回"_"
Enum::describe()
返回类名后跟"::_"
最后,当在某个普通(非通配符)实例上调用 Enum::describe()
并使用一些通配符参数时,而不是参数类型,使用字符串 "_"
。
assert( Move::_()->variant() === null ); assert( Move::_()->enumClass() === null ); assert( Move::_()->key() === '_' ); assert( Move::_()->describe() === 'Move::_' ); assert( Move::LX(Enum::_)->describe() === 'Move::LX(_)' );
模式匹配(类似)
在 Rust 中,处理枚举的最强大和最符合语言习惯的方式是模式匹配。
在 PHP 中没有类似的东西,但这个库提供了一个 Enum::match()
方法,它实现了一些类似的功能(除非我们考虑性能 :D)。
让我们以一个枚举为例
use Toobo\Enum; /** * @method static User ACTIVE(int $id, string $name = 'unknown') * @method static User NOT_ACTIVE(int $id, string $name = 'unknown') */ final class User extends Enum { public const ACTIVE = 'active'; public const NOT_ACTIVE = 'not-active'; public $id = -1; public $name = 'N/D'; public function hydrate(int $id, string $name = 'N/D') { $this->id = $id; $this->name = $name; } }
我们可以这样做
function greet(User $user) { $user->match( [User::ACTIVE, function (User $user) { print "Welcome back {$user->name}!"; }], [User::NOT_ACTIVE, function (User $user) { print "Hi {$user->name}, please activate your account."; }], [User::ACTIVE(User::_, 'root'), function () { print 'Hello Administrator!'; }], ); } greet(User::ACTIVE(2, 'Jane')); // "Welcome back Jane!" greet(User::NOT_ACTIVE(5, 'John')); // "Hi John, please activate your account." greet(User::ACTIVE(123, 'root')); // "Hello Administrator!"
Enum::match()
接受任意数量的 2 项数组,其中第一个项是枚举实例或代表枚举变体的字符串,第二个项是一个可调用的对象。
每个第一个项都会与调用该方法的实例进行比较,使用 is()
(如果是实例)或 isVariant()
(如果是字符串),如果匹配,则立即返回相关回调(第二个项)的值。回调将只接收作为参数的枚举。
Enum::match()
基本上允许逻辑无 switch
实现。
当匹配的 分支 会使用枚举实例时,特别有用,因为匹配回调接收枚举作为参数。
另一个有趣的功能是可以使用通配符参数来隐式应用匹配逻辑,而无需任何 if
。
例如,在上面的代码片段中,“administrator” 用户通过要求特定的用户名("root"
)进行匹配,忽略用户 ID。
与之比较,等效的代码将是
function greet(User $user) { switch (true) { case $user->isVariant(User::NOT_ACTIVE)): print "Hi {$user->name}, please activate your account."; break; case $user->is(User::ACTIVE(User::_, 'root')): print 'Hello Administrator!'; break; default: print "Hi {$user->name}, please activate your account."; } }
这略微减少了代码量(13 行 VS 14 行),但它的循环复杂度更高,并且会随着更多变体的增加而线性增加,而使用 match
则无论变体数量如何都保持循环复杂度为 1。
此外,我们在上面的 match
示例中使用了闭包,但使用定义好的函数/方法/可调用对象会更少代码,而且更加灵活。例如:
/** * @var Response|Enum<string, int> $response */ function dispatch(Response $response) { $response->match( [Response::REDIRECT, new Handler\Redirect()], [Response::ERROR, new Handler\Error()], [Response::SUCCESS, new Handler\Success()], [Response::ERROR(Response::_, 404), new Handler\NotFoundError()], ); }
每个可调用对象的 __invoke()
方法将接收 Response
对象作为参数,并且它们可以对其进行任何操作,而无需知道对象来自何处。
但这还有更多。
匹配回调
枚举提供了一个实用方法来 将匹配逻辑封装在一个可调用的对象中,该对象可以被存储和/或传递。静态方法 Enum::matcher()
接受与可以传递给 Enum::match()
相同的变数组,并返回一个可调用对象,该对象接受一个枚举实例,并返回应用匹配逻辑的结果。
/** @var callable $dispatcher */ $dispatcher = Response::matcher( $response->match( [Response::REDIRECT, new Handler\Redirect()], [Response::ERROR, new Handler\Error()], [Response::SUCCESS, new Handler\Success()], [Response::ERROR(Response::_, 404), new Handler\NotFound()], ); ); // $dispatcher($response);
匹配对象可以被注入到对象中或作为参数传递...
匹配模式优先级
您可能已经注意到,在上面的代码片段中,如果将字符串模式 Response::ERROR
放在 Response::ERROR(Response::_, 404)
之前,模式 Response::ERROR(Response::_, 404)
可以匹配,并且理论上会“覆盖”它,匹配任何错误响应。
这是因为匹配模式时应用了一个优先级。首先:
- 首先解析没有任何通配符参数的枚举 实例。如果存在多个此类实例,则顺序很重要;
- 匹配使用 通配符参数 的枚举 实例。这些实例按 使用的通配符参数数量 排序:使用的通配符参数越少,优先级越高。如果存在具有相同数量通配符参数的多个此类实例,则顺序很重要;
- 解析 枚举变体常量。如果存在多个此类实例,则顺序很重要;
- 解析 通配符枚举(通过
_()
方法创建的枚举)。如果存在多个此类实例,则顺序很重要; - 最后,如果找到,将与通配符模式相关联的回调将被执行。定义通配符模式有两种方式:匹配所有通配符实例的
Enum::_()
,或直接使用通配符常量:Enum::_
。如果存在多个此类通配符模式,则只有第一个将被执行(如果没有其他匹配项先执行),其余的将被忽略。
关于构造函数的说明
对于枚举类,不能调用__construct()
(因为它是private
的)也不能重写,因为它是final
的:因此,获取枚举实例的唯一干净方法是调用魔法方法。
原因是类中其余的逻辑依赖于__callStatic
方法的调用。
还值得注意的是,填充方法不是构造函数,尽管这提供了一些好处(例如,在填充期间所有枚举获取器都可用),但它也带来了一个问题,即应特别注意不要留下未定义的属性。
例如,在类中
final class Move extends Enum { public const LX = 'left'; public const RX = 'right'; private $steps = 0; public function hydrate(int $steps) { $this->steps = $steps; } public function steps(): int { return $this->steps; } }
因为没有构造函数,为了确保$steps
被设置,在声明中赋值(private $steps = 0
)。
这样,可以安全地将steps()
方法声明为具有int
类型声明。
另一种选择是在属性声明中不赋值,并将steps()
的返回类型声明为?int
,在类的其余部分处理$steps
的类型为int|null
而不是int
的事实。