枚举

用 PHP 枚举表示封闭值集合,掌握纯枚举、带值枚举、边界转换、方法、序列化与演进陷阱。

难度 进阶 时长 标准深度约 15分钟
版本 PHP 8.3.33
what

枚举把有限且封闭的一组合法值定义为独立类型。纯枚举只区分 case,带值枚举还为每个 case 提供唯一的 stringint 值。

trap

namevalue 和外部数据不是一回事;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 显式绑定唯一的 stringint。只有需要数据库、消息或 API 往返时才需要 backing value。

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

枚举带来的是 名义类型(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,也不能为枚举声明实例属性或静态属性。枚举没有继承层次:它不能继承类,也不能被类或另一个枚举继承。

UnitEnumBackedEnum

所有枚举都会自动实现内部接口 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() 则给出声明中的全部选项。

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));
}
Morning closes at 11
Afternoon closes at 16
Evening closes at 20

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

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

从外部值恢复 case

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

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;
draft => Draft
paid => Paid
refunded => invalid
PAID => invalid
ValueError
"paid"

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

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

把状态转换放在枚举旁边

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

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;
    }
}
Created -> Paid: yes
Paid -> Shipped: yes
Shipped -> Created: no

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

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

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

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

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;
AccessMode: Read
ResponseKind: Missing=404
true

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

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

陷阱

把标量当作枚举

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

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

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

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

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

混淆 namevalue

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

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

default 掩盖新 case

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

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

把封闭集合当作扩展机制

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

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

随意修改持久化表示

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

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

深入 类型关系与运行限制

类型关系与运行限制

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

声明接受的值可安全使用的成员
OrderStatusOrderStatus 的 case该枚举的方法、name,以及带值时的 value
UnitEnum任意纯枚举或带值枚举 casename;具体类上的 cases()
BackedEnum任意带值枚举 casenamevalue
Labelled实现接口的枚举 case 或普通对象接口声明的 label()

UnitEnum::cases() 是静态方法,而参数通常是一个 case 实例。需要列出任意枚举类时,可以接收 class-string<UnitEnum> 的 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 风险更高。稳妥迁移会先停止产生旧值,让读取端在过渡期理解两种表示,迁移存量数据,确认队列和缓存中没有旧消息,最后删除兼容代码。枚举让合法集合清楚可见,但不会替你完成分布式协议迁移。

延伸阅读

检查点

5个问题 · 2 道输出预测题 · 1 道找错题

复制为 Markdown 面试题库 在 GitHub 上编辑 报告错误 讲清楚了吗?