XiaoboTalk

函数和闭包的语法糖实现

 
 
Rust 语言的函数具有明确的语义,方便静态检查器对其进行编译期检查,Rust 也支持函数指针,类似于 C。但一般情况,Rust 都使用函数项对函数指针进行优化,这样可以做零大小的类型优化。
type Bar = (i32, i32); fn trans(c: &str) -> Bar { (2, 3) } fn show(f: fn(&str) -> Bar) { println!("{:?}", f("test")); } fn main() { let a = trans; // a 为函数项 show(a); let t_a: fn(&str) -> Bar = a; // 显式转为函数指针 show(t_a); // 测量大小 println!("a size: {:?}", std::mem::size_of_val(&a)); // 0 println!("t_a size: {:?}", std::mem::size_of_val(&t_a)); // 8 }
上述代码可以看到,函数项 a 占用的是 0 大小内存空间,而如果显式转为函数指针,则需要占用 8 个字节的内存空间。

闭包

函数虽然够用,但是它不能捕获环境变量,而闭包可以。
fn main() { // 未捕获环境变量 let f1 = || println!("hi!"); f1(); // 修改了外部变量 let mut arr = [1, 2, 3]; let mut f2 = |i| { arr[0] = i; }; f2(12); println!("arr: {:?}", arr); // 访问了外部变量 let an = 13; let f3 = || println!("an is: {:?}", an); f3(); } // 输出: hi! arr: [12, 2, 3] an is: 13
Rust 语言的闭包,没有任何特性,其实是编译期的语法糖实现。|| {} 是闭包的基本语法,|| 为参数列表,后边是闭包体,如果只有一行函数体,{} 可以省略。

闭包是如何通过语法糖实现的

Rust 语言是具有高度一致性的,闭包的实现和所有权保持了高度一致性,Rust 的闭包完全是 Rust 的语法糖实现,其底层实现是基于 struct 和 trait,事实就是三个 trait:
  1. FnOnce —> 所有权转移,只能调用一次
  1. FnMut —> 可变借用 (&mut T),可以对捕获的值做修改
  1. Fn —> 不可变借用,只访问捕获的值
源码定义:
pub trait FnOnce<Args> { type Output; extern "rust-call" fn call_once(self, args: Args) -> Self::Output; } // FnMut 对 FnOnce 做了“继承” pub trait FnMut<Args>: FnOnce<Args> { extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output; } // Fn 对 FnMut 做了“继承” pub trait Fn<Args>: FnMut<Args> { extern "rust-call" fn call(&self, args: Args) -> Self::Output; }
这里说“继承”,意思是实现 FnMut trait 就必须先实现 FnOnce ,实现 Fn trait 就必须先实现FnMutFnOnce 。这是 Rust Trait 的特性。
当定义一个闭包的时候,Rust 检查器,会优先尝试实现一个 Fn Trait,然后是 FnMut,最后是 FnOnce 。这个需要结合例子来看,先从最简单的例子看起:
let f1 = || println!("hi!"); f1(); f1();
上述代码,f1 闭包没有不过任何外部变量,如果去掉语法糖,大致的原始代码如下:
#[derive(Clone, Copy)] struct __closure_0__ {} impl FnOnce<()> for __closure_0__ { type Output = (); fn call_once(self, args: ()) -> () { println!("hi!"); } } impl FnMut<()> for __closure_0__ { fn call_mut(&mut self, args: ()) -> () { println!("hi!"); } } impl Fn<()> for __closure_0__ { fn call(&self, (): ()) -> () { println!("hi!"); } } let f1 = __closure_0__ {}; Fn::call(&f1, ()); Fn::call(&f1, ());
Rust 先定义一个 __closure_0__ 结构体,由于闭包没有捕获任何外部变量,所以结构体不需要任何元素。然后 Rust 按照 Fn → FnMut → FnOnce 的优先级,实现对应的 Trait,由于我们没有对任何变量进行所有权转移,也没有任何外部的可变引用。所以可以实现 Fn Trait,由于 Fn ”继承” 了 FnMutFnOnce ,所以这两个 Trait 也需要实现。最后把
let f1 = || println!("hi!"); f1(); f1();
转换为 Fn 的调用:
let f1 = __closure_0__ {}; Fn::call(&f1, ()); Fn::call(&f1, ());
Fn 和 FnMut 都是可以多次调用的。

接下来,实现一个捕获了外部变量的闭包:
fn main() { let s = String::from("hi"); let f = || println!("{:?}", s); f(); }
去掉语法糖,原始代码如下:
#[derive(Clone, Copy)] struct __closure_1__<'a> { // 'a 是生命周期参数 s: &'a String, // 注意,这里是 &String, 不是 String } // FnOnce FnMut ... 省略实现部分 impl<'a> Fn<()> for __closure_1__<'a> { // type Output = (); fn call(&self, (): ()) -> () { println!("{}", *self.s); } } let s = String::from("hi"); let f = __closure_1__ { s: &s }; Fn::call(&f, ());
由于闭包体中只访问了 s ,所以实际捕获的是 s 的引用:&s ,然后把引用存入到闭包结构体中。最终闭包实际上为 Fn Trait 类型。

如果我们在闭包前加上 move 关键字,将 s 的所有权移入闭包,rust 会做何实现呢?
fn main() { let s = String::from("hi"); let f = move || println!("{:?}", s); f(); f(); }
去掉语法糖,原始实现如下:
#[derive(Clone)] struct __closure_1__ { s: String, // 注意,这次是 String, 不是 &String } // FnOnce FnMut ... 省略实现部分 impl<'a> Fn<()> for __closure_1__<'a> { // type Output = (); fn call(&self, (): ()) -> () { println!("{}", self.s); } } let s = String::from("hi"); let f = __closure_1__ { s: s }; // 这里直接将 s 所有权转移给结构体内部了 Fn::call(&f, ()); Fn::call(&f, ());
move 关键字只是将 s 的所有权转移进了闭包结构体,由于闭包的实现中,依然只是访问 s 的值。所以 Fn Trait 依然能够符合规则,所以依旧实现的是 Fn Trait。记住,Rust 总会按照优先级:
FnFnMutFnOnce
的顺序进行实现,因为:
  • Fn 是最松的规则,即不可变借用(可以多次调用),这里的不可变是指闭包结构体本身,而不是捕获的值。
  • FnMut 是独占借用,但是依然可以多次调用。
  • FnOnce 是转移所有权,理论上只能调用一次,因为 FnOnce 不仅会捕获外部变量的所有权,而且会消费捕获变量的所有权,所以只能调用一次。
再来看一个更抽象的情况,将一个引用变量的所有权转移到闭包里:
fn main() { let s = String::from("hi"); let ps = &s; let f = move || println!("{:?}", ps); f(); f(); }
去掉语法糖,原始实现:
#[derive(Clone, Copy)] struct __closure_1__<'a> { ps: &'a String } // FnOnce FnMut ... 省略实现部分 impl<'a> Fn<()> for __closure_1__<'a> { // type Output = (); fn call(&self, (): ()) -> () { println!("{}", self.ps); } } let s = String::from("hi"); let ps = &s; let f = __closure_1__ { ps: ps }; Fn::call(&f, ()); Fn::call(&f, ());
由于捕获的本身就是一个引用,所以闭包结构体内部,也是&String类型。唯一需要注意的是,引用是有生命周期的,所以用 ‘a 来指明引用的生命周期,关于生命周期不是本节的重点,这里先不做讨论。

接下来我们来看 FnMut 的情况,现在我们实现一个闭包,对外部变量进行修改:
let mut counter: u32 = 0; let delta: u32 = 2; let mut next = || { // 注意 next 闭包本身也需要 mut 修饰 counter += delta; counter }; assert_eq!(next(), 2); assert_eq!(next(), 4); assert_eq!(next(), 6);
去掉语法糖:
struct __closure_4__<'a, 'b> { counter: &'a mut u32, delta: &'b u32 } // ...省略 FnOnce 的实现... impl<'a, 'b> FnMut<()> for __closure_4__<'a, 'b> { // type Output = u32; fn call_mut(&mut self, (): ()) -> u32 { *self.counter += *self.delta; *self.counter } } let mut counter: u32 = 0; let delta: u32 = 2; let mut next = __closure_4__ { counter: &mut counter, delta: &delta }; assert_eq!(FnMut::call_mut(&mut next, ()), 2); assert_eq!(FnMut::call_mut(&mut next, ()), 4); assert_eq!(FnMut::call_mut(&mut next, ()), 6);
这次闭包修改了外部变量 counter,所以需要捕获一个 counter 的可变借用,对 delta 只是访问,所以值需要一个不可变借用,另外 delta 本身也是不可变的值。
相应的,由于闭包结构体自身持有一个 &mut counter ,所以自己也得是可变的,所以需要实现 FnMutFn 已经无法满足规则(Fn 是自身不可变借用)。FnMut 依然可以多次调用。
通过去语法糖,看原始的实现,也就明白了为什么 next 本身也要声明为 mut 。因为闭包本身是结构体,结构体内部的 counter 是可变的借用,所以 next 也必须声明为可变的。不然无法调用 call_mut

最后我们来看看,如果闭包捕获一个所有权,并把捕获变量消费了呢?
fn main() { let s = String::from("hi"); let f = || s; let rs1 = f(); let rs2 = f(); // 编译报错,❌,s 已经被第一次调用消费掉了。 println!("s is: {:?}", s); // 编译报错,❌,s 所有权已经被转移 }
我一直在代码示例中,调两次闭包,目的就是验证是否是 FnOnce 。显然上述闭包代码实现的就是 FnOnce Trait。let f = || s; 这个闭包很简单,捕获外部变量 s,并将它返回。这样最初的 s 所指向的堆内存 “hi” 的所有权已经被转移给 rs1 ,所以闭包不能再次被使用了,同样 s 变量也不能被使用了,这样体现了 Rust 语言的设计一致性;我们来看看去掉语法糖的代码:
#[derive(Clone)] struct __closure_5__ { a: String } impl FnOnce<()> for __closure_5__ { type Output = String; fn call_once(self, (): ()) -> String { self.a // 所有权转移出去 } } let s = String::from("hi"); let f = __closure_5__ { s: s }; // 转移所有权到闭包结构体内部 let rs1 = FnOnce::call_once(f, ()) // 注意这里也是 f 本身的所有权,不是 & // let rs1 = FnOnce::call_once(f, ()),错误代码,f 的所有权已经被转移 // println!("s is: {:?}", s); 错误代码,s 的所有权已经被转移
原始代码并没有显式的调用 move 来移交所有权,但是 Rust 根据规则推断出需要移入所有权。还有些代码的所有权转移会更加隐蔽:
fn main() { let a = vec![0, 1, 2, 3, 4, 5, 100]; // 注意, 没有 `move` let transform = || { let a = a.into_iter().map(|x| x * 3 + 1); a.sum::<u32>() }; println!("{}", transform()); println!("s is: {:?}", a); // 编译报错 ❌, a 所有权已经被转移 }
虽然没有明确的消费 a,但是:
let a = a.into_iter().map(|x| x * 3 + 1);
这一行代码,会把 a 转换为一个迭代器,迭代器是需要消费 a 的。迭代器的实现:
fn into_iter(self) -> Self::IntoIter
第一个参数是 self ,是所有权本身,而不是引用。

逃逸闭包(escaping closures)和非逃逸闭包 (non-escaping closures)

这主要是两个概念,很多编程语言中都有这两个概念,逃逸即指,是否超出作用域:
  • 逃逸闭包:闭包在超出定义它的作用域的地方被调用;常见为函数返回一个闭包
  • 非逃逸闭包:指不能在定义它的作用域之外被调用的闭包。这意味着非逃逸闭包只能在定义它的作用域内部使用,无法被保存或传递到其他函数中。
非逃逸闭包例子:
fn scope_fn() { let f = |x:i32| x+1; // 在 scope_fn 中执行了闭包 f let r = f(2); // 作用域结束,闭包销毁 }
逃逸闭包的例子:
fn scope_fn() -> impl FnMut(i32) -> i32 { let a = 10; move |i| a + i }
move |i| a + i 闭包定义在函数内部,但是被函数作为返回值扔了出去,并没有在函数体内被调用。所以称为逃逸闭包。
(全文完)