枚举

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

难度 进阶 时长 标准深度约15分钟
版本 PHP 8.3.33
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()则给出声明中的全部选项。

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()检查名称是否指向已经定义或可自动加载的枚举;它不能替代具体业务允许列表。

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

陷阱

把标量当作枚举

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

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

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

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

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

混淆name与value

把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的实例。接收具体枚举类型能表达最窄契约,接收接口则表达跨类型共享的行为。

声明接受的值可安全使用的成员
OrderStatus仅OrderStatus的case该枚举的方法、name,以及带值时的value
UnitEnum任意纯枚举或带值枚举casename;具体类上的cases()
BackedEnum任意带值枚举casename与value
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上编辑 报告错误 讲清楚了吗?