# 枚举

Source: https://codewiki.com/zh/php/enums/

> - **what**: 枚举把有限且封闭的一组合法值定义为独立类型。纯枚举只区分 case，带值枚举还为每个 case 提供唯一的 `string` 或 `int` 值。
> - **trap**: `name`、`value` 和外部数据不是一回事；`match` 也不会在编译时证明已经处理全部 case。默认分支和静默回退很容易掩盖新状态或脏数据。
> - **fix**: 在输入边界显式调用 `from()` 或 `tryFrom()`，持久化稳定的 backing value，并让领域代码始终接收具体的枚举类型。

## 是什么，为什么存在

枚举（enumeration）是由程序声明的一组有限合法值。在 PHP 8.1 及以后，`enum` 声明会创建真正的类型，不是装着常量的普通类，也不是字符串取值的命名约定。参数声明为 `OrderStatus` 后，调用方只能传入该枚举的 case，不能传入内容碰巧相同的字符串。

每个枚举 case（enum case）都是该枚举类型的单例对象。两次读取 `OrderStatus::Paid` 得到的是同一个 case，可以用 `===` 比较。case 还能作为 `match` 的条件、传给带类型的函数，并实现枚举定义中的方法。

枚举适合描述集合由代码拥有、成员彼此排斥的概念，例如订单状态、命令类型或部署环境。它把非法状态挡在类型边界之外，也让 IDE 和静态分析器看见全部候选值。数据库行、JSON 或表单仍只提供标量值，因此这些边界必须先完成解析，之后才能获得这种保证。

集合如果需要由插件扩展，或者多个选项可以同时成立，枚举通常不是合适的模型。插件注册表是开放集合；权限组合更像集合或位标志。把这类概念硬塞进单一枚举，往往会产生不断膨胀的 case 和难以解释的组合状态。

PHP 有两类枚举。纯枚举（pure enum）只声明 case 名称；带值枚举（backed enum）为每个 case 显式绑定唯一的 `string` 或 `int`。只有需要数据库、消息或 API 往返时才需要 backing value。

| 形式 | case 示例 | 可用数据 | 常见用途 |
| --- | --- | --- | --- |
| 纯枚举 | `case Morning;` | 只读的 `name` | 只在 PHP 进程内区分类别 |
| 带值枚举 | `case Paid = 'paid';` | 只读的 `name` 与 `value` | 与标量边界往返 |

枚举带来的是名义类型（nominal typing）边界。即使两个枚举都有值为 `'paid'` 的 case，它们仍是两个不同类型，不能互换。这个差别能阻止订单状态误传到发票状态参数中，而普通字符串做不到。

## 工作原理

### 声明与 case 对象

纯枚举使用 `enum Name { case Value; }`。它的 case 没有应用定义的标量值，但每个 case 都有内建的只读 `name` 属性，其内容就是源码中的 case 标识符。`name` 适合诊断和开发工具，不一定适合作为长期外部协议。

带值枚举在名称后写 `: string` 或 `: int`，并为每个 case 写出值。一个枚举不能混用两种 backing type，也不能省略某个 case 的值。backing value 必须唯一，PHP 不会自动生成连续整数。

带值 case 还拥有内建的只读 `value` 属性。它是你在定义中写下的标量，适合保存到数据库或发到已经约定该表示的 API。它不是普通属性，不能重新赋值或通过引用修改。

case 是对象，但枚举不允许每个对象携带任意状态。你不能用 `new` 创建 case，不能克隆 case，也不能为枚举声明实例属性或静态属性。枚举没有继承层次：它不能继承类，也不能被类或另一个枚举继承。

### `UnitEnum` 与 `BackedEnum`

所有枚举都会自动实现内部接口 `UnitEnum`。它提供静态方法 `cases()`，按声明顺序返回由全部 case 组成的紧凑数组。枚举不能自行重新定义 `cases()`，普通类也不能手动实现 `UnitEnum`。

带值枚举还会自动实现 `BackedEnum`。该接口提供 `from()` 和 `tryFrom()`，两者都按 backing value 查找，不按 case 的 `name` 查找。返回的仍是已有的单例 case，不是新建对象。

