别只把 enum 当常量列表:Zig 枚举、状态机与 Tagged Union 实战

别只把 enum 当常量列表:Zig 枚举、状态机与 Tagged Union 实战

枚举最简单的用途,是给一组固定选项起名字:订单有 pendingpaidshipped,日志有 infowarnerror

但 Zig 的 enum 不止是"几个整数常量"。它有强类型约束,可以和 switch 做穷尽检查,可以指定底层整数类型,可以定义方法,还能和 union 组合成携带不同数据的 Tagged Union。状态机、协议解析、命令分发和错误分类,都适合用这套能力表达。

本文按 Zig 0.16.0 语法编写,示例可以直接保存为 .zig 文件运行。

一、最基础的 enum

zig 复制代码
const std = @import("std");

const Color = enum {
    red,
    green,
    blue,
};

pub fn main() void {
    const color1 = Color.red;
    const color2: Color = .green;

    std.debug.print("{} {}\n", .{ color1, color2 });
}

Color 是一个枚举类型,Color.red 是其中一个枚举值。已知目标类型时,可以省略枚举名,写成 .green

zig 复制代码
const color: Color = .blue;

这种写法叫枚举字面量。.blue 只有放在能够推导出 Color 的上下文里才成立;单独写 const color = .blue,编译器没有足够信息判断它属于哪个枚举。

枚举是强类型。下面两个 red 虽然名字相同,但属于不同类型:

zig 复制代码
const Color = enum { red, green };
const Status = enum { red, green };

const color = Color.red;
// const status: Status = color; // 编译错误,Color 和 Status 不是同一种类型

这能避免不同业务含义的整数或标签意外混用。

二、枚举与 switch:让状态处理完整

枚举最常和 switch 一起使用:

zig 复制代码
const std = @import("std");

const Status = enum {
    pending,
    running,
    finished,
};

fn statusText(status: Status) []const u8 {
    return switch (status) {
        .pending => "等待中",
        .running => "运行中",
        .finished => "已完成",
    };
}

pub fn main() void {
    std.debug.print("{s}\n", .{statusText(.running)});
}

这里没有 else,但三个枚举成员全部处理了,所以 switch 是完整的。以后给 Status 增加一个 .failed,这个函数会立刻出现编译错误,提醒补上处理逻辑。

这比一串整数判断更可靠:

zig 复制代码
// 不推荐:魔术数字没有业务含义
// if (status_code == 2) { ... }

多个分支可以合并:

zig 复制代码
fn isFinished(status: Status) bool {
    return switch (status) {
        .finished => true,
        .pending, .running => false,
    };
}

如果确实需要兜底,也可以使用 else

zig 复制代码
fn shortText(status: Status) []const u8 {
    return switch (status) {
        .finished => "done",
        else => "not done",
    };
}

对于普通穷尽枚举,else 会让新增枚举成员时失去一部分编译期提醒。状态规则比较重要时,优先逐项列出分支。

三、枚举的底层整数值

默认情况下,枚举成员从 0 开始递增:

zig 复制代码
const Level = enum(u8) {
    low,    // 0
    normal, // 1
    high,   // 2
};

指定底层类型后,可以把枚举转换成对应的整数:

zig 复制代码
const std = @import("std");

const HttpStatus = enum(u16) {
    ok = 200,
    not_found = 404,
    internal_error = 500,
};

pub fn main() void {
    const code: u16 = @intFromEnum(HttpStatus.not_found);
    std.debug.print("{}\n", .{code});
}

@intFromEnum 读取枚举的整数标签。它适合协议编码、日志输出和与外部数值对接,但不应该让业务代码到处依赖数字本身。

也可以从整数转回枚举:

zig 复制代码
const raw: u16 = 404;
const status: HttpStatus = @enumFromInt(raw);

@enumFromInt 的目标类型来自左侧的 HttpStatus。旧版本资料中常见的 @enumFromInt(HttpStatus, raw) 是旧写法,当前 Zig 语法不需要把类型作为第一个参数传入。

对普通穷尽枚举来说,整数必须对应已经声明的成员。非法值在编译期可能直接报错,运行时则会触发安全检查错误:

zig 复制代码
const raw: u16 = 401;
// const status: HttpStatus = @enumFromInt(raw); // 运行时非法枚举值

