Rust学习笔记1:闭包
Rust学习笔记1:闭包
什么是闭包?
闭包是一种可以捕获调用者作用域变量的匿名函数。它既可以作为值赋值给变量,又可以作为实参传递给函数,它让函数变成了一种数据类型存在。还有,由于闭包不需要进行参数和返回值的类型标注,这可以省却我们不少事情。
下面这是一个好例子,cat的值是一个闭包,它捕捉了变量x。它的参数和返回值都没有类型标注,又没有fn、花括号,代码显得十分简洁。
fn main() {
let x = 100;
let cat = |y| x + y;
assert_eq!(101, cat(1));
}
闭包的语法
这是闭包的正统语法:
|param1, param2...| {
语句1;
语句2;
返回表达式
}
如果只有一行代码,花括号按约定也可以省去:
|param1,param2...| 返回表达式
当把一个闭包赋值给一个变量时,例如下面这个骚气的闭包:
let action = || 3;
此时是闭包赋值给了action,并不是把闭包的执行结果赋值给了它。这一点非常浅显易知,有经验的程序员一定不会犯这样的判断错误。
谨以此例为例,action在执行之后结果仍然为3,它和let action = 3
区别并不大,即使新学者判断失误也没有问题。
类型推导:先入为主
闭包不用标注参数和返回值的类型,编译器会自己推导,但是它的推导有时候会因先入为主而产生“错误”:
fn main() {
let fn1 = |x| x;
let s = fn1(String::from("hi"));
let n = fn1(5);
}
第3行和第4行代码,谁放在后面,谁在编译时报错。**此时决定它们正确与否的关键,只是它们所站的位置不同。**闭包中的参数类型和返回值类型被推导为一种可能后,就会被一直保持为这种可能。后来的会被排斥。
怎么破解?如果我们在下面需要有不同类型参数的调用呢?
解决方法也极其简单:
fn main() {
let s = (|x| x)(String::from("hi"));
let n = (|x| x)(5);
println!("s: {}", s);
println!("n: {}", n);
}
闭包可以现写现用,相同的闭包代码拷贝一份即可。代码虽然还是那个代码,但闭包已经不是那个闭包。
闭包的所有权⭐
现在我们设想这样一个问题:如果我们在结构中使用了闭包,那么Rust对该闭包的所有权是如何规定的呢?
先看一则非常有Rust意味的代码示例:
struct Cache<T,E>
where
T: Fn(E) -> E,
E: Copy
{
query: T,
value: Option<E>,
}
impl<T,E> Cache<T,E>
where
T: Fn(E) -> E,
E: Copy
{
fn new(query: T) -> Self {
Cache {
query,
value: None
}
}
fn value(&mut self, arg: E) -> E {
match self.value {
Some(v) => v,
None => {
let v = (self.query)(arg);
self.value = Some(v);
v
}
}
}
}
在结构体Cache中,query是一个闭包,它的类型是T,约束T的特征是Fn(E) -> E
。
代码很简单,不细讲了,下面开始实践。如果我们在两个结构中实例中引用同一个闭包,会发生什么呢?程序会报错吗?
fn main() {
let a = 1;
let fn1 = |x| x + a;
let mut c = Cache::new(fn1);
println!("{}", c.value(10)); // 11
println!("{}", c.value(11)); // 仍然是 11
let mut d = Cache::new(fn1);
println!("{}", d.value(10)); // 1
println!("{}", d.value(11)); // 仍然是 10
}
c与d是两个Cache实例,它俩同时使用了fn1赋值query,但是程序并没有因为所有权冲突而报错。
Rust中的闭包在所有权这个议题上稍微有点复杂,究竟有没有发生所有权转移,要看闭包捕获的变量是什么情况。没有捕获或捕获的只是栈上的变量,即实现了Copy的变量,那么闭包也在栈上,并没有所有权转移,而是产生了复制(Copy),跟重写一遍闭包代码没有区别。
如何判断闭包是不是实现了Copy特征呢?和其它复杂的类型一样,看闭包捕获的变量是不是都实现了Copy特征,如果是,则闭包也实现了Copy特征;如果否,则不是。
对于否的这种情况,即捕猎的变量是分配在堆上的,大小是动态的,例如String,那么闭包也分配在堆上,这时候对闭包没有实现Copy特征,对它的使用就涉及到所有权转移了。
下面我们做另外一个实验,我们让闭包捕获一个String类型的变量:
#[test]
fn test() {
let a = String::from("LY");
// L的ascii码是76
let fn1 = |x| x + a.chars().next().unwrap() as u8 as i32-75;
println!("a={}", a); // 这行代码会报错
let mut c = Cache::new(fn1);
assert_eq!(c.value(10), 11);
assert_eq!(c.value(11), 11);
println!("a={}", a); // LY
let mut d = Cache::new(fn1);
assert_eq!(d.value(10), 11);
assert_eq!(d.value(11), 11);
println!("a={}", a); // LY
}
这个测试通过了,它没有报错。并且a一直都能打钱。为什么?
这是因为闭包fn1对a的使用,仅是引用,并没有夺取它的所有权。我们如果在闭包前面加上一个move,情况就不一样了。
let fn1 = move |x| x + a.chars().next().unwrap() as u8 as i32-75;
程序不能编译能过了,为什么?因为发生了所有权转移,当a被闭包占有后,后面的代码就不能再使用它了。
move的含义是:不管原来有没有发生所有权转移,加上move一定会发生所有权转移。
三种Fn特征⭐
闭包会捕获调用者作用域中的变量,按照捕获方式不同,及闭包可以执行一次或多次,Rust给闭包分配了三种特征,涵盖所有情况。
1,FnOnce,对待捕获变量的方式是转移所有权,仅能执行一次。如果所有权仅可转移一次,所以闭包也仅能执行一次。
FnOnce特征示例:
fn fn_once<F>(f: F)
where
F: FnOnce(usize) -> bool,
{
println!("{}", f(3));
// println!("{}", f(4));
}
fn main() {
let x = String::from("123");
fn_once(|z| {
let y = x;
z == y.len()
});
// println!("{}", x); // 这里不可用,x已经被move了
}
第6行代码不可执行,因为f作为传递进来的闭包仅可执行一次。第15行代码也不可执行,因为x的所有权已经在第12行被闭包转移给了y。
对于被FnOnce特征约束的闭包,有没有办法执行两次甚至多次呢?
当然有。
当我们给闭包实现Copy特征后,它就可以多次调用了:
fn fn_once<F>(f: F)
where
F: Copy + FnOnce(usize) -> bool,
{
println!("{}", f(3));
println!("{}", f(4));
}
fn main() {
let x = String::from("123");
fn_once(|z| {
let y = x.clone();
z == y.len()
});
println!("{}", x); // 这里的x可以使用了
}
第6行与第15行的代码都可以正常运行了。第6行可以运行,是因为闭包f实现了Copy特征。第15行可以运行,是因为在第12行,y是对x的克隆,x的所有权并没有被转移进来。
在这个示例中,我们虽然对闭包使用了FnOnce特征,但同时还有其他特征加在它的身上,它已经不纯粹了,自然也不能单纯地继续恪守“只能执行一次”的规则了。
2,FnMut,对待捕获变量的方式是可变借用,可执行多次。但这种方式在多线程中不具有安全性。
FnMut示例:
fn main() {
let mut s = String::from("123");
let mut update = |str| s.push_str(str);
update("45");
exec(update, "67");
println!("{:?}", s); // 1234567
}
fn exec<'a, F: FnMut(&'a str)>(mut f: F, s: &'a str) {
f(s)
}
第3行是一个闭包,并且赋值给了变量update。该闭包捕获了变量s,并且是以可变借用的方式捕获并使用的。我们单独执行第4行代码,编译器要求在update前面加上mut修饰符。当闭包要修改其捕获的变量时,在闭包变量前面必须加上mut修饰符。
但是,如果我们单执行第5行代码,第3行中的mut修饰符又可以去掉,为什么?
对闭包使用mut修饰符和应用FnMut特征是两套体系,对于后面,第9行的特征约束已经保证闭包对捕获的变量应用可变借用的方式。这一块实现拿捏不准,可依编译器为准,Rust编译器是Rust程序员的vb
3,Fn,对待捕获变量的方式是不可变借用,可执行多次。这种方式在多线程中具有安全性。
Fn特征示例:
fn main() {
let s = String::from("123");
let update = |str| format!("{}{}", str, s);
update("45".to_string());
exec(update, "67".to_string());
println!("{:?}", s); // 123
}
fn exec<'a, F: Fn(String) -> String>(f: F, s: String) -> String {
f(s)
}
第3行,update闭包对参数str拥有所有权,对捕获的变量s只是不可变借用,无论是独立调用,还是通过exec调用,都没有问题。第6行,s没有变化,输出仍然是“123”。
三种Fn特征之间的继承关系⭐
这是它们的简化版源码:
pub trait Fn<Args> : FnMut<Args> {
...
}
pub trait FnMut<Args> : FnOnce<Args> {
...
}
pub trait FnOnce<Args> {
...
}
从源码可以看出来,三种Fn特征存在继承关系:Fn继承于FnMut,FnMut又继承于FnOnce。如果画一张图,该关系应该是这样的:
Fn → FnMut → FnOnce
从继承关系看,Fn对函数的约束最大,FnOnce最小,从描述上看,确实也是如此:
- FnOnce:转移所有权,调用一次,线程不安全
- FnMut:可变借用,可调用多次,线程不安全
- Fn:不可变借用,可调用多次,线程安全
从上往上看,对闭包的约束越来越强了。转移所有权是Rust的默认执行方式。
三种Fn特征是一种对函数的约束特征,并且这种约束是通过函数本身的代码决定的,Rust编译器也是通过代码进行的类型推断。下面是一种示例,展示了如果是我们人为对函数施加约束,则可能对同一个函数,三种约束都是ok的:
fn main() {
let s = String::from("1234567890");
let update = || println!("{}",s);
exec1(update); // 1234567890
exec2(update); // 1234567890
exec3(update); // 1234567890
}
fn exec1<F: FnOnce()>(f: F) {
f()
}
fn exec2<F: FnMut()>(mut f: F) {
f()
}
fn exec3<F: Fn()>(f: F) {
f()
}
但是,如果我将update的代码修改一下:
let update = || {
let x = s;
println!("{}",x);
};
update转移了变量s的所有权,此时exec2、exec3的调用都不再合适,只有exec1的调用无误,因为exec1的特征约束是FnOnce,与update的代码呈现出来的对变量的使用方式是相同的。一个闭包实现的是三类中的哪种特征,取决于它如何使用捕获的变量。
move与mut
move与mut是语法层面的修饰符,move代表强制转移所有权,作用与FnOnce相当,mut代表闭包对捕获的变量将有修改,是可变借用,作用与FnMut相当。我们不使用Fn特征,使用这两个修饰符有时也能达到类似的语法效果。
mut示例:
fn main() {
let mut s = String::from("123");
let mut update = |str| s.push_str(str);
update("456");
println!("{}", s); // 123456
}
变量s被捕获,在第2行要被修改,所以第2行变量前要加上mut。在第3行,因为闭包对捕获的变量要进行修改,所以在update变量前也要加上mut修饰符。Rust注重内存安全,凡是涉及到内存变动的地方,
move示例:
fn main() {
let s = String::from("123");
let update = move |str| println!("{}{}", s, str);
update("456"); // 123456
// println!("{}", s); // 这里不可运行
}
因为变量s的所有权被消耗了,所以第5行不能运行。
函数如何返回闭包?⭐
Rust是静态语言,函数的参数和返回值必须是固定大小,但是闭包并不是固定大小的。举个例子说明一下,对于下面的闭包:
let s = String::from("hello");
let closure2 = |x| x + s.len(); // 捕获一个 String
在编译器编译后,会生成一个结构体,并实现一个方法:
fn main() {
let c1 = Closure2 { s: String::from("hello") };
let result = c1.call(1); // 调用 call 方法
println!("{}", result); // 输出 6
}
// 手动实现的结构体
struct Closure2 {
s: String, // 捕获的环境变量
}
// 为结构体实现一个普通的 call 方法
impl Closure2 {
fn call(&self, x: i32) -> i32 {
x + self.s.len() as i32
}
}
我们对闭包的调用,相当于第3行对编译后call方法的调用。
由于Closure2中s是一个动态变量,它的大小是不固定的,所以闭包的大小也是不固定的。
有人说,如果闭包捕获的是一个固定大小的变量呢,例如let x = 1
?
即使如此,闭包的大小也是按照“不确定的大小”对待的,至少目前Rust编译器目前是这样做的。
好,我们已经明确,闭包大小不是确定的,那么在函数中返回闭包,我们怎么做呢?
下面是一个函数返回闭包的示例:
fn main() {
let f = factory();
let answer = f(1);
assert_eq!(6, answer); // 通过
}
fn factory() -> Box<dyn Fn(i32) -> i32> {
let num = 5;
Box::new(move |x| x + num)
}
第9行返回了一个使用Box包装的闭包。Box是智能指针,它的大小是固定的,通常需要解引用(*)访问其内容,在第3行并没有解引用直接调用了,这是因为Rust对Box有自动解引用的语法机制。在第3行,(*f)(1)
与f(1)
是相同的。
最后再总结一下,为什么函数直接返回一个闭包不可以,返回使用Box包装的闭包就可以呢?闭包的大小是不固定的,不能作为函数的返回值返回,把它分配在堆上,让Box的指针指向它,Box作为智能指针它的大小是固定的,这样就可以满足Rust编译器的要求了。在许多地方,当Rust编译器要求固定大小,而代码实际情况不满足的时候,Rust都是这样解决的。
2025年3月8日