`from()` 找不到值时抛出 `ValueError`。当值来自可信数据库，而且未知值意味着数据或部署不一致时，这种立即失败很合适。`tryFrom()` 找不到值时返回 `null`，适合需要生成校验错误的外部输入边界。

在 `declare(strict_types=1)` 下，字符串带值枚举的转换方法要求字符串，整数带值枚举则要求整数。不要依赖弱类型调用把输入碰巧转换为 backing type。先验证原始表示，再用准确类型调用转换方法，边界政策会更清楚。

### 方法、接口与 Trait

纯枚举和带值枚举都可以声明实例方法、静态方法与常量。实例方法中的 `$this` 指向当前 case，因此 `match ($this)` 可以为每个 case 给出行为。和普通对象一样，枚举也可以实现一个或多个接口。

接口让调用方依赖行为，而不必依赖某个具体枚举。若 `OrderStatus` 与普通类都实现 `Labelled`，接收 `Labelled` 的函数可以处理两者。case 集合仍由各自的枚举类型封闭，接口不会把不同枚举的 case 混成一种值。

枚举可以使用 Trait，但 Trait 不能包含属性。只提供方法、静态方法或常量的 Trait 可以复用；带属性的 Trait 用在枚举中会造成致命错误。复用之前仍要判断行为是否真的属于所有使用方，通用的 `values()` 助手往往只适用于带值枚举。

枚举不能声明构造函数或析构函数，大多数魔术方法也不允许。这个限制保证 case 没有各自变化的对象状态。需要运行时数据的领域对象应保存枚举属性，而不是把数据塞进枚举本身。

### `match` 与完整分支

`match` 使用全等比较，因此枚举 case 很适合作为匹配条件。省略 `default` 后，如果运行时出现没有分支的 case，PHP 会抛出 `UnhandledMatchError`。这是一种运行时保护，不是 PHP 编译器提供的穷尽性证明。

新增 case 时，声明本身仍可以正常加载。只有执行到缺失分支，或静态分析工具检测到它时，问题才会暴露。对必须随 case 集合更新的领域决策，通常应省略 `default`，并为每个 case 建立覆盖测试。

某些转换确实有合理的默认结果，例如所有未知显示颜色统一为灰色。但默认分支也会吞掉后来新增的 case。使用它之前，应能说明为什么新 case 自动继承该行为不会造成业务错误。

## 示例

以下四个示例依次展示纯枚举、标量边界转换、带行为的状态枚举，以及通用枚举代码。输出均由本地 PHP 8.3.33 CLI 实际生成。

### 枚举封闭的一组时段

`DeliveryWindow` 是纯枚举。函数签名排除了任意字符串，`cases()` 则给出声明中的全部选项。

<!-- quick -->

```php
// file: delivery_window.php
<?php

declare(strict_types=1);

enum DeliveryWindow
{
    case Morning;
    case Afternoon;
    case Evening;
}

function cutoffHour(DeliveryWindow $window): int
{
    return match ($window) {
        DeliveryWindow::Morning => 11,
        DeliveryWindow::Afternoon => 16,
        DeliveryWindow::Evening => 20,
    };
}

foreach (DeliveryWindow::cases() as $window) {
    printf("%s closes at %d\n", $window->name, cutoffHour($window));
}
```

```text
Morning closes at 11
Afternoon closes at 16
Evening closes at 20
```

<!-- /quick -->

`cases()` 保留声明顺序，所以输出与源码排列一致。`cutoffHour()` 没有 `default`；以后新增时段后，测试若遍历全部 case，就会执行到缺失分支并失败。

这里的 `name` 只是为了输出标识符。若客户界面需要可翻译文本，应使用独立的翻译键或展示层映射，不要把源码标识符直接当作面向用户的文案。

### 从外部值恢复 case

`InvoiceState` 是字符串带值枚举。示例对可能无效的数据使用 `tryFrom()`，对本应有效的数据演示 `from()` 的失败方式，并观察带值枚举的默认 JSON 表示。