如果输入来自网络、文件或用户,不能直接信任整数。更稳妥的方式是先用 switch 或范围判断完成业务校验,再转换。

四、跳号和显式标签值

枚举成员可以只给部分值赋值,后续未指定的成员会从前一个值继续递增:

zig 复制代码
const Token = enum(u8) {
    identifier = 1,
    number = 10,
    string, // 11
    keyword = 20,
    operator, // 21
};

显式数值适合协议码、系统调用编号或稳定的持久化格式。普通业务状态通常不需要手动编号,因为业务关心的是标签,不是底层整数。

五、@tagName:把枚举标签变成字符串

调试日志或展示文本经常需要枚举成员名称,可以使用 @tagName

zig 复制代码
const std = @import("std");

const Direction = enum {
    north,
    south,
    east,
    west,
};

pub fn main() void {
    const direction = Direction.east;
    std.debug.print("方向:{s}\n", .{@tagName(direction)});
}

@tagName 返回以 0 结尾的编译期字符串,适合直接打印。它返回的是标签名 east,不是业务翻译文本。如果界面需要"东",仍然适合通过 switch 映射:

zig 复制代码
fn directionText(direction: Direction) []const u8 {
    return switch (direction) {
        .north => "北",
        .south => "南",
        .east => "东",
        .west => "西",
    };
}

六、枚举也可以定义方法

枚举内部的方法,本质上仍然是带命名空间的函数:

zig 复制代码
const std = @import("std");

const TrafficLight = enum {
    red,
    yellow,
    green,

    pub fn duration(self: TrafficLight) u32 {
        return switch (self) {
            .red => 30,
            .yellow => 5,
            .green => 25,
        };
    }

    pub fn next(self: TrafficLight) TrafficLight {
        return switch (self) {
            .red => .green,
            .green => .yellow,
            .yellow => .red,
        };
    }
};

pub fn main() void {
    var light: TrafficLight = .red;
    std.debug.print("当前={}秒\n", .{light.duration()});

    light = light.next();
    std.debug.print("下一个:{s}\n", .{@tagName(light)});
}

light.duration() 只是 TrafficLight.duration(light) 的点号调用形式。方法适合放置与枚举状态紧密相关的转换规则,例如"下一个状态""是否终态""对应超时时间"等。

七、完整实战:用 enum 写一个任务状态机

状态机的核心是:当前状态 + 输入事件 = 下一个状态。把状态和事件分别定义成枚举,再用 switch 写转换规则,结构会非常清楚。

zig 复制代码
const std = @import("std");

const TaskState = enum {
    todo,
    running,
    success,
    failed,
};

const TaskEvent = enum {
    start,
    complete,
    fail,
    retry,
};

const Task = struct {
    state: TaskState = .todo,

    fn dispatch(self: *Task, event: TaskEvent) !void {
        self.state = switch (self.state) {
            .todo => switch (event) {
                .start => .running,
                else => return error.InvalidEvent,
            },
            .running => switch (event) {
                .complete => .success,
                .fail => .failed,
                else => return error.InvalidEvent,
            },
            .failed => switch (event) {
                .retry => .running,
                else => return error.InvalidEvent,
            },
            .success => return error.TaskAlreadyFinished,
        };
    }

    fn print(self: *const Task) void {
        std.debug.print("状态:{s}\n", .{@tagName(self.state)});
    }
};

pub fn main() !void {
    var task = Task{};
    task.print();

    try task.dispatch(.start);
    task.print();

    try task.dispatch(.complete);
    task.print();

    if (task.dispatch(.retry)) |_| {
        unreachable;
    } else |err| {
        std.debug.print("事件被拒绝:{}\n", .{err});
    }
}

运行结果:

text 复制代码
状态:todo
状态:running
状态:success
事件被拒绝:error.TaskAlreadyFinished

这个 demo 中的 TaskState 只负责描述状态,TaskEvent 只负责描述外部事件,Task.dispatch 负责维护状态转移规则。非法转移通过错误返回,不需要用注释约定"这个状态不能调用某个方法"。

八、union(enum):枚举标签也可以携带数据

