通过 Rspack 源码学 Rust(三):用 Default 与 std::mem::take 取出字段所有权

上一篇讲到:&mut T 允许修改借来的值,但不表示可以把它的字段直接搬走。本篇从走读笔记中的 CodeSplitter 初始化问题出发,只讲两个相连的知识点:Default 如何提供默认值,std::mem::take 如何用默认值占位,换出旧值的所有权。

一、先看源码:两条分支为什么都能得到 splitter

阅读 build_chunk_graph/mod.rs,会看到这段代码:

rust 复制代码
let enable_incremental = false;
let mut splitter = if enable_incremental {
  std::mem::take(&mut compilation.build_chunk_graph_artifact.code_splitter)
} else {
  Default::default()
};

当前源码把 enable_incremental 固定为 false,上方注释说明启发式增量更新暂时禁用。所以本版本实际走的是创建默认值的分支,不能把 take 分支说成每次构建正在执行的路径。不过这两条分支都要通过 Rust 类型检查,仍然适合拿来理解语言规则。

随后代码让这个局部变量参与构图,最后显式放回:

rust 复制代码
splitter.prepare(&all_modules, compilation)?;
// ......其余构图步骤......
compilation.build_chunk_graph_artifact.code_splitter = splitter;

第一行里的 prepare 同时需要可变的 splitter 和整个 compilation。要理解为什么 splitter 是局部拥有的值,先从 Default 开始。

二、知识点一:Default 是类型约定的默认构造方法

1. Default::default() 是一次方法调用

Default 是标准库提供的 trait,可以先把 trait 理解成"一组由类型实现的方法约定"。它的核心方法返回实现类型自身的值,通常用下面的方式调用:

rust 复制代码
let values = Vec::<String>::default();
let count = usize::default();

这里 values 是空向量,count 是 0。它们都是正常构造出来、满足各自类型约束的值,不是未初始化内存,也不是把任意类型的内存按字节清零。

另一种写法把类型信息放在变量上:

rust 复制代码
let values: Vec<String> = Default::default();

编译器根据左侧类型确定要调用哪个实现。因此 Default::default() 不会在运行时自动判断"应该生成什么类型";它需要编译期上下文。

2. #[derive(Default)] 让结构体按字段构造默认值

Rspack 的 CodeSplitter 定义 带有:

rust 复制代码
#[derive(Clone, Debug, Default)]
pub(crate) struct CodeSplitter {
  // ......字段......
}

本篇只关注其中的 Default。对这种结构体,派生实现会为各字段调用相应类型的默认构造:例如队列字段 queue: Vec<QueueAction> 得到空向量,next_chunk_group_index: u32 得到 0,其他字段使用各自的 Default 实现。

约束是字段类型必须支持所需的默认构造,而不是"结构体里所有业务对象都必须能默认创建"。例如 Vec<QueueAction> 的空向量不需要构造任何元素,因此不要求 QueueAction 自身实现 Default。

下面是一个完整示例,同时展示派生和手写两种形式。保存为 default.rs:

rust 复制代码
#[derive(Debug, Default)]
struct Queue {
    jobs: Vec<String>,
    done: usize,
}

struct BuildOptions {
    workers: usize,
}

impl Default for BuildOptions {
    fn default() -> Self {
        Self { workers: 4 }
    }
}

fn main() {
    let queue: Queue = Default::default();
    let options = BuildOptions::default();
    println!("jobs={}, done={}", queue.jobs.len(), queue.done);
    println!("workers={}", options.workers);
}

运行:

sh 复制代码
rustc --edition=2021 default.rs -o default-demo
./default-demo

输出:

text 复制代码
jobs=0, done=0
workers=4

impl Default for BuildOptions 表示给 BuildOptions 实现这个约定,Self 在这里就是 BuildOptions。手写实现选择了 4,所以"默认值总是零或空"并不成立。

工程上,如果默认状态就是各字段默认状态的组合,可以派生;若字段之间有业务关系,或者默认并发数应该是特定值,就应考虑手写。不要为了能调用 take,随意给一个没有合理默认状态的类型编造默认值。

3. 回到 Rspack:分支的类型在编译期统一

code_splitter 字段的类型是 CodeSplitter。take 这一支返回它的旧值,类型也就是 CodeSplitter。if 作为表达式需要给局部变量产生一个统一的结果类型,因此另一支的 Default::default() 也按 CodeSplitter 来解析。

可以把 else 分支写得更明确:

rust 复制代码
CodeSplitter::default()

这不会改变这里的默认构造含义。源码使用短写是因为类型上下文已经足够明确。

还要留意,Default 并不规定"能执行完整业务"。默认 splitter 只是合法初始状态,后续仍要经过 prepare 等初始化步骤,才能参与实际构图。