```php
// file: invoice_state.php
<?php

declare(strict_types=1);

enum InvoiceState: string
{
    case Draft = 'draft';
    case Sent = 'sent';
    case Paid = 'paid';
}

foreach (['draft', 'paid', 'refunded', 'PAID'] as $raw) {
    $state = InvoiceState::tryFrom($raw);
    printf("%s => %s\n", $raw, $state?->name ?? 'invalid');
}

try {
    InvoiceState::from('refunded');
} catch (ValueError $error) {
    echo $error::class, PHP_EOL;
}

echo json_encode(InvoiceState::Paid, JSON_THROW_ON_ERROR), PHP_EOL;
```

```text
draft => Draft
paid => Paid
refunded => invalid
PAID => invalid
ValueError
"paid"
```

查找按 `value` 严格区分大小写，因此 `'PAID'` 不会匹配 `'paid'`。是否允许大小写、空白或别名属于输入协议；如需规范化，应在调用 `tryFrom()` 前明确完成，而不是把模糊转换藏在枚举内部。

最后一行表明带值枚举会默认编码为其标量值。对 JSON 对象中的枚举属性也采用同一规则。`JSON_THROW_ON_ERROR` 让编码错误成为异常，避免调用方忽略 `false`。

### 把状态转换放在枚举旁边

状态允许的下一步与当前 case 紧密相关，可以由实例方法表达。接口则规定每个状态都必须提供标签行为。

```php
// file: order_transitions.php
<?php

declare(strict_types=1);

interface Labelled
{
    public function label(): string;
}

enum OrderStatus: string implements Labelled
{
    case Created = 'created';
    case Paid = 'paid';
    case Shipped = 'shipped';
    case Cancelled = 'cancelled';

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

    public function canMoveTo(self $next): bool
    {
        return match ($this) {
            self::Created => in_array($next, [self::Paid, self::Cancelled], true),
            self::Paid => in_array($next, [self::Shipped, self::Cancelled], true),
            self::Shipped, self::Cancelled => false,
        };
    }
}

$current = OrderStatus::Created;
foreach ([OrderStatus::Paid, OrderStatus::Shipped, OrderStatus::Created] as $next) {
    $allowed = $current->canMoveTo($next);
    printf("%s -> %s: %s\n", $current->label(), $next->label(), $allowed ? 'yes' : 'no');
    if ($allowed) {
        $current = $next;
    }
}
```

```text
Created -> Paid: yes
Paid -> Shipped: yes
Shipped -> Created: no
```

方法只判断两种 case 之间是否允许转换，没有修改共享对象。调用方在得到 `true` 后更新自己的 `$current`，所以状态所有权仍然清楚。真实系统通常还要在事务中检查当前数据库版本，枚举方法本身不能防止并发写入。

`in_array()` 的第三个参数使用 `true`。case 本来就是对象，严格比较能准确表达意图，也避免后来把列表改成标量时悄悄引入弱比较。

### 在通用代码中缩小枚举类型

接收 `UnitEnum` 的函数既可能拿到纯枚举，也可能拿到带值枚举。先检查 `BackedEnum`，才能安全读取 `value`。

```php
// file: enum_metadata.php
<?php

declare(strict_types=1);

enum AccessMode
{
    case Read;
    case Write;
}

enum ResponseKind: int
{
    case Success = 200;
    case Missing = 404;
}

function describeCase(UnitEnum $case): string
{
    if ($case instanceof BackedEnum) {
        return "{$case->name}={$case->value}";
    }
    return $case->name;
}

foreach ([AccessMode::Read, ResponseKind::Missing] as $case) {
    printf("%s: %s\n", $case::class, describeCase($case));
}

var_export(enum_exists(ResponseKind::class));
echo PHP_EOL;
```

```text
AccessMode: Read
ResponseKind: Missing=404
true
```

`$case::class` 返回具体枚举的类名，而 `describeCase()` 只依赖内部接口。`enum_exists()` 检查名称是否指向已经定义或可自动加载的枚举；它不能替代具体业务允许列表。

这种通用函数适合诊断、表单构建器或框架适配层。领域逻辑通常应接收 `AccessMode` 或 `ResponseKind`，否则签名会接受过多无关枚举，并把错误推迟到函数体中。

## 陷阱

### 把标量当作枚举