普通 enum 只能表示"当前是哪一个标签",不能携带额外数据。例如,文件操作结果可能是成功并带有文件大小,也可能失败并带有错误信息。这时可以使用 Tagged Union:

zig 复制代码
const Result = union(enum) {
    ok: usize,
    not_found: []const u8,
    permission_denied: []const u8,
};

创建值:

zig 复制代码
const success: Result = .{ .ok = 1024 };
const missing: Result = .{ .not_found = "config.json" };

使用 switch 时,可以同时匹配标签并取出载荷:

zig 复制代码
const std = @import("std");

const Result = union(enum) {
    ok: usize,
    not_found: []const u8,
    permission_denied: []const u8,
};

fn printResult(result: Result) void {
    switch (result) {
        .ok => |size| std.debug.print("读取成功,大小={}\n", .{size}),
        .not_found => |path| std.debug.print("文件不存在:{s}\n", .{path}),
        .permission_denied => |path| std.debug.print("没有权限:{s}\n", .{path}),
    }
}

pub fn main() void {
    printResult(.{ .ok = 1024 });
    printResult(.{ .not_found = "config.json" });
}

union(enum) 内部有一个隐藏的枚举标签,用来记录当前激活的是哪个字段;同时只能有一个载荷处于有效状态。switch 会检查所有变体,避免读取错误的载荷。

普通 union 没有这个标签,代码必须自行记录当前激活字段;只有 Tagged Union 才适合直接用 switch 安全分支处理。

自定义 Tagged Union 的标签类型

union(enum) 让 Zig 自动生成标签类型,也可以显式提供一个枚举:

zig 复制代码
const EventTag = enum {
    text,
    number,
};

const Event = union(EventTag) {
    text: []const u8,
    number: i64,
};

这种写法适合多个类型需要共享同一套标签,或者需要明确访问标签类型的场景。大多数业务代码使用 union(enum) 就足够了。

九、非穷尽枚举:允许出现未命名值

普通枚举必须覆盖所有可能的值。与外部系统交互时,底层整数可能包含当前版本还不认识的值,这时可以使用非穷尽枚举:

zig 复制代码
const DeviceState = enum(u8) {
    offline = 0,
    online = 1,
    _,
};

末尾的 _ 表示还有未命名的合法值。整数转换:

zig 复制代码
const raw: u8 = 200;
const state: DeviceState = @enumFromInt(raw);

对于非穷尽枚举,@enumFromInt 可以产生未命名值。switch 中可以使用 _ 处理未知值,同时仍然逐项列出已知成员:

zig 复制代码
fn stateText(state: DeviceState) []const u8 {
    return switch (state) {
        .offline => "离线",
        .online => "在线",
        _ => "未知状态",
    };
}

else 也可以兜底,但 _ 更适合非穷尽枚举:它表示"其他标签",同时能让编译器继续检查已知标签是否遗漏。

十、C 互操作:extern enum

普通 Zig 枚举默认不保证 C ABI 兼容。需要导出到 C 或接收 C API 的枚举时,使用 extern enum 并明确指定底层类型:

zig 复制代码
const Signal = extern enum(c_int) {
    interrupt = 2,
    kill = 9,
};

extern enum 适合 ABI 场景,不是普通枚举的"增强版本"。业务代码优先使用普通 enum,只有跨语言边界需要稳定布局时才使用 extern enum

十一、枚举与错误集不是同一种类型

错误集的写法和枚举很像:

zig 复制代码
const ParseError = error{
    InvalidNumber,
    UnexpectedEnd,
};

错误集也可以使用 switch

zig 复制代码
fn printError(err: ParseError) void {
    switch (err) {
        .InvalidNumber => std.debug.print("数字格式错误\n", .{}),
        .UnexpectedEnd => std.debug.print("输入提前结束\n", .{}),
    }
}

但错误集不是普通 enum。错误值使用 error.Name 产生,函数返回错误时通常写成 ParseError!T!T;普通枚举则使用 Type.member 或上下文中的 .member。两者都适合表示有限集合,但用途和类型系统语义不同。

十二、通过 @typeInfo 做编译期反射

需要在编译期遍历所有枚举成员时,可以使用 @typeInfo

zig 复制代码
const std = @import("std");

const Color = enum {
    red,
    green,
    blue,
};

