上一篇用
Option<T>表达查询可能没有结果。如果操作失败时还需要说明原因,就要读懂Result<T, E>。走读笔记中的build_chunk_graph返回rspack_error::Result<()>,函数里又连续出现?。本篇只围绕这两个知识点展开:返回值怎样区分成功和错误,问号怎样把错误交给调用者。
一、源码入口:一串问号代表怎样的执行顺序
在 build_chunk_graph/mod.rs 中,函数签名是:
rust
pub fn build_chunk_graph(compilation: &mut Compilation) -> rspack_error::Result<()> {
下面是函数中连续的一段原始代码:
rust
splitter.prepare(&all_modules, compilation)?;
splitter.update_with_compilation(compilation)?;
if !enable_incremental || splitter.chunk_group_infos.is_empty() {
let inputs = splitter.prepare_input_entrypoints_and_modules(&all_modules, compilation)?;
splitter.prepare_entries(inputs, compilation)?;
}
splitter.split(compilation)?;
// remove empty chunk groups
splitter.remove_orphan(compilation)?;
函数后部还有给模块登记 chunk graph 信息的循环,然后才执行:
rust
compilation.build_chunk_graph_artifact.code_splitter = splitter;
Ok(())
这些语句不是"把所有错误收集起来,最后统一检查"。每个 ? 都可能直接结束当前函数;只有走完前面的步骤,才能到达后部的赋值和 Ok(())。
二、知识点一:Result 同时描述成功值与错误值
1. 两种可能,以及两个类型参数
标准库 Result 的核心形态可以这样理解:
rust
enum Result<T, E> {
Ok(T),
Err(E),
}
T 是成功时携带的值的类型,E 是失败时携带的错误的类型。Result<u32, String> 表示要么得到 Ok(42) 这样的整数结果,要么得到 Err(String) 这样的错误信息,不会同时包含两者。
Ok、Err 是枚举变体,Ok(42) 中的括号用于构造变体。常见写法可以直接使用标准库预导入的 Result、Ok、Err,不需要自己定义上面的枚举。
调用者拿到的仍是一个 Result,不能把它直接当成 u32 相加。它必须通过 match 等方式处理两条分支,或者在允许返回错误的函数里用 ? 继续传播。
与上一篇作一个最小比较:Option<u32> 的 None 不携带原因;Result<u32, E> 的 Err(error) 可以让调用者知道具体发生了什么。表达自然缺失时用 Option 很合适;需要报告解析、IO、构建失败原因时,Result 更合适。
2. Result<()> 为什么只有一个参数
rspack_error/src/lib.rs 定义了这个类型别名:
rust
pub type Result<T, E = Error> = std::result::Result<T, E>;
可以分两步读:Result 是标准库类型的别名;当调用者省略 E 时,默认使用 rspack_error::Error。所以:
text
rspack_error::Result<()>
= std::result::Result<(), rspack_error::Error>
别名没有创建一种新的 Result,也没有自动转换任何错误。它让各个 crate 的返回签名少写一遍项目错误类型,同时保留显式指定 E 的能力。
这里的 () 读作 unit,表示成功分支没有额外业务数据。它既是类型的写法,也是该类型唯一的值的写法:
rust
Ok(())
外层 Ok(...) 构造成功变体,内部 () 是成功值。Ok(()) 依然明确表示成功,不能和"没有结果"的 None 混为一谈;也不能写成 Ok(),因为 Ok 需要一个参数。
对 Rspack 而言,构图结果已经写入 Compilation,无需再从返回值传出一份图;Result<()> 专门告诉上层这次操作是否成功。
三、知识点二:问号解包成功值,提前返回错误
1. 从一行语法展开控制流
先看这行解析:
rust
let count = text.parse::<u32>()?;
text.parse::<u32>() 返回 Result<u32, std::num::ParseIntError>。::<u32> 是显式告诉解析方法目标数字类型;本篇重点在它后面的问号。
若当前函数的错误类型同样是 ParseIntError,上面这行的控制流等价于:
rust
let count = match text.parse::<u32>() {
Ok(value) => value,
Err(error) => return Err(error),
};
成功时,count 绑定内部的 u32,函数继续往下执行。失败时,当前函数立即把错误返回给调用方,后面的语句不再执行。
因此,Rspack 的 let inputs = ...?; 得到的是成功分支的输入数据,变量 inputs 本身不是 Result;没有绑定变量的 prepare(...)?; 也同样检查失败,只是成功值没有继续使用。
2. 谁能接住返回的错误
对这里的 Result 用法,包含 ? 的函数或闭包需要有兼容的错误返回类型。? 不是让错误跨过任意多层调用:它先结束当前函数,再由直接调用者决定怎么处理。
当内层错误是 E1、当前函数错误是 E2 时,还需要能够通过 From<E1> 转成 E2。所以更一般的 Result 错误分支相当于 return Err(From::from(error))。本篇示例保持两边错误类型一致,先看清控制流;换了错误类型却没有转换规则,编译器会拒绝。
同样,虽然上一篇的 Option 也支持 ?,但不能在返回 Result 的函数中直接对 Option 使用 ?。Option 的 None 没有错误原因,通常要先显式转换成带错误值的 Result。问号不会替开发者决定"缺失应该对应什么错误"。
四、最小可运行示例:成功继续,失败交回调用者
下面的程序只使用标准库,完整覆盖 Result 的返回、? 的解包和外层处理:
rust
use std::num::ParseIntError;
fn read_count(text: &str) -> Result<u32, ParseIntError> {
println!("开始解析 {text:?}");
let count = text.parse::<u32>()?;
println!("完成解析");
Ok(count)
}
fn main() {
for text in ["42", "oops"] {
match read_count(text) {
Ok(count) => println!("成功:{count}"),
Err(error) => println!("失败:{error}"),
}
}
}
保存为 result_demo.rs 后执行:
sh
rustc +stable --edition=2021 result_demo.rs -o result_demo
./result_demo
本篇使用 rustc 1.95.0 的 stable 工具链实际编译运行,输出:
text
开始解析 "42"
完成解析
成功:42
开始解析 "oops"
失败:invalid digit found in string
第一轮输入 "42",parse 返回 Ok(42),问号取出整数,继续打印"完成解析",再由 Ok(count) 把结果交给 main。
第二轮输入 "oops",parse 返回 Err(ParseIntError),问号立即返回。因此没有第二次"完成解析";main 的 Err 分支负责打印原因。
这里没有 panic!,也没有 .expect()。无效输入是一条普通、可处理的返回路径;main 选择处理以后,程序仍正常运行到结束。上篇固定常量示例中的 .expect() 若用于外部输入,就可能把这种可处理失败变成 panic。
五、编译器为什么不允许随处写问号
下面故意写一个错误程序:
rust
fn main() {
let count = "42".parse::<u32>()?;
println!("{count}");
}
同样用 stable、edition 2021 编译,编译器报 E0277 :这个 main 返回 (),没有适合接收 Result 错误的返回类型。
即使字符串恰好是可解析的 "42",类型规则也不会因此放宽。编译器检查的是表达式的返回类型和潜在失败路径,不是开发者对这个常量的口头保证。
可以采用上一节 main 的 match,在当前层处理错误。另一种选择是让 main 返回 Result<(), ParseIntError>,并在结尾写 Ok(()),这样错误就能从 main 返回。本篇的完整程序采用前一种方式,便于明确展示究竟在哪一层打印错误。
? 也不是 unwrap() 的简写:前者要求兼容的返回类型并交回错误,后者在 Err 时 panic。两者的控制流和适用边界不同。
六、回到 Rspack:提前返回不会自动回滚
理解 ? 后,要再读一次源码中的语句顺序。假如 splitter.split(compilation)? 返回 Err:
- 当前
build_chunk_graph返回错误。 - 后面的
remove_orphan、模块登记循环和 splitter 放回操作不会执行。 - 先前已经通过
&mut Compilation写入的状态,不会因为问号自动恢复。
局部拥有的值在正常错误返回时会按 Rust 的作用域规则析构;但"析构局部 splitter"和"撤销它曾写入 Compilation 的数据"完全是两件事。问号只负责返回控制流,不提供事务、重试或恢复策略。
也要结合当前版本判断:函数把 enable_incremental 固定为 false,实际创建默认 splitter,不能声称本版本每次都先从 artifact 中 take 旧 splitter。无论初始化走哪条分支,前面某个 ? 提前返回都会跳过末尾赋值。
项目中也有刻意推迟问号的写法。阅读 finish_modules/mod.rs 的 finish_modules_pass,可见以下连续片段:
rust
let diagnostics = compilation
.finish_modules_inner(
&mut dependencies_diagnostics_artifact,
&mut async_modules_artifact,
&mut exports_info_artifact,
)
.await;
compilation.dependencies_diagnostics_artifact = dependencies_diagnostics_artifact.into();
compilation.async_modules_artifact = async_modules_artifact.into();
compilation.exports_info_artifact = exports_info_artifact.into();
let diagnostics = diagnostics?;
compilation.extend_diagnostics(diagnostics);
先暂存 Result,把三个取出的 artifact 放回,再使用 ?。这样普通 Err 返回之前,放回语句已经执行;若直接写 .await?,就可能在放回之前离开函数。这里的 async 语法暂时只需知道 .await 得到调用结果,错误分支仍遵守相同的问号规则。
这不是回滚:放回的是可能已经被修改的 artifact,不是旧状态快照;也不能据此保证 panic 时会执行后面的赋值。这个对照只说明,错误返回之前必须完成的事情,要安排在问号之前。
下面用不依赖 Rspack 的最小程序验证"错误返回不会撤销先前修改":
rust
use std::num::ParseIntError;
fn update(log: &mut Vec<&'static str>, text: &str) -> Result<(), ParseIntError> {
log.push("开始");
let _count = text.parse::<u32>()?;
log.push("完成");
Ok(())
}
fn main() {
let mut log = Vec::new();
let result = update(&mut log, "oops");
println!("失败:{}", result.is_err());
println!("日志:{log:?}");
}
实际编译运行,输出:
text
失败:true
日志:["开始"]
日志向量由 main 持有,update 借用它并先追加一项。解析失败时只跳过后续操作,不会把已经追加的"开始"删掉。生产代码若要求失败后保持原状态,需要另行设计先验证再提交、临时状态或明确的恢复逻辑。
总结
读 Result<T, E> 时,先确定成功数据和错误类型;读 ? 时,找到它所在函数的返回类型,以及它之后哪些语句可能被跳过。Rspack 用统一的错误别名减少签名重复,用问号连接构建步骤,但恢复状态、补充上下文和最终报告错误仍由具体调用层负责。
简要说明
从 Rspack 构图函数学习 Result<T, E>、Result<()> 与 ?,通过实测理解成功解包、错误提前返回,以及问号不会自动回滚状态。