> **陷阱:** 数据库或 HTTP 请求给出 `'paid'`，并不意味着程序已经得到 `OrderStatus::Paid`。把字符串直接传给 `OrderStatus` 参数会抛出 `TypeError`。

枚举的价值正来自具体类型边界。若领域函数继续接收 `string`，合法值集合仍靠注释和人工验证维持，类型系统无法阻止拼写错误或错误领域的值。

**修复方法：** 在边界校验原始类型和格式，再调用 `tryFrom()`；内部不变量被破坏时使用 `from()` 立即失败。转换完成后只传递 `OrderStatus`，写出边界错误时保留原始字段和值。

### 把无效输入静默改成默认 case

> **陷阱:** `OrderStatus::tryFrom($raw) ?? OrderStatus::Created` 会把缺失值、拼写错误、未知新值都解释成「已创建」。

这种回退看似稳健，却会制造错误业务事实。特别是在多版本服务之间，较新服务发送的 case 可能被旧服务当成默认状态，导致错误通知或状态倒退。

**修复方法：** 分别定义字段缺失与值无效的政策。可选字段可以在转换前保留 `null`；无效字段应生成明确校验错误。只有协议明确规定未知值采用默认语义时，才使用回退，并为该行为编写测试。

### 混淆 `name` 与 `value`

> **陷阱:** 对 `case Paid = 'paid'`，`name` 是 `'Paid'`，`value` 才是 `'paid'`。`from()` 与 `tryFrom()` 只查找后者。

把 `name` 持久化，会让 PHP 标识符意外成为数据库契约；用 `value` 做开发日志，又可能丢失源码名称。纯枚举根本没有 `value`，所以接收 `UnitEnum` 的通用助手不能无条件访问它。

**修复方法：** 明确每个边界需要源码标识、外部标量还是用户文案。持久化带值枚举的 `value`，诊断时可使用 `name`；展示文本放在翻译层。通用代码先用 `instanceof BackedEnum` 缩小类型。

### 用 `default` 掩盖新 case

> **陷阱:** 给每个 `match` 补上 `default` 会隐藏遗漏的分支。新增枚举 case 后，旧逻辑继续运行，却可能采用完全错误的业务分支。

PHP 不会在编译时检查枚举 `match` 是否穷尽。没有 `default` 时，遗漏只会在该 case 真正执行后抛出 `UnhandledMatchError`；有默认分支时，连这个信号也消失了。

**修复方法：** 对必须逐 case 决策的逻辑省略 `default`，并让参数化测试遍历 `cases()`。静态分析可以更早报告遗漏，但测试仍要验证每个 case 的具体结果。

### 把封闭集合当作扩展机制

> **陷阱:** 枚举不能继承，也不能在运行时追加 case。用枚举表示第三方可注册的支付提供商，会迫使核心包为每个插件发布新版本。

同样，一个枚举变量一次只能持有一个 case。把 `ReadWriteExecute` 等每种权限组合都做成 case，会让组合数量快速增加，而且调用方难以查询单项能力。

**修复方法：** 开放集合使用接口加注册表；可组合能力使用集合、值对象或经过清楚设计的位标志。只有成员由当前代码库控制，而且未知成员应被拒绝时才使用枚举。

### 随意修改持久化表示

> **陷阱:** 修改 backing value、case 名称或枚举类名可能破坏已有数据库行、消息、缓存与 PHP 序列化数据。

JSON 对带值枚举保存 `value`，PHP 的 `serialize()` 则记录枚举类型名与 case 名。两种格式依赖的标识不同。重命名看似只是清理源码，实际可能需要协议版本、数据迁移或兼容读取。

**修复方法：** 把已发布的 backing value 当作外部契约，并盘点所有写入格式。先部署能读取旧值与新值的代码，再迁移存量数据，最后停止写旧值；不要把长期跨服务协议建立在 PHP 序列化格式上。

<!-- deep -->

## 类型关系与运行限制

枚举声明与类、接口、Trait 共用命名空间，也采用相同的自动加载方式。case 同时是对象、其具体枚举类型的实例和 `UnitEnum` 的实例；带值 case 还是 `BackedEnum` 的实例。接收具体枚举类型能表达最窄契约，接收接口则表达跨类型共享的行为。