pub fn main() void {
    const info = @typeInfo(Color).@"enum";

    inline for (info.fields) |field| {
        std.debug.print("{s}\n", .{field.name});
    }
}

inline for 会在编译期展开循环,适合生成注册表、命令帮助信息或测试。只需要打印当前值时,@tagName 更简单;只有需要遍历整个枚举定义时,才有必要使用 @typeInfo

十三、枚举和位标志不是一回事

枚举表示"多个选项中当前只有一个":

zig 复制代码
const Mode = enum(u8) {
    read,
    write,
};

权限、功能开关这类场景往往允许多个选项同时存在,不应该用普通枚举:

zig 复制代码
const Permissions = packed struct(u8) {
    read: bool = false,
    write: bool = false,
    execute: bool = false,
    reserved: u5 = 0,
};

const permissions = Permissions{
    .read = true,
    .write = true,
};

这里 readwrite 可以同时为 true,所以它是位标志集合,不是枚举。单选使用 enum,多选使用 packed struct 或专门的位运算封装,语义会更准确。

十四、常见错误

1. 直接把枚举和整数比较

zig 复制代码
const State = enum(u8) { idle, running };
const state = State.idle;

// if (state == 0) {} // 类型不匹配
if (@intFromEnum(state) == 0) {
    // 只有明确需要比较协议数值时,才转换为整数
}

业务判断更建议写成 state == .idle,这样不会依赖底层编号。

2. 用非法整数强行转换

zig 复制代码
const State = enum(u8) { idle, running };
const raw: u8 = 99;
// const state: State = @enumFromInt(raw); // 非法枚举值

外部输入先校验,再转换;无法列举所有外部值时,考虑非穷尽枚举或保留原始整数。

3. switch 漏掉成员

普通枚举的 switch 要么覆盖所有成员,要么提供 else。状态转移逻辑最好不要随意用 else,否则新状态可能静默落入旧的兜底分支。

4. 用 enum 模拟多选

enum 同一时间只有一个值。权限、功能开关、多个过滤条件同时启用时,应使用位标志集合。

5. 把 union(enum) 当成普通 enum

普通枚举只有标签;union(enum) 还有与标签对应的载荷。创建 Tagged Union 时需要为带载荷的字段提供值:

zig 复制代码
const value: union(enum) {
    none,
    number: i32,
} = .{ .number = 42 };

十五、选择哪一种写法

  • 一组固定的单选状态:普通 enum
  • 协议或数据库需要稳定数值:enum(u8)enum(u16) 等显式底层类型。
  • 输入可能包含未识别标签:非穷尽枚举 enum(u8) { known, _ }
  • C ABI 交互:extern enum(c_int) 或目标 ABI 要求的整数类型。
  • 一个状态需要携带不同类型数据:union(enum)
  • 多个开关可以同时打开:packed struct 或位标志封装。
  • 错误分类:错误集 error{ ... },不要和普通 enum 混用。

枚举真正有价值的地方,不是把数字换成了单词,而是把"允许出现哪些状态"写进了类型。再配合 switch 的完整性检查和 union(enum) 的载荷,状态变化、协议消息和业务分支都能在编译期获得更清晰的约束。

参考资料

相关推荐
2601_963870211 小时前
【计算机毕业设计】基于Spring Boot的非遗文创产品交易平台的设计与实现
java·spring boot·后端
lv__pf1 小时前
Spring之AOP底层源码解析(下)【TL spring14】
java·后端·spring
苍何2 小时前
保姆级教程,Codex 接入 DeepSeek V4 Pro 和 Grok 4.6,6 种方法全整理
后端
苍何2 小时前
全球首款原生全模态交互式世界模型来了,AI 视频终于能“玩”了
后端
一只叫煤球的猫2 小时前
Spring AI 2.0 源码解析(一):一次 ChatClient 调用到底经历了什么?
后端·面试·ai编程
andongni2032 小时前
后端参数校验
spring boot·后端·mybatis
颜进强2 小时前
06 - OpenSpec change 从模糊想法到完整契约:new change / explore / propose 三连
前端·后端·ai编程
Cache技术分享2 小时前
499. Java 反射 - 获取类型上的注解
前端·后端