Rust 中的惰性求值:从语法糖到零成本抽象的完整旅程

本文目标:读完之后,你不仅能写出 lazy 链,还能在 10 行之内把它“拆”成零开销手写代码;更重要的是,你会明白什么时候该用
Iterator,什么时候该用LazyCell,什么时候干脆自己写enum🦀
1. 开场:什么是“惰性”?
在 Rust 社区里,大家常说“迭代器是零成本的抽象”。
但“零成本”≠“零开销”——它只是意味着你不需要为你没用到的东西付费。
惰性求值(Lazy Evaluation)正是这一哲学的核心:计算只在真正需要结果时才发生。
我们先看一个最朴素的例子:
// eager.rs
fn main() {
let v = vec![1, 2, 3, 4, 5];
let doubled: Vec<_> = v.iter().map(|x| x * 2).collect();
println!("{:?}", doubled);
}
上面这段代码在 map 时就立即分配了一个新的 Vec。
如果后续逻辑根本用不到 doubled,那我们就白分配了一次内存。
惰性版本长这样:
// lazy.rs
fn main() {
let v = vec![1, 2, 3, 4, 5];
let doubled = v.iter().map(|x| x * 2); // 类型是 Map<..., ...>
// 这里没有任何内存分配!
if std::env::args().len() > 1 {
// 只有真正迭代时才执行 map 闭包
for x in doubled {
println!("{x}");
}
}
}
2. 从语法糖到 MIR:Iterator 惰性链剖析
2.1 为什么 Iterator 能“零成本”?
我们先写一个更复杂的链,然后把它“拆”成手写的状态机。
use std::iter;
fn main() {
let data = [1i32, 2, 3, 4, 5];
let res = data
.iter()
.copied()
.filter(|&x| x % 2 == 0)
.map(|x| x * x)
.take(2)
.sum::<i32>();
println!("{res}");
}
编译器看到的 MIR(中间层)其实长这样(简化):
let mut iter = data.iter().copied();
let mut iter = Filter { iter, predicate: |&x| x % 2 == 0 };
let mut iter = Map { iter, f: |x| x * x };
let mut iter = Take { iter, n: 2 };
let mut acc = 0i32;
loop {
match iter.next() {
Some(x) => acc += x,
None => break,
}
}
注意:
- 没有一次额外的堆分配;
- 所有中间结构体都在栈上;
- 每个 adaptor 都是一个零大小的
struct,仅携带必要的闭包指针。
这就是典型的“结构化惰性”:每一步都推迟到
next()被调用时才发生。
2.2 自己手写一个 Map adaptor(实践 1)
为了验证上述说法,我们把 Map 从标准库里“抄”出来:
pub struct MyMap<I, F> {
iter: I,
f: F,
}
impl<I, F, B> Iterator for MyMap<I, F>
where
I: Iterator,
F: FnMut(I::Item) -> B,
{
type Item = B;
#[inline]
fn next(&mut self) -> Option<Self::Item> {
self.iter.next().map(&mut self.f)
}
#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
self.iter.size_hint()
}
}
trait MyIteratorExt: Iterator {
fn my_map<B, F>(self, f: F) -> MyMap<Self, F>
where
Self: Sized,
F: FnMut(Self::Item) -> B,
{
MyMap { iter: self, f }
}
}
impl<I: Iterator> MyIteratorExt for I {}
fn main() {
let sum = (0..10)
.my_map(|x| x * 2)
.take(3)
.sum::<i32>();
println!("{sum}"); // 0+2+4 = 6
}
在 release 模式下,MyMap::next 会被 LLVM inline 成一条指令,最终汇编里只剩一个计数器加法。
这验证了:惰性 adaptor 链不仅语法优雅,还能真正跑成手写循环的速度。
3. 当惰性遇到所有权:LazyCell 与 LazyLock
迭代器擅长“流式”惰性,但有时我们想对单个值做惰性初始化。
Rust 1.70 起,标准库引入了 std::sync::LazyLock(nightly 以前叫 once_cell::sync::Lazy)。
3.1 经典场景:读取环境变量一次,到处使用
use std::sync::LazyLock;
static CONFIG_PATH: LazyLock<String> = LazyLock::new(|| {
std::env::var("CONFIG").unwrap_or_else(|_| "/etc/app/config.toml".into())
});
fn main() {
println!("Config path = {}", *CONFIG_PATH);
// 第二次访问不再执行闭包
println!("Again = {}", *CONFIG_PATH);
}
3.2 深入源码:LazyLock 如何保证线程安全?
其核心结构:
pub struct LazyLock<T, F = fn() -> T> {
cell: OnceLock<T>,
init: Cell<Option<F>>,
}
OnceLock<T>保证仅初始化一次;Cell<Option<F>>允许在初始化完成后把闭包置空,避免长期持有资源;- 内部使用
atomic::Ordering处理多线程竞争。
3.3 单线程版本:LazyCell(unstable)
如果你只在单线程使用,可以用 std::cell::LazyCell(nightly)或社区 crate once_cell::unsync::Lazy,性能更高:
#![feature(lazy_cell)]
use std::cell::LazyCell;
thread_local! {
static HEAVY: LazyCell<String> = LazyCell::new(|| {
std::fs::read_to_string("/tmp/big.txt").unwrap()
});
}
fn main() {
HEAVY.with(|s| println!("first 10 bytes: {:?}", &s[..10]));
}
4. 超越标准库:自研按需生成器(实践 2)
有时我们想表达无限的惰性序列,例如斐波那契数列,但不想写 Iterator 的四个方法。
Rust nightly 的 Generator API 可以派上用场。
4.1 手写一个 gen 宏(简化版)
#![feature(generators, generator_trait)]
use std::{
ops::{Generator, GeneratorState},
pin::Pin,
};
fn fib() -> impl Generator<Yield = u64, Return = ()> {
|| {
let (mut a, mut b) = (0, 1);
loop {
yield a;
(a, b) = (b, a + b);
}
}
}
fn main() {
let mut g = Box::pin(fib());
for _ in 0..10 {
match g.as_mut().resume(()) {
GeneratorState::Yielded(v) => println!("{v}"),
GeneratorState::Complete(_) => break,
}
}
}
编译器会把生成器函数转成一个状态机 enum,每个 yield 变成一个状态转移。
这就是协程级别的惰性求值:计算暂停在 yield,直到外部再次 resume()。
5. 性能与内存的权衡:什么时候不该惰性?
惰性并非银弹。下面列出几条“反模式”:
-
副作用泄漏:
如果map闭包里有日志或 I/O,惰性会让调用时机不可预测,导致调试困难。
解决:把副作用提到for循环里,或者使用inspect并写文档。 -
栈深度爆炸:
很长的iter().a().b().c()...链在 debug 模式下会压很多层next,可能触发栈溢出。
解决:release 编译 +inline提示,或手动collect::<Vec<_>>()到一个临时缓冲。 -
缓存命中率下降:
流式迭代对 CPU cache 不一定友好,尤其是随机访问。
解决:使用chunks_exact或array_chunks提前批处理。
6. 综合实战:一个可扩展的惰性计算框架(实践 3)
我们来写一个“惰性表达式树”,支持 +、*、变量替换,但只有在 eval() 时才真正计算。
use std::collections::HashMap;
#[derive(Clone)]
enum Expr {
Num(i32),
Var(String),
Add(Box<Expr>, Box<Expr>),
Mul(Box<Expr>, Box<Expr>),
}
impl Expr {
// 关键:&self 不动,返回新树,保证共享子树
fn subst(&self, env: &HashMap<String, i32>) -> Expr {
match self {
Expr::Num(n) => Expr::Num(*n),
Expr::Var(name) => env.get(name).map(|&n| Expr::Num(n)).unwrap_or_else(|| self.clone()),
Expr::Add(a, b) => Expr::Add(
Box::new(a.subst(env)),
Box::new(b.subst(env)),
),
Expr::Mul(a, b) => Expr::Mul(
Box::new(a.subst(env)),
Box::new(b.subst(env)),
),
}
}
fn eval(&self) -> i32 {
match self {
Expr::Num(n) => *n,
Expr::Var(_) => panic!("unbound var"),
Expr::Add(a, b) => a.eval() + b.eval(),
Expr::Mul(a, b) => a.eval() * b.eval(),
}
}
}
fn main() {
let expr = Expr::Add(
Box::new(Expr::Mul(
Box::new(Expr::Var("x".into())),
Box::new(Expr::Num(2)),
)),
Box::new(Expr::Num(1)),
);
let mut env = HashMap::new();
env.insert("x".into(), 10);
let reduced = expr.subst(&env); // 惰性:没有真正计算
println!("{}", reduced.eval()); // 这里才计算:21
}
你可以继续拓展:
- 把
subst改成返回Cow<Expr>,避免无意义克隆; - 加入
Lazy<Expr>缓存节点,实现记忆化; - 把
eval换成Iterator<Item = i32>,支持逐步求值。
7. 结语:把惰性思维写进 API 设计
Rust 的惰性体系并不是简单的“延迟计算”,而是一套从语言特性(ownership + move)、零大小类型、内联优化到生态 crate 的整体工程。
当你在设计库 API 时,可以问自己三个问题:
- 用户是否可能永远不需要这个值?
→ 考虑返回impl Iterator而非Vec。 - 这个值是否全局只需初始化一次?
→ 使用LazyLock。 - 这个计算是否有副作用且需严格控制时机?
→ 直接求值或提供显式build()方法。
带着这些原则,你就能写出既优雅又高效的 Rust 代码。
祝你享受零成本抽象的魔法!🦀

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)