通过 Rspack 源码学 Rust(五):读懂 Result 与问号运算符的错误返回

上一篇用 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:

  1. 当前 build_chunk_graph 返回错误。
  2. 后面的 remove_orphan、模块登记循环和 splitter 放回操作不会执行。
  3. 先前已经通过 &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<()> 与 ?,通过实测理解成功解包、错误提前返回,以及问号不会自动回滚状态。

相关推荐
杨航 AI36 分钟前
JEV的用法
java·前端·数据库
Amos_Web36 分钟前
Rspack 源码解析(三十五):rspack_error 如何用 miette 渲染带源码定位的错误链
前端
修炼的dance42 分钟前
OCR预处理测了108组,纠斜去噪有用,坑在顺序和力度
前端
不可能片场1 小时前
AI视频成片 帧间差分把拼接缝照出来
前端·electron
写的都是BUG1 小时前
APK 里的钥匙,一直就放在门口:聊聊加固真正该做什么
前端·算法
dpharness1 小时前
我踩过的四个记忆坑,从预算到换机
前端·后端
用户7783366132111 小时前
SerpBase驱动:用原生JS实现搜索意图分流浮层
前端
阿明的小蝴蝶1 小时前
设备出厂横竖屏修改 & 默认手势导航修改(按键导航)
前端
SameX1 小时前
ArkTS 做应用内"快捷胶囊" —— 续航日记踩过的两个鸿蒙声明式 UI workaround
前端