| 声明 | 接受的值 | 可安全使用的成员 |
| --- | --- | --- |
| `OrderStatus` | 仅 `OrderStatus` 的 case | 该枚举的方法、`name`，以及带值时的 `value` |
| `UnitEnum` | 任意纯枚举或带值枚举 case | `name`；具体类上的 `cases()` |
| `BackedEnum` | 任意带值枚举 case | `name` 与 `value` |
| `Labelled` | 实现接口的枚举 case 或普通对象 | 接口声明的 `label()` |

`UnitEnum::cases()` 是静态方法，而参数通常是一个 case 实例。需要列出任意枚举类时，可以接收 `class-string` 的 PHPDoc 类型，先用 `enum_exists()` 校验，再调用 `$enumClass::cases()`。原生 `string` 声明本身不会保证这个类字符串真的指向枚举。

`ReflectionEnum` 可以在框架或开发工具中检查枚举是否带值、它的 backing type 以及各个 case。业务逻辑通常不需要反射；具体枚举类型或一个小接口更直白。只有当代码确实要处理未知枚举类型时，通用抽象才有收益。

case 的单例身份意味着同一 case 可以用 `===` 稳定比较。它不意味着 case 可以自然排序，也不意味着 backing value 的大小就是领域顺序。优先级或流程顺序需要显式方法或映射，不能靠 case 声明位置和标量大小暗示。

枚举没有属性，因此无法把每个 case 变成带可变字段的小对象。方法可以根据 `$this` 计算结果，也可以读取外部服务，但后者会隐藏依赖并让测试困难。通常把纯领域映射留在枚举内，把 I/O 和事务放在服务中。

## 序列化与跨边界演进

带值枚举的 JSON 表示默认只有 backing value：字符串 case 编码为 JSON 字符串，整数 case 编码为 JSON 数字。纯枚举没有默认 JSON 表示，使用 `JSON_THROW_ON_ERROR` 时会抛出 `JsonException`。两类枚举都可以实现 `JsonSerializable` 改写行为，但这么做会为所有调用点建立统一格式。

统一格式不一定适合每个 API。有的响应只要 `'paid'`，有的调试接口可能需要 `{name, value}`，还有的协议需要版本字段。把投影写在资源或 DTO 层，往往比让枚举全局实现 `JsonSerializable` 更容易演进。

PHP 原生序列化为枚举使用记录类型与 case 名称的专用表示。反序列化会恢复现有的单例 case；类型或 case 找不到时会发出警告并返回 `false`。这种表示适合同版本 PHP 应用的短期内部数据，不适合语言无关的长期协议。

数据库列只保存 backing value，并不会自动获得 PHP 枚举的封闭集合。要在存储层拒绝非法值，需要数据库约束、受控写入路径或两者同时存在。应用转换失败时应带上记录标识和原始值，便于区分脏数据与发布顺序问题。

向枚举新增 case 通常兼容读取旧值，却可能破坏旧消费者。旧 PHP 服务的 `tryFrom()` 会把新 value 视为未知，遗漏分支的 `match` 会在运行时失败。滚动发布时，应先让消费者具备明确的未知值政策，再让生产者发出新值。

删除或重命名 case 风险更高。稳妥迁移会先停止产生旧值，让读取端在过渡期理解两种表示，迁移存量数据，确认队列和缓存中没有旧消息，最后删除兼容代码。枚举让合法集合清楚可见，但不会替你完成分布式协议迁移。

<!-- /deep -->

[检查点: php/enums](https://codewiki.com/zh/php/enums/#checkpoint)

## 延伸阅读

- [PHP 手册：基础枚举](https://www.php.net/manual/en/language.enumerations.basics.php)
- [PHP 手册：带值枚举](https://www.php.net/manual/en/language.enumerations.backed.php)
- [PHP 手册：列出枚举值](https://www.php.net/manual/en/language.enumerations.listing.php)
- [PHP 手册：枚举方法](https://www.php.net/manual/en/language.enumerations.methods.php)
- [PHP 手册：枚举与对象的区别](https://www.php.net/manual/en/language.enumerations.object-differences.php)
- [PHP 手册：枚举序列化](https://www.php.net/manual/en/language.enumerations.serialization.php)
