19|闭包:FnOnce、FnMut和Fn,为什么有这么多类型?

1,565 阅读11分钟

正式开始

闭包的作用

  1. 作为参数传递给函数;
  2. 作为函数返回值;
  3. 实现某个 trait,使其能表现出其他行为

闭包的定义

闭包是 将函数或者说代码和其环境一起存储的一种数据结构

闭包引用的上下文中的自由变量,会被捕获到闭包的结构中,成为闭包类型的一部分

image.png

在 Rust 里,闭包可以用 |args| {code} 或者 move |args| {code} 来表述

以创建新线程的 thread::spawn为例


pub fn spawn<F, T>(f: F) -> JoinHandle<T> 
where
    F: FnOnce() -> T,
    F: Send + 'static,
    T: Send + 'static,
  1. F: FnOnce() → T,表明 F 是一个接受 0 个参数、返回 T 的闭包

  2. F: Send + 'static,说明闭包 F 这个数据结构,需要静态生命周期或者拥有所有权,并且它还能被发送给另一个线程

    a. 使用了 move 且 move 到闭包内的数据结构满足 Send,因为此时,闭包的数据结构拥有所有数据的所有权,它的生命周期是 'static

  3. T: Send + 'static,说明闭包 F 返回的数据结构 T,需要静态生命周期或者拥有所有权,并且它还能被发送给另一个线程

闭包本质上是什么?

  1. 闭包是一种匿名类型,一旦声明,就会产生一个新的类型
  2. 这个类型无法被其它地方使用
  3. 这个类型就像一个结构体,会包含所有捕获的变量

写代码探索


use std::{collections::HashMap, mem::size_of_val};
fn main() {
    // c1 没有参数,也没捕获任何变量,从代码输出可以看到,c1 长度为 0;
    let c1 = || println!("hello world!");
    // c2 有一个 i32 作为参数,没有捕获任何变量,长度也为 0,可以看出参数跟闭包的大小无关
    let c2 = |i: i32| println!("hello: {}", i);
    let name = String::from("tyr");
    let name1 = name.clone();
    let mut table = HashMap::new();
    table.insert("hello", "world");
    // 如果捕获一个引用,长度为 8
    // c3 捕获了一个对变量 name 的引用,这个引用是 &String,长度为 8。而 c3 的长度也是 8;
    let c3 = || println!("hello: {}", name);
    // 捕获移动的数据 name1(长度 24) + table(长度 48),closure 长度 72
    // c4 捕获了变量 name1 和 table,由于用了 move,它们的所有权移动到了 c4 中。c4 长度是 72,恰好等于 String 的 24 字节,加上 HashMap 的 48 字节
    let c4 = move || println!("hello: {}, {:?}", name1, table);
    let name2 = name.clone();
    // 和局部变量无关,捕获了一个 String name2,closure 长度 24
    // c5 捕获了 name2,name2 的所有权移动到了 c5,虽然 c5 有局部变量,但它的大小和局部变量也无关,c5 的大小等于 String 的 24 字节
    let c5 = move || {
        let x = 1;
        let name3 = String::from("lindsey");
        println!("hello: {}, {:?}, {:?}", x, name2, name3);
    };

    println!(
        "c1: {}, c2: {}, c3: {}, c4: {}, c5: {}, main: {}",
        size_of_val(&c1),
        size_of_val(&c2),
        size_of_val(&c3),
        size_of_val(&c4),
        size_of_val(&c5),
        size_of_val(&main),
    )
}
  1. 不带 move 时,闭包捕获的是对应自由变量的引用

  2. 带 move 时,对应自由变量的所有权会被移动到闭包结构中

  3. 闭包的大小跟参数、局部变量都无关,只跟捕获的变量有关

    a. 参数和局部变量,因为它们是在调用的时刻才在栈上产生的内存分配,说到底和闭包类型本身是无关的

image.png

结合gdb查看

image.png

  1. c3 的确是一个引用,把它指向的内存地址的 24 个字节打出来,是 (ptr | cap | len) 的标准结构。如果打印 ptr 对应的堆内存的 3 个字节,是 ‘t’ ‘y’ ‘r’
  2. c4 捕获的 name 和 table,内存结构和下面的结构体一模一样

