toobo/enum

PHP 单类、受 Rust 启发的枚举实现

1.0.0 2019-03-08 21:42 UTC

This package is auto-updated.

Last update: 2024-08-29 05:07:58 UTC


README

PHP 的单类、受 Rust 启发的枚举实现。

license travis-ci status codecov.io release packagist PHP version requirement

基本枚举

具体的实现必须定义公共常量,这些常量将成为相应枚举变体的工厂方法。例如这样

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),即 ErrorResult 类本身的子类。
  • 两个水解方法(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:当它能够确定(确定地)两个枚举是否匹配而不查看构造参数时,它返回 truefalse,当确定身份需要解析构造参数时,它返回 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() 返回 null
  • Enum::enumClass() 返回 null
  • Enum::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) 可以匹配,并且理论上会“覆盖”它,匹配任何错误响应。

这是因为匹配模式时应用了一个优先级。首先:

  1. 首先解析没有任何通配符参数的枚举 实例。如果存在多个此类实例,则顺序很重要;
  2. 匹配使用 通配符参数 的枚举 实例。这些实例按 使用的通配符参数数量 排序:使用的通配符参数越少,优先级越高。如果存在具有相同数量通配符参数的多个此类实例,则顺序很重要;
  3. 解析 枚举变体常量。如果存在多个此类实例,则顺序很重要;
  4. 解析 通配符枚举(通过 _() 方法创建的枚举)。如果存在多个此类实例,则顺序很重要;
  5. 最后,如果找到,将与通配符模式相关联的回调将被执行。定义通配符模式有两种方式:匹配所有通配符实例的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的事实。