
记得很多年以前,闭包还是个很新潮的概念,如今已是很多语言里的“标配”了。闭包最初诞生于函数式编程语言中,后来很多语言都纷纷效仿,即使是在 Java 这种天生缺乏函数式编程基因的语言里,也引入了 Lambda 表达式。关于闭包,我们在此前的《一篇关于闭包的杂文:直击闭包产生的根源》中已经做了深入的解读,你会发现,那篇文章所解释的原理同样适用于 Rust 中的闭包。本文将详细了解一下 Rust 中的闭包。真诚推荐本博客近期的两篇精华好文:《你真得懂 Rust 借用检查吗?一文聊透 Borrow Checker 底层逻辑:权限模型》和《深入理解 Rust 生命周期(可能是全网最透彻的解读了)》。
1. 一个简单的闭包
正如我们在《从函数字面量发现函数式编程》中解释的那样:闭包在很多函数式编程语言中一般被称为“函数字面量”。更透彻的解释是:它是一个函数的“值”,就像一个变量,有类型,有值。我们来看一个简单的 Rust 闭包:
fn main() {
let number_one = 6;
let number_two = 10;
let my_closure = |x: i32| println!("{}", number_one + number_two + x);
my_closure(5);
}
|| 包裹的部分对应一个函数的“参数列表”,后面是函数的具体实现,也就是函数体,当有多行代码时可用{}包裹起来。在很多函数式编程语言里,展示闭包最经典的地方就是 map 函数,我们看一下 Rust 是怎么做的:
fn main() {
let num_vec = vec![2, 4, 6];
let double_vec = num_vec // take num_vec
.iter() // iterate over it
.map(|number| number * 2) // for each item, multiply by two
.collect::<Vec<i32>>(); // then make a new Vec from this
println!("{:?}", double_vec);
}
上述代码中的 map 函数会应用一个闭包,这个闭包将集合中的每个元素乘以 2,然后通过 collect 将修改后的新 Vec 返回。这是一套很“函数式”的集合操作,有函数式编程背景的人会觉得非常自然。
2. 惰性求值
当没有函数式编程背景的程序员在函数式编程语言里操作集合类型,或者是使用 Spark 处理大数据集时,时常会被一个问题困惑:我已经写好了全部的处理逻辑,各种 map、filter 全上了,但就是没结果,程序也不报错,在 map、filter 的闭包里插入一些打印语句,才发现它们压根没有执行!这其实是使用闭包时会特意追求的一种“效果”,叫:Lazy Evaluation(惰性求值)。注意:惰性求值并不是闭包的特性,而是因为使用了闭包,惰性求值才成为了可能。惰性求值其实是函数式编程思想最求的一种“效果”。看下面的例子:
fn main() {
let num_vec = vec![10, 9, 8];
num_vec
.iter()
.enumerate()
.map(|(index, number)| println!("Index number {} has number {}", index, number));
}
在上述代码中:
-
let num_vec = vec![10, 9, 8] 是一个 Vec<i32>
-
.iter() 之后是一个 Iter<i32>,这是一个迭代器,其元素类型为 i32
-
.enumerate() 之后是一个 Enumerate<Iter<i32>> 类型,它是用 Enumerate 包裹迭代器后的产物
-
.map() 则是一个 Map<Enumerate<Iter<i32>>> 类型,它是用 Map 类型再次包裹后的结构
以上几个连续操作只是在“堆砌”各种计算“逻辑”,然后层层包裹出一个越来越复杂的结构,最终得到一个 Map<Enumerate<Iter<i32>>>,而不是在每一步真的去执行它所声明的逻辑。只有当我们告诉它要做什么的时候,它才会去做。Rust 这样做是因为它需要保证足够快。它不想每执行一种操作就迭代一次,Rust 只想做一次计算,所以它创建结构并等待。这其实是函数式编程的一种“思维”,即:在函数式编程中,一次“计算”可以表示为一个“求值表达式”(也就是“函数字面量”、“闭包”),对应于一个传统的函数来说,这相当于把一个函数抽离成“类型”和“值”两部分(关于这一点,参考《从函数字面量发现函数式编程》一文),这样,一次“计算”(函数)就像一个普通的“值”一样赋给一个变量或作为参数传给一个函数,亦或是多次“计算”进行“组合”(就像普通值做“加法”),但这都不会导致它们被“执行”,只是关于计算的层层描述,直到需要执行时再一次性执行掉!这是很“厉害”的一点,也是函数式编程与命令式编程一个极大的不同之处。
我们可以容易地理解这种处理方式的“高级”之处,如果每一个操作都是立即执行,那必将产生对集合进行多次迭代,也会产生大量的中间结果:
map -> 迭代一遍,产生中间结果 -> filter -> 迭代一遍,产生中间结果 -> map -> 迭代一遍,产生中间结果
在效率上完全无法和函数式的处理方式相比,因为,函数式风格是把所有的操作“组合”成一个操作,只迭代一遍就结束了!这就是 Lazy Evaluation(惰性求值)所追求的效果。
3. 自由变量
最后,我们研究一下所有闭包都会涉及的“自由变量”问题。正如我们在相关文章中解释的那样:闭包之所以会有“自由变量”,关键原因是:在函数式编程里,函数字面量(也就是闭包)本身可以作为值来使用,函数的定义变得与定义普通变量无异,都是 let 变量名 = 函数字面量(闭包) 的形式。既然普通变量在赋值时可以引用另一个变量的值,那么定义函数字面量(闭包)时,引用其他变量也变成了非常自然的事情,这些“被引用”的变量就是“自由变量”。看下面的例子:
fn main() {
let factor = 10;
let multiply = |x| x * factor;
println!("{}", multiply(5));
}
闭包 multiply 在定义过程中引用到了它之外的一个变量 factor,我们在相关文章中解释过,这种情形是不可能发生在非函数式编程语言里的,因为在非函数式编程语言里,如果一个函数想要引用函数体外的一个变量,只能以参数形式传入函数体中或定义为全局变量,是绝不可能在函数定义过程中如此“轻易”地把一个外部变量“拉”到函数体里的。但是在函数式编程里,这种做法却很简单而自然,就像我们在定义一个变量 let b = a + 1 时,可以很容易地在定义 b 时引入对另一个变量 a 的依赖一样。
3.1 被捕获的自由变量的值是什么时候的值?
一方面,我们已经知道一个闭包的定义和执行是两个不同的“时间点”,再加上“惰性求值”机制,闭包的执行时机可能会更晚,这就不免引出一个问题:因为变量的值可能会发生变化,那么在一个闭包中出现的自由变量,在执行时它的值是定义闭包时的值还是执行闭包时的值呢?
这个问题在 Rust 之外的语言里情况会相对简单一些。我们以 Scala 为例,它的闭包捕获的是对变量的“绑定”(binding),也就是说,闭包中的自由变量的值是闭包执行时的值,而非定义时的值。看下面的 Scala 示例:
var x = 10
val f = () => println(x)
x = 20
f()
代码打印出的 x 的值是 20 而非 10,这验证了我们上面的说法。但是,在 Rust 中,情况就要复杂一些了,因为在 Rust 中除了值还有引用,并且值还有所有权问题,后面我们将分情况讨论。
3.2 在 Rust 的闭包中捕获“值”
如果 Rust 闭包引用的自由变量是一个“值”,那么需要注意的是,在变量被闭包使用的那一刻,就会发生“所有权转移”,这发生在闭包定义时。因为所有权转移的判定发生在编译期,所以当闭包定义时,转移就已经发生了。看一个例子吧:
fn main() {
let s = String::from("hello");
let f = || {
let t = s;
println!("{:?}", t);
};
f();
// println!("{}", s); // 报错:borrow of moved value: `s`
}
上面例子中把 s 赋给 t 时就发生了所有权转移,因此在最后一行打印 s 时就会报错。这个例子展示的是:在闭包中,对于值的处理与在 Rust 中其他地方处理值的行为是一致的。闭包中发生的所有权转移是在闭包定义时(基于静态代码分析),而不是在闭包执行时。
以上的“按值捕获”是编译器自动推导出来的,Rust 还支持一种显式指定的“强制按值捕获”:move 闭包。顾名思义,它强制将闭包内使用到的值的所有权从闭包外移动到闭包内使用它的地方,这也意味着在闭包结束时,这个值也被消耗(consume)掉了。它是这样书写的:
fn main() {
let s = String::from("hello");
let f = move || {
println!("{}", s);
};
f();
}
3.3 在 Rust 的闭包中捕获“引用”
在闭包中直接使用一个值并不常见,更多的是使用引用,那么闭包又是如何捕获引用的呢?我们分成不可变引用和可变引用两种情况分别讨论一下。
3.3.1 捕获不可变引用
实际上,当闭包中的代码引用了一个自由变量时,从借用规则上讲,这和普通本地代码中对一个值的引用是一样的,也要遵守借用规则。只不过,因为闭包可能会推迟执行,而它捕获的是引用,所以实际执行时使用到的值就是执行时刻引用所指向的值。不过,这一点在被捕获的不可变引用上体现不出来,因为它的值不允许改变。看一个例子:
fn main() {
let x = 5;
let f = || {
println!("{}", x);
};
f();
}
上面的闭包 f 捕获了自由变量 x 的不可变引用:println! 的第二个参数。为验证闭包中的引用同样要遵守借用检查规则,我们可以这样测试一下:
fn main() {
let mut x = 5;
let f = || {
println!("{}", x);
};
// let r = &mut x; // 报错:cannot borrow `x` as mutable because it is also borrowed as immutable
f();
}
我们让闭包先借出一个 x 的不可变引用,如果取消第 6 行的注释,尝试再借出一个 x 的可变引用 r,这时就会报错。
3.3.1 捕获可变引用
如果我们在闭包中借用了一个自由变量的可变引用,那如前面说的,它同样要受借用检查的约束。看一个例子:
fn main() {
let mut s = String::from("hello");
let mut f = || {
s.push_str("world!");
};
// let r = &s; // 报错:Cannot borrow `s` as immutable because it is also borrowed as mutable
f();
}
我们让闭包先借用 s 的一个可变引用,如果取消第 7 行的注释,尝试再借用 s 的一个不可变引用 r,这时候就会报错。
3.4 重新梳理:在 Rust 被捕获的自由变量的值是什么时候的值?
尽管 3.1 节我们简单解释了其他语言的处理方式,并说明在 Rust 里要按不可变引用、可变引用和值的形式分别讨论,那么在讨论完后,我们重新回顾一下这个问题。首先,对于不可变引用来说,因为是不可变引用,所以直到闭包执行时,借用的值始终是不可变的,因此体现不出差别;对于可变引用来说,由于只能借出一个可变引用,我们也无法在别处修改这个值,因此也体现不出差别;而值的 move 是在编译期就被判断出来的,不会推迟到执行阶段,因此也体现不出差别。真正能体现出 Rust 捕获的自由变量是定义时的值还是运行时的值的例子,还得通过智能指针来看,例如:
use std::cell::Cell;
fn main() {
let x = Cell::new(10);
let f = || {
println!("{}", x.get());
};
x.set(20);
f(); // 输出 20
}
这个示例就跟前面那个 Scala 示例有点像了,第 9 行 f() 输出的是 20。这个例子也能反映出“惰性求值”的特性。从这个示例中我们也能体会到:闭包仅仅是一个“求值表达式”,是一个函数字面量,它所引用到的各种自由变量,最终还是闭包执行的那个时刻的值。尽管在 Rust 中有值、可变引用和不可变引用之分,但基于前面的分析我们也看到了,在这一点上它们其实体现不出差异。通过智能指针可以窥见 Rust 的处理方式,其本质与其他函数式编程语言的处理方式是一致的。