struct Closure4 {
    name: String,  // (ptr|cap|len)=24字节
    table: HashMap<&str, &str> // (RandomState(16)|mask|ctrl|left|len)=48字节
}

闭包捕获变量的顺序,和其内存结构的顺序是一致的(在逻辑上是一致的)。但由于Rust编译器会重排内存,所以有些情况下,内存中数据的顺序可能和struct不一致

  1. 调整闭包里使用 name1 和 table 的顺序 let c4 = move || println!("hello: {:?}, {}", table, name1);
  2. 其数据的位置是相反的,类似于

struct Closure4 {
    table: HashMap<&str, &str> // (RandomState(16)|mask|ctrl|left|len)=48字节
    name: String,  // (ptr|cap|len)=24字节
}
  1. 从gdb查看结果

image.png

小总结

  1. 在 Rust 里,闭包产生的匿名数据类型,格式和 struct 是一样的
  2. 闭包是存储在栈上,并且除了捕获的数据外,闭包本身不包含任何额外函数指针指向闭包的代码

不同语言的闭包设计

  1. 大部分编程语言的闭包很多时候无法放在栈上,需要额外的堆分配

  2. 它们闭包的性能要远低于函数调用

    a. 额外的堆内存分配

    b. 潜在的动态分派(很多语言会把闭包处理成函数指针)

    c. 额外的内存回收

Rust性能和函数差不多的原因

  1. 如果不使用 move 转移所有权,闭包会引用上下文中的变量,这个引用受借用规则的约束,所以只要编译通过,那么闭包对变量的引用就不会超过变量的生命周期,没有内存安全问题
  2. 如果使用 move 转移所有权,上下文中的变量在转移后就无法访问,闭包完全接管这些变量,它们的生命周期和闭包一致,所以也不会有内存安全问题
  3. Rust 为每个闭包生成一个新的类型,又使得调用闭包时可以直接和代码对应,省去了使用函数指针再转一次的额外消耗

Rust 的闭包类型

  1. 在声明闭包的时候,我们并不需要指定闭包要满足的约束
  2. 闭包作为函数的参数或者数据结构的一个域时,我们需要告诉调用者,对闭包的约束

FnOnce


pub trait FnOnce<Args> {
    type Output;
    extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

它只能被调用一次

