用类型讲故事:结构体与枚举
第 4 章你能读单个函数了;这一章看数据本身怎么被设计。jackdaw 用结构体说清"一个东西有哪些字段",用枚举说清"一个值只可能是哪几种"。读完本章,你能回答:
jackdaw.toml里的strategy = "order"这行字符串,在哪一步、经什么规则变成了Strategy::Order?- 给
Strategy新增一个变体,哪些代码会立刻编译失败? - 读
load()时,哪一步可能得到None、哪一步可能产生Err?错误信息是谁拼出来的?
结构体:把一张 TOML 表变成类型
先看 jackdaw 吃进去的文件。下面这份是 config.rs 测试里拼出的合法样例:网关、一个供应商、一个路由组,三个顶层表。
[gateway]
port = 3426
auth_token_env = "JACKDAW_TOKEN"
[providers.openai]
base_url = "https://api.openai.com/v1"
api_key_env = "OPENAI_API_KEY"
[groups.coder]
strategy = "order"
models = ["openai/gpt-5.2"]没有结构体,这份文件进程序只是一堆嵌套的字符串表:每处使用都自己取值、自己猜类型,键名拼错要等运行到那行才炸。结构体把"配置长什么样"一次声明成类型,之后交给编译器核对:
/// The full top-level configuration.
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
pub gateway: GatewayConfig,
pub providers: BTreeMap<String, ProviderConfig>,
#[serde(default)]
pub groups: BTreeMap<String, GroupConfig>,
}
// ...
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupConfig {
pub strategy: Strategy,
/// Group members: `<provider>/<model-id>` references, tried in order.
pub models: Vec<String>,
}从 pub struct Config 看起:三个字段对应三个顶层表,gateway: GatewayConfig 是结构体套结构体;// ... 是本书节选约定,表示省略的 GatewayConfig(装 port 与 auth_token_env)和 ProviderConfig(装 base_url 与 api_key_env)。providers 的类型 BTreeMap<String, ProviderConfig> 读作"名字 → 一份配置"——BTreeMap 是按键排序的 map,集合家族里 HashMap 的近亲,这里先当作映射读即可,它与 HashMap 的取舍不影响本章。#[serde(default)] 允许整个 [groups] 段缺省:删掉 coder 组,加载照样成功。
derive 与 serde:编译器替你写的部分
#[derive(Debug, Clone, Deserialize)] 是本章第二个主角:派生。它让编译器按通用规则替类型实现常用能力:Debug 让值能用 {:?} 打印——下文 validate 的报错里 {member:?} 打印的是 String,它的 Debug 标准库自带;自家类型想要这份能力,靠的就是这行派生。Clone 允许显式 .clone();Deserialize 来自序列化库 serde——一个外部箱,箱与依赖从哪来第 8–9 章讲,这里只需知道它声明"我知道怎么从 TOML 文本装配出这个类型的值"——于是 load() 里一行 toml::from_str(&text),文件文本就成了类型化的 Config。
#[serde(deny_unknown_fields)] 单独记一下:多写的字段(比如把 strategy 拼成 stratagy)直接报加载错误,而不是被静默忽略——config.rs 的测试 unknown_field_is_a_load_error 盯着这件事。
审 AI 生成的配置代码时
看到 AI 定义的配置结构体只挂了 #[derive(Deserialize)]、没有 deny_unknown_fields,值得问一句:字段拼错被悄悄吞掉,是这个程序想要的吗?
枚举:一个类型,几种已知形状
port: u16 表示任何 u16 都合法;strategy 却只有两个合法值。用字符串装它,"smart" 也能通过编译,等运行才炸。枚举把"只能是这样几种"写进类型:
/// How a group picks its starting member. Serde matches the lowercase TOML
/// spelling, so an unknown strategy (say, magpie's `smart`) is a load error.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Strategy {
/// Always start at the first member on the list.
Order,
/// Move the starting member forward on every request.
Rotate,
}从 doc 注释看起,它把机制说完了:serde 按小写拼写匹配,#[serde(rename_all = "lowercase")] 把变体 Order 对应 TOML 里的 "order"。中间状态走一遍:文件里 strategy = "order" → from_str 读到字符串 "order" → 按小写比对变体 → 产出 Strategy::Order,存进 GroupConfig.strategy。对照 magpie:它的路由策略有 smart / order / rotate / usage 四种,jackdaw 刻意只做 order 和 rotate(教程只要最小核);差异被类型接住——配置写 strategy = "smart" 得到的是一条加载错误,测试 unknown_strategy_is_rejected 验证的正是它。
同一副眼镜看 agent 发来的 chat 请求(OpenAI 兼容形态):role 取值封闭,是枚举;messages 是 Vec;temperature 可有可无,是 Option;整份请求靠 serde 进出。jackdaw 真正的消息类型第 14 章随网关展开,现在你只需会做这道"哪个键是什么类型"的阅读题。
match 的穷尽性:编译器替你查漏
模式匹配的 match 按结构拆值,最硬的保证叫穷尽性:每个可能的变体都必须有分支,漏一个编译不过。假设给 Strategy 加第三个变体,config.rs 里每个 match 它的地方都会立刻报错——你不必记得"还有哪里要改",编译器替你列清单;if-else 链给不了这个保证。枚举变体还能携带数据,match 顺手拆开。validate() 检查组员引用就是一例:
// ...
for member in &group.models {
match ModelRef::parse(member) {
Err(why) => {
errors.push(format!("[groups.{id}] models: member {member:?}: {why}"));
}
// Groups stay flat on purpose (magpie nests up to 8 levels;
// jackdaw keeps one level to stay readable).
Ok(ModelRef::Group { .. }) => {
errors.push(format!(
"[groups.{id}] models: member {member:?} is a group — nested \
groups are not supported. Use `<provider>/<model-id>` members only."
));
}
Ok(ModelRef::Provider { provider, .. }) => {
if !self.providers.contains_key(&provider) {
errors.push(format!(
"[groups.{id}] models: member {member:?} names unknown \
provider `{provider}`. Known providers: {}. Add a \
[providers.{provider}] table or fix the typo.",
self.provider_names().join(", ")
));
}
}
}
}
// ...从 ModelRef::parse(member) 看起:它把 "openai/gpt-5.2" 解析成 model.rs 里的 ModelRef 枚举,包在 Ok/Err 里(第 4 章你见过解析 provider/model 字符串的函数)。三个分支各绑定各的:Err(why) 把失败原因绑给 why 拼进报错;Ok(ModelRef::Group { .. }) 只认"是携带字段的 Group 变体",字段一概不要;Ok(ModelRef::Provider { provider, .. }) 只取出 provider 字段,拿去 providers 表查存在性——{ .. } 读作"其余字段我不关心"。走一遍中间状态:成员 "group/other" 落第二分支被拒(magpie 组可嵌套最多 8 层,jackdaw 刻意只留一层);"nope/gpt-5.2" 落第三分支,查无此 provider。两种结果都是可行动的报错,不是崩溃。
Option 与 Result:缺席与失败各有类型
"可能没有"与"可能失败",不少语言用 null、异常或魔法值对付;Rust 给它们各自的类型:选项类型 Option<T> 是 Some(值) 或 None,结果类型 Result<T, E> 是 Ok(值) 或 Err(错误)。两者都是枚举,前面的一切照用。
找配置文件时,家目录可能取不到:
/// Where to look when `--config` was not given: local file first, then the
/// per-user location. Order matters — a local file overrides the user one.
fn config_candidates() -> Vec<PathBuf> {
let mut tried = vec![PathBuf::from(LOCAL_CONFIG_NAME)];
match std::env::home_dir() {
Some(home) => tried.push(home.join(".config").join("jackdaw").join("jackdaw.toml")),
None => tried.push(PathBuf::from("~/.config/jackdaw/jackdaw.toml")),
}
tried
}从 std::env::home_dir() 看起:它返回 Option,因为系统可能给不出家目录。Some(home) 把家目录绑给 home,拼出真实路径;None 手里没有任何值,只能压一个占位路径进列表,好让报错里看得出"这里找过"。
加载主流程把两种类型串成一条链:
pub fn load(explicit: Option<&Path>) -> Result<Config, ConfigError> {
let path = match explicit {
Some(given) => Some(given.to_path_buf()),
None => config_candidates().into_iter().find(|path| path.is_file()),
};
let Some(path) = path else {
return Err(ConfigError::no_config(&config_candidates()));
};
let text = std::fs::read_to_string(&path).map_err(|error| ConfigError::Read(path, error))?;
let config: Config =
toml::from_str(&text).map_err(|error| ConfigError::Parse(error.to_string()))?;
config
.validate()
.map_err(|errors| ConfigError::Validation(format!("- {}", errors.join("\n- "))))?;
Ok(config)
}从签名看起:explicit 是 --config 给的路径,可能没有,所以是 Option;第一个 match 把它换成"找到的第一个存在的文件",没有则 let Some(path) = path else { ... } 提前返回——这是模式匹配的另一种句式:是 Some 就把值绑给 path 继续,是 None 就走 else。之后每个 ? 含义一致:这步若是 Err,把错误原样交还调用者;若是 Ok,解包继续(问号运算符)。三处 map_err 各自把底层错误换成 ConfigError 的对应变体。返回类型里的 ConfigError 是又一个携带数据的枚举——比如 Read(PathBuf, std::io::Error) 同时装着哪个文件、底层错误是什么;它的定义与错误处理分工第 11 章展开。把失败也写成类型,调用方就无法装作没有失败。回看开场第三问:哪步 None(let-else)、哪步 Err(三个 ?)、错误信息谁拼(map_err 的闭包),你现在都有答案了。
小结
- 结构体把 TOML 表的结构写成类型,枚举把"只能这几种"写成类型;serde 的派生负责文本与类型之间的装配。
- match 的穷尽性把"新增变体忘了改某处"从运行时事故变成编译错误。
- Option 与 Result 把缺席与失败放进返回类型,
?是它们的快速通道。 - 下一章:trait——行为的契约,以及它为什么是 jackdaw 的脊柱。
自检
- 用你最熟的语言(如 TypeScript)表达"strategy 只能是 order 或 rotate",再对照
Strategy枚举加rename_all:Rust 这套多给了什么保护?代价是什么? - 把样例配置里 coder 组的成员改成
group/coder(引用它自己):validate()会命中哪个 match 分支?结果是什么? load()里read_to_string那行若去掉.map_err(...)只留?,为什么编译不过?
参考答案
- TypeScript 的字面量联合
'order' | 'rotate'能约束程序内的值;差别在外部边界——TOML 文本进来的那一刻,Rust 的Deserialize把"校验 + 装配"合成一步,进程序后拿到的必是Strategy两个变体之一,match 还受穷尽性保护。代价是多一层注解,变体名与配置拼写要两处联动。 ModelRef::parse把group/coder解析成Ok(ModelRef::Group { .. }),命中第二分支,errors里多一条 "nested groups are not supported";errors 非空使validate()返回Err,load包成ConfigError::Validation,启动失败。jackdaw 的组刻意不嵌套(magpie 最多 8 层)。read_to_string的错误类型是std::io::Error,而load声明返回Result<_, ConfigError>;?要求此处错误类型与函数返回的一致(或存在自动转换,这里没有)。.map_err(|error| ConfigError::Read(path, error))先把io::Error换成ConfigError::Read,?才能直接提前返回。