三、知识点二:std::mem::take 用新值换出旧值

1. 为什么不能从 &mut 后面直接 move 字段

这段完整程序故意写错,保存为 bad_move.rs:

rust 复制代码
#[derive(Default)]
struct Compilation {
    jobs: Vec<String>,
}

fn extract(compilation: &mut Compilation) -> Vec<String> {
    compilation.jobs
}

fn main() {}

编译器报告:

text 复制代码
error[E0507]: cannot move out of `compilation.jobs` which is behind a mutable reference

Vec<String> 不是 Copy,返回字段需要转移它的所有权。但函数只借用了 compilation,调用方仍拥有整个结构体;如果字段被直接搬走,结构体就会留下一个没有合法值的位置。

可变借用允许写入一个有效新值,不允许让借来的结构体在返回后缺少这个字段。这里需要的是"换出",而不只是"搬走"。

2. take 的输入与返回值分别是什么

忽略标准库实现属性等细节,先用下面这个普通函数签名理解它的类型关系:

rust 复制代码
fn take<T: Default>(dest: &mut T) -> T

T 表示同一个具体类型;T: Default 要求它支持默认构造;dest: &mut T 是待替换位置的可变引用;返回的 T 是替换前的旧值。

对本篇的普通运行时调用,可以把行为理解为依次完成两件事:构造 T 的默认值,将它放进 dest,同时返回原先位于 dest 的值。调用完成时,原位置始终有一个合法值。

例如:

rust 复制代码
let mut jobs = vec![String::from("entry")];
let old_jobs = std::mem::take(&mut jobs);
assert!(jobs.is_empty());
assert_eq!(old_jobs, vec![String::from("entry")]);

old_jobs 拥有原来的向量内容;jobs 变成一个默认空向量。这里没有克隆原向量及其中的字符串,也没有因为 take 就释放原来的元素。原有缓冲区随旧向量一起转移,之后如何使用、何时释放,由新所有者决定。

这不表示所有类型的 take 都是零分配或固定成本:构造默认值的成本取决于该类型的实现,手写 Default 完全可能做分配。对空 Vec 默认值的理解,不能直接套给任意业务类型。

如果类型没有实现 Default,这条路径就不能编译。下面也是一个完整的错误示例:

rust 复制代码
struct Token;

fn main() {
    let mut token = Token;
    let _old = std::mem::take(&mut token);
}

编译器会给出 E0277,指出 Token: Default 的约束未满足。take 不会绕开类型约束来生成一个"临时空壳"。

3. 更实际的价值:分开字段与整个结构体的借用

假设一个 planner 的方法既修改自己,也需要修改 compilation:

rust 复制代码
compilation.planner.run(&mut compilation);

左侧接收者已经可变借用了 compilation.planner,右侧又要可变借用包含这个字段的整个 compilation。这是重叠的可变借用。

以下最小错误程序可以直接交给编译器验证:

rust 复制代码
#[derive(Default)]
struct Planner;

struct Compilation {
    planner: Planner,
}

impl Planner {
    fn run(&mut self, _: &mut Compilation) {}
}

fn main() {
    let mut compilation = Compilation { planner: Planner };
    compilation.planner.run(&mut compilation);
}

结果是 E0499,即不能在此处同时建立这些可变借用。即便 Planner 是没有数据字段的单元结构体,签名仍然表达了互相重叠的借用范围。

如果先把 planner 换出来,它就成了独立的局部变量。take 对字段的那次临时借用结束后,再借用整个 compilation,就不再和这个局部变量重叠。

下面是完整可运行程序,保存为 take.rs:

rust 复制代码
#[derive(Default)]
struct Planner {
    jobs: Vec<String>,
}

#[derive(Default)]
struct Compilation {
    planner: Planner,
    processed: usize,
}

impl Planner {
    fn run(&mut self, compilation: &mut Compilation) {
        compilation.processed += self.jobs.len();
        self.jobs.clear();
    }
}

fn main() {
    let mut compilation = Compilation::default();
    compilation.planner.jobs.push(String::from("entry"));

    let mut planner = std::mem::take(&mut compilation.planner);
    println!(
        "placeholder={}, detached={}",
        compilation.planner.jobs.len(),
        planner.jobs.len()
    );

    planner.run(&mut compilation);
    compilation.planner = planner;
    println!(
        "processed={}, restored={}",
        compilation.processed,
        compilation.planner.jobs.len()
    );
}

运行:

sh 复制代码
rustc --edition=2021 take.rs -o take-demo
./take-demo

输出:

text 复制代码
placeholder=0, detached=1
processed=1, restored=0