  1. 一个关联类型 Output,它是 闭包返回值的类型
  2. 一个方法 call_once,要注意的是 call_once 第一个参数是 self,它会转移 self 的所有权到 call_once 函数中
  3. FnOnce 的参数,是一个叫 Args 的泛型参数,没有任何约束

简单示例


fn main() {
    let name = String::from("Tyr");
    // 这个闭包啥也不干,只是把捕获的参数返回去
    let c = move |greeting: String| (greeting, name);
    // 闭包内的name发生了转移,所以这里闭包变得不完整,无法再次使用
    let result = c("hello".to_string());

    println!("result: {:?}", result);

    // 无法再次调用
    let result = c("hi".to_string());
}
  1. 如果一个闭包并不转移自己的内部数据,那么它就不是 FnOnce
  2. 一旦它被当做 FnOnce 调用,自己会被转移到 call_once 函数的作用域中,之后就无法再次调用

fn main() {
    let name = String::from("Tyr");

    // 这个闭包会 clone 内部的数据返回,所以它不是 FnOnce
    let c = move |greeting: String| (greeting, name.clone());

    // 所以 c1 可以被调用多次

    println!("c1 call once: {:?}", c("qiao".into()));
    println!("c1 call twice: {:?}", c("bonjour".into()));

    // 然而一旦它被当成 FnOnce 被调用,就无法被再次调用
    println!("result: {:?}", call_once("hi".into(), c));

    // 无法再次调用
    // let result = c("hi".to_string());

    // fn 也可以被当成 fnOnce 调用,只要接口一致就可以
    println!("result: {:?}", call_once("hola".into(), not_closure));
}

fn call_once(arg: String, c: impl FnOnce(String) -> (String, String)) -> (String, String) {
    c(arg)
}

fn not_closure(arg: String) -> (String, String) {
    (arg, "Rosie".into())
}

/*
c1 call once: ("qiao", "Tyr")
c1 call twice: ("bonjour", "Tyr")
result: ("hi", "Tyr")
result: ("hola", "Rosie")
*/

FnMut


pub trait FnMut<Args>: FnOnce<Args> {
    extern "rust-call" fn call_mut(
        &mut self, 
        args: Args
    ) -> Self::Output;
}
  1. FnMut “继承”了 FnOnce,或者说 FnOnce 是 FnMut 的 super trait
  2. FnMut 也拥有 Output 这个关联类型和 call_once 这个方法
  3. 还有一个 call_mut() 方法。注意 call_mut() 传入 &mut self,它不移动 self,所以 FnMut 可以被多次调用
  4. FnOnce 是 FnMut 的 super trait,所以,一个 FnMut 闭包,可以被传给一个需要 FnOnce 的上下文,此时调用闭包相当于调用了 call_once()

举例


fn main() {
    let mut name = String::from("hello");
    let mut name1 = String::from("hola");

    // name使用了引用
    // 如果在闭包 c 里借用了 name,你就不能把 name 移动给另一个闭包 c1
    // 捕获 &mut name
    let mut c = || {
        name.push_str(" Tyr");
        println!("c: {}", name);
    };

    // name1移动了所有权
    // 捕获 mut name1,注意 name1 需要声明成 mut
    let mut c1 = move || {
        name1.push_str("!");
        println!("c1: {}", name1);
    };

    c();
    c1();

    // FnMut 可以被多次调用,这是因为 call_mut() 使用的是 &mut self,不移动所有权。
    call_mut(&mut c);
    call_mut(&mut c1);

    // c 和 c1 这两个符合 FnMut 的闭包,能作为 FnOnce 来调用
    call_once(c);
    call_once(c1);
}

// 在作为参数时,FnMut 也要显式地使用 mut,或者 &mut
fn call_mut(c: &mut impl FnMut()) {
    c();
}

// 想想看,为啥 call_once 不需要 mut?,因为会移动所有权
fn call_once(c: impl FnOnce()) {
    c();
}
/*
c: hello Tyr
c1: hola!

c: hello Tyr Tyr
c1: hola!!

c: hello Tyr Tyr Tyr
c1: hola!!!
*/

Fn

pub trait Fn<Args>: FnMut<Args> {
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}
  1. 它“继承”了 FnMut,或者说 FnMut 是 Fn 的 super trait
  2. 任何需要 FnOnce 或者 FnMut 的场合,都可以传入满足 Fn 的闭包

举个例子


fn main() {
    let v = vec![0u8; 1024];
    let v1 = vec![0u8; 1023];

    // Fn,不移动所有权
    let mut c = |x: u64| v.len() as u64 * x;
    // Fn,移动所有权
    let mut c1 = move |x: u64| v1.len() as u64 * x;

    println!("direct call: {}", c(2));
    println!("direct call: {}", c1(2));

    println!("call: {}", call(3, &c));
    println!("call: {}", call(3, &c1));

    println!("call_mut: {}", call_mut(4, &mut c));
    println!("call_mut: {}", call_mut(4, &mut c1));

    println!("call_once: {}", call_once(5, c));
    // 带有move的c1在作为impl FnOnce之后就无法再使用了
    println!("call_once: {}", call_once(5, c1));
}

fn call(arg: u64, c: &impl Fn(u64) -> u64) -> u64 {
    c(arg)
}

fn call_mut(arg: u64, c: &mut impl FnMut(u64) -> u64) -> u64 {
    c(arg)
}

fn call_once(arg: u64, c: impl FnOnce(u64) -> u64) -> u64 {
    c(arg)
}
/*
direct call: 2048
direct call: 2046
call: 3072
call: 3069
call_mut: 4096
call_mut: 4092
call_once: 5120
call_once: 5115
*/

闭包使用场景

