Zero 风格的标签联合
choice 声明一种类型,它的值是若干个具名变体之一,每个变体携带自己的负载:
choice Result {
ok: i32,
err: String,
}
一个 Result 值要么是携带 i32 的 ok,要么是携带 String 的 err。不会两者都是,也不会两者都不是。类型系统保证了这一点,而 match 让你可以方便地据此行动。
其他语言把同样的概念叫作“标签联合”“和类型”“可辨识联合”或“代数数据类型”。Zero 把它写作 choice,并保持语法精简。
声明 choice
choice Name {
variantA: PayloadTypeA,
variantB: PayloadTypeB,
}
每行列出一个变体。名字由你决定;冒号后面的类型是这个变体携带的负载。不需要负载的变体使用 Void:
choice Token {
word: String,
number: i32,
eof: Void,
}
Token.eof 是一个没有实际负载的变体(它的负载类型是 Void),适合表示终止符一类的情况。
构造 choice 值
依次写出类型名、变体名,再传入负载,就能构造一个值:
let success = Result.ok(42)
let failure = Result.err("validation failed")
负载类型必须与变体声明的负载类型一致。Result.ok("hello") 会导致编译错误,因为 ok 需要的是 i32。
这里同样支持类型推断。如果右侧已经完全确定了类型,就可以写 let success = Result.ok(42),绑定的类型就是 Result。如果想在绑定处把类型写明,也可以显式标注:
let success: Result = Result.ok(42)
匹配 choice
match 是读取 choice 值的方式。它的结构是:
match value {
.variantA => binding { /* body when value is variantA, with payload in `binding` */ }
.variantB => binding { /* body when value is variantB */ }
}
下面是 Zero 官方仓库中的一个示例,点击“运行”,看看 .ok 分支被执行:
按字面意思来读这个 match:“根据 result 持有的是哪个变体,执行对应的分支,并把负载绑定到选定的名字上。”在 .ok 分支中,value 是 i32 负载;在 .err 分支中,message 是 String 负载。每个分支都是独立的作用域,绑定只在它自己的分支体内可见。
穷尽性
这是 match 相对于 if/else if 链最大的好处:编译器会检查每个变体都有对应的分支。如果你忘了 .err 分支,不会在运行时落到某个默认分支,而是会得到一个编译错误:
{
"code": "MAT001",
"message": "match is not exhaustive: missing variant 'err'",
"line": 9
}
(错误代码仅作示意;关键在于这个约定本身。)
给 choice 添加一个新变体,比如 Result.timeout: Void,代码库中所有针对 Result 的 match 都会报编译错误,直到你处理了这个新情况。这是一项特性,而不是负担:编译器在准确地告诉你哪里需要处理新情况。
不需要负载的时候
如果某个变体的负载是 Void,或者你在这个分支中根本不关心它,可以忽略绑定,但为了满足穷尽性,你仍然必须写出这个分支:
match token {
.word => w { /* use w */ }
.number => n { /* use n */ }
.eof => _ { /* nothing to bind */ }
}
在 1.0 之前的 Zero 中,“忽略负载”的具体写法可能还会变化(你可能会看到 _,或者直接省略绑定)。概念上的要点是稳定的:每个变体都要有一个分支,无论有没有负载。
常见用法
Result 风格的错误类型
这正是官方仓库使用的示例:
choice Result {
ok: i32,
err: String,
}
既可能成功返回值、也可能失败返回消息的函数,会返回一个 Result。调用者通过模式匹配取出值或消息。Zero 的 raises/check 系统负责可能失败的操作中错误的传播;当你想把一个成功或失败的结果作为数据保存下来时,Result 就很有用。
解析器 token
choice Token {
word: String,
number: i32,
eof: Void,
}
分词器产生一串 Token。每个使用者根据变体决定要做什么:打印单词、累加数字、遇到 eof 时退出。
状态机
choice State {
waiting: Void,
processing: i32,
done: String,
}
processing 携带当前任务的 ID;done 携带最终结果。每次状态转换都是一个新的 State 值,不会有可变字段散落在一个 shape 里。
choice 与泛型
choice 和 shape 一样可以是泛型的:
choice Maybe<T> {
some: T,
none: Void,
}
Maybe<i32> 表示“一个可选的整数”,Maybe<String> 表示“一个可选的字符串”。Zero 标准库中也有同样的写法,它比用 null 作哨兵值合适得多:一旦你对这个类型使用 match,就不可能忘掉 .none 的情况。
什么时候用 choice、shape 或 enum
- Shape:包含多个字段的记录,所有字段同时存在。
- Enum:N 个标签之一,不带额外数据。
- Choice:N 个变体之一,每个变体携带一个负载。
真实程序中的大多数数据模型都是这三者的某种组合。先问一句“这是‘和’、‘或’,还是‘带数据的或’?”,由此带来的清晰思路,是使用一门小型语言时常被低估的好处之一。
下一步:World 能力对象
choice 和 match 涵盖了 Zero 的数据部分。下一章讲副作用:Zero 程序如何与外部世界交互。它从 World 能力对象开始,所有 I/O 都要经过这个对象。
常见问题
Zero 中的 choice 是什么?
choice 是 Zero 的标签联合类型:它的值是若干个具名变体之一,每个变体携带自己的负载类型。例如:choice Result { ok: i32, err: String }。一个 Result 值要么是携带 i32 的 ok,要么是携带 String 的 err。用 Result.ok(42) 或 Result.err("bad") 来构造它。
Zero 中的 match 是怎么工作的?
match value { .variantA => binding { ...body } .variantB => binding { ...body } } 根据 value 当前持有的变体进行分支。每个分支匹配一个变体,为负载绑定命名,然后执行分支体。编译器会检查你是否覆盖了每一个变体:穷尽性检查是它相对于 if/else if 最大的优势。
如何构造一个 choice 值?
写出类型名和变体名,再传入负载:let r: Result = Result.ok(42) 或 let r = Result.err("validation failed")。负载类型必须与变体声明的负载类型一致,传错类型会导致编译错误。
choice 和 enum 有什么区别?
enum 的变体只是标签,不带负载。choice 的每个变体都携带一个声明了类型的值。如果需要给某种情况附加数据(一条错误消息、一个成功的结果、一个解析出来的 token),就用 choice。如果各种情况只是纯标签,就用 enum。
为什么处理 choice 时 match 比 if-else 更好?
match 天生就是穷尽的:编译器会检查每个变体是否都被处理,所以以后添加新变体时,你必须更新所有对这个类型做分支的地方。if/else if 链则会悄悄地漏过去,把遗漏的情况隐藏起来,直到它在生产环境中变成一个 bug。