输入是一个名为 entry 的任务。第一行证明旧 planner 的任务还在局部变量中,而 compilation 内保存的是空的默认 planner。运行后计数变为 1,旧 planner 的任务被清空,最后赋值语句再把处理过的 planner 移回字段。

注意 take 并没有扩大借用权限。它改变了所有权布局,让两个可变借用指向不同的值,从而满足原本的规则。

四、在 Rspack 中为什么要关注"临时默认状态"

在 CodeSplitter 的增量分支设计中,take 能保留上次 splitter 的状态,同时让 splitter 以局部拥有的方式调用需要 &mut Compilation 的方法。

当前非增量分支则创建新的默认 splitter,也自然具有独立所有权。此时旧字段没有被 take 换掉,不能把它也描述成"已经放入默认占位值"。成功执行到末尾的赋值时,原字段才被替换。

两条分支最终都会让局部 splitter 在完成构图后写回字段,但构图期间 compilation 内的字段状态并不相同。分析源码时,要把语言机制和当前启用的分支分开。

这也带来使用边界:如果局部对象处理期间,别的代码又通过 compilation 去读同一个字段,它看到的是字段当前保存的值,不会自动找到局部变量里的 splitter。类型合法与业务状态合适是两件事;使用这类模式必须确认处理过程中允许字段暂时与局部对象分离。

五、最容易忽略的事:take 不会自动放回

take 只是一次替换操作,不是作用域守卫。示例中的恢复依赖这条显式语句:

rust 复制代码
compilation.planner = planner;

如果在它之前提前返回,字段仍保持当时的占位值;局部对象会按正常所有权规则释放。遇到错误传播符 ? 或 panic,也不能默认认为原对象会被放回。

Rspack 构图步骤里有多次 ?,所以不能仅凭末尾存在赋值,就断言"任何失败路径都恢复旧 splitter"。对于当前固定为 false 的分支,旧字段原本没有被取走;对 take 分支的假设分析,则必须考虑默认占位与错误退出的关系。

这个模式适用于存在合理默认状态、并且允许处理期间分离字段的对象。若业务要求成功或失败都恢复原状态,就要单独设计恢复协议;不要把 take 本身当作事务或自动回滚工具。

六、验证记录与阅读练习

本篇的两个完整正例已在仓库外的临时目录用 rustc 1.95.0、edition 2021 编译运行,输出如上。三个完整反例也已实际编译:直接搬走字段得到 E0507,重叠可变借用得到 E0499,缺少 Default 实现得到 E0277。这只验证这些最小示例,没有执行 Rspack 完整构建。

再读到 std::mem::take(&mut value) 时,可以依次问:旧值交给谁?新默认值是什么?处理过程中谁会读取原位置?旧值最终是否写回,失败时又会发生什么?这几个问题比把它记成"清空变量的快捷方法"更有用。

可以把正例中 Planner 的默认实现改成手写,并给默认任务列表放入一个任务,再观察第一行输出。你会看到 take 留下的是该类型定义的默认值,而不是永远为空的容器。

总结

Default 负责构造类型认可的默认值;std::mem::take 利用这一约定替换一个位置,并交回旧值的所有权。结合 Rspack 的 CodeSplitter,可以看到它既避免克隆原对象,也帮助拆开字段与整个上下文的借用,但默认状态是否合适、失败时是否恢复,仍需由业务代码保证。

简要说明

从 CodeSplitter 初始化讲透 Default 与 std::mem::take,理解默认构造、字段所有权转移和借用边界,并用实际编译结果验证常见错误。

相关推荐
MetWeave1 小时前
风怎么流动:色斑告诉你多大,告诉不了你去哪
前端·数据可视化
Amos_Web1 小时前
Rspack 源码解析(三十三):Module Federation 如何用 RemoteModule 与 ContainerEntryModule 表示远程代
前端
特立独行的猫A1 小时前
仓颉语言实现的Harness: 一口气派三个子代理,cjh 的并发不是“多线程“,是“进程树“
前端·harmonyos
Dumbness1 小时前
农历转换的 4 个边界:2 月 30 日能转出"正月十四"、小月没有三十、闰月、立春到春节之间的干支
前端·javascript
不可能片场1 小时前
工程实践 日志文件名是北京日期 内容却是 UTC
前端·electron
徐小夕1 小时前
JitWord 4.3 发布!Word 内嵌 Visio 图表、原生流程图一键无损解析
前端·vue.js·github
挖掘狂人1 小时前
全栈正在杀死前端?被淘汰的从来不是岗位,只会是执行层
前端·ai编程·全栈
sycmancia1 小时前
Qml——Button元素
前端
codigger2 小时前
前端没有被杀死,被杀死的是"只会把设计稿变成页面"
前端·ai·ai编程·编程语言·全栈·#人工智能