  1. thread::spawn
  2. Iterator中的大部分函数,比如map

fn map<B, F>(self, f: F) -> Map<Self, F>
where
    Self: Sized,
    F: FnMut(Self::Item) -> B,
{
    Map::new(self, f)
}
/*
1. map() 方法接受一个 FnMut,它的参数是 Self::Item,返回值是没有约束的泛型参数 B
2. Self::Item 是 Iterator::next() 方法吐出来的数据,被 map 之后,可以得到另一个结果
*/
  1. 闭包作为返回值

use std::ops::Mul;

fn main() {
    let c1 = curry(5);
    println!("5 multiply 2 is: {}", c1(2));

    let adder2 = curry(3.14);
    println!("pi multiply 4^2 is: {}", adder2(4. * 4.));
}

fn curry<T>(x: T) -> impl Fn(T) -> T
where
    T: Mul<Output = T> + Copy,
{
    move |y| x * y
}
  1. 为闭包实现某个trait

pub trait Interceptor {
    /// Intercept a request before it is sent, optionally cancelling it.
    fn call(&mut self, request: crate::Request<()>) -> Result<crate::Request<()>, Status>;
}

impl<F> Interceptor for F
where
    F: FnMut(crate::Request<()>) -> Result<crate::Request<()>, Status>,
{
    fn call(&mut self, request: crate::Request<()>) -> Result<crate::Request<()>, Status> {
        self(request)
    }
}
/*
1. Interceptor 有一个 call 方法,它可以让 gRPC Request 被发送出去之前被修改,一般是添加各种头,比如 Authorization 头
2. 为了让传入的闭包也能通过 Interceptor::call() 来统一调用,可以为符合某个接口的闭包实现 Interceptor trait
*/

小结

闭包的调用效率和函数调用几乎一致

  1. 闭包捕获的变量,都储存在栈上,没有堆内存分配
  2. 闭包在创建时会隐式地创建自己的类型,每个闭包都是一个新的类型
  3. 通过闭包自己唯一的类型,Rust 不需要额外的函数指针来运行闭包

Rust 支持三种不同的闭包 trait:FnOnce、FnMut 和 Fn

  1. FnOnce 是 FnMut 的 super trait,而 FnMut 又是 Fn 的 super trait
  2. FnOnce 只能调用一次
  3. FnMut 允许在执行时修改闭包的内部数据,可以执行多次
  4. Fn 不允许修改闭包的内部数据,也可以执行多次

image.png

链接

  1. 闭包的定义
  2. Golang闭包的例子
  3. C++ lambda分配在堆上的博客
  4. FnOnce定义
  5. FnMut
  6. Fn trait
  7. Iterator 中的map
  8. gRPC库 tonic
  9. (不完全)模拟 FnOnce 中闭包的使用

精选问答

  1. 下面的代码,闭包 c 相当于一个什么样的结构体?它的长度多大?代码的最后,main() 函数还能访问变量 name 么?为什么?

    
    fn main() {
        let name = String::from("Tyr");
        let vec = vec!["Rust", "Elixir", "Javascript"];
        let v = &vec[..];
        let data = (1, 2, 3, 4);
        let c = move || {
            println!("data: {:?}", data);
            println!("v: {:?}, name: {:?}", v, name.clone());
        };
        c();
    
        // 请问在这里,还能访问 name 么?为什么?
    }
    

    a. 相当于以下数据结构

    struct Closure<'a, 'b: 'a> { 
        data: (i32, i32, i32, i32), 
        v: &'a [&'b str], 
        name: String, 
    }
    
  2. 下面的代码,为啥 call_once 不需要 c 是 mut 呢?

    // 想想看,为啥 call_once 不需要 mut?
    fn call_once(mut c: impl FnOnce()) {
        c();
    }
    

    a. 调用FnOnce的call_once方法会取得闭包的所有权

    b. 对于闭包c和c1来说,即使在声明时不使用mut关键字,也可以在其call_once方法中使用所捕获的变量的可变借用

  3. fn和Fn的区别

    a. fn 和 Fn / FnMut / FnOnce 不是一回事,fn 是一个 function pointer,不是闭包