今天来学习rust中的闭包。

rust中的闭包实际上是一个匿名函数,这个匿名函数可以被赋值给一个变量,可以作为参数传递给函数,可以作为函数返回值被返回,也可以为它实现某个trait,使其表现出其他行为。 在rust中可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获调用者作用域中的变量。

1.闭包的定义

在rust的reference中给出了闭包的定义:

  • 闭包是由闭包表达式产生的匿名类型,每声明一个闭包都产生一个唯一的、匿名的类型。
  • 可以将闭包理解成一种特殊的数据结构,闭包周围的作用域被称为其环境,闭包将其代码和环境存储在一起。
  • 闭包中包含了它所捕获的其环境中的变量。

闭包定义的具体链接地址是https://doc.rust-lang.org/reference/types/closure.html

rust会为每个闭包生成一个唯一的,新的匿名类型,这个匿名类型在内存中的排布和结构体挺相似的。

例1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
fn f<F: FnOnce() -> String>(g: F) {
    println!("{}", g());
}

fn main() {
    let mut s = String::from("foo");
    let t = String::from("bar");

    f(|| {
        s += &t;
        s
    });
    // Prints "foobar".
}

上面例1第9~12行声明了一个闭包,并将其作为参数传给f函数,调用了f函数。

例1中产生的闭包类型大致和下面例2的伪代码中的Closure结构体相似。

例2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
fn f<F: FnOnce() -> String>(g: F) {
    println!("{}", g());
}

struct Closure<'a> {
    s: String,
    t: &'a String,
}

impl<'a> FnOnce<()> for Closure<'a> {
    type Output = String;
    fn call_once(self) -> String {
        self.s += &*self.t;
        self.s
    }
}

fn main() {
    let mut s = String::from("foo");
    let t = String::from("bar");

    f(Closure { s: s, t: &t });
}

例1中的比高代码相当于,例2中的伪代码,闭包类型相当于一个结构体,其所捕获的变量,闭包所捕获的变量相当于结构体中的字段。 闭包的代码相当于结构体对三个Fn trait的实现代码。闭包相关的三个Fn trait后边会介绍。

2.闭包类型占用内存的大小

如果把闭包理解成一个结构体,闭包所捕获的变量相当于结构体中的字段,则闭包的大小就和闭包参数,闭包代码的局部变量都没有关系,而只与闭包从其环境中捕获的变量有关。 下面我们尝试打印一下不同的闭包占用内存大小。

例3:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
use std::mem::size_of_val;

fn main() {
    // 闭包1, 没有参数, 没有捕获任何变量
    // 闭包1相当于 struct{}
    let c1 = || {};
    println!("size_of_val(&c1) = {}", size_of_val(&c1)); // size_of_val(&c1) = 0

    // 闭包2, 有1个参数, 没有捕获任何变量, 闭包的代码中有一个i32的局部变量num
    // 闭包2相当于 struct{}
    let c2 = |x: i32| {
        let num = 3;
        num * x
    };
    println!("size_of_val(&c2) = {}", size_of_val(&c2)); // size_of_val(&c2) = 0

    let num: i32 = 3;
    let x: i32 = 4;
    // 闭包3, 没有参数, 捕获了其环境中的num和x两个i32变量的引用,即&num和&x
    // 闭包3相当于 struct{num: &i32, x: &i32}
    let c3 = || num * x;
    println!("size_of_val(&c3) = {}", size_of_val(&c3)); // size_of_val(&c3) = 16

    let num: i32 = 3;
    let x: i32 = 4;
    // 闭包4, 没有参数, 捕获了其环境中的num和x两个i32的变量, move表示num和x的所有权转移到闭包中
    // 闭包4相当于 struct{num: &i32, x: &i32}
    let c4 = move || num * x;
    println!("size_of_val(&c4) = {}", size_of_val(&c4)); // size_of_val(&c4) = 8
}

例3中我们声明了c1, c2, c3, c4四个闭包, 它们是4个不同的匿名类型,它们在内存中的排布与结构体类似,结构体的字段为闭包所捕获的变量:

  • 闭包c1没有捕获其环境中任何变量,所以c1就相当于struct{}, 打印闭包大小size_of_val(&c1) = 0
  • 闭包c2没有捕获其环境中任何变量,所以c2就相当于struct{}, 打印闭包大小size_of_val(&c2) = 0
  • 闭包c3捕获了num和i32两个变量, 注意这里的捕获方式是以不可变引用形式捕获的, 即闭包内是&num&x两个不可变引用。所以c3就相当于struct{num: &i32, x: &i32}在64位机器上各占用8个字节,因此打印闭包大小size_of_val(&c3) = 16
  • 闭包c4捕获了num和i32两个变量, 注意这里闭包参数列表前的move关键字表示捕获方式是强制以获取所有权的方式捕获,因此闭包内部捕获的是numhex。所以c4就相当于struct{num: i32, x: i32},因此打印闭包大小size_of_val(&c4) = 8

从前面对例3代码的分析,能够在编译时确认闭包类型所占内存大小时,闭包将会被存储在栈上。

关于闭包捕获其环境中的变量的捕获方式,后边会介绍

3.Fn、FuMut、FnOnce traits

当将闭包作为函数的参数或者数据结构(例如结构体)中的某个字段时,需要指定闭包需要满足的约束,需要使用trait bounds告诉调用者闭包的约束。 为此rust标准库中提供了FnFuMutFnOnce三个trait,所有的闭包至少实现了它们中的一个。

注意rust中的函数也实现了这三个trait。

以下是这三个trait的定义:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
// std::ops::Fn
pub trait Fn<Args>: FnMut<Args> {
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

// std::ops::FnMut
pub trait FnMut<Args>: FnOnce<Args> {
    extern "rust-call" fn call_mut(
        &mut self, 
        args: Args
    ) -> Self::Output;
}

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

从这三个trait的定义上看,Fn继承了FnMut, FnMut继承了FnOnce,既然所有的闭包至少实现了它们中的一个,根据这个继承关系可以推断出所有闭包一定都实现了FnOnce。 从三个trait的继承关系还能看出,任何需要FnOnce或者FnMut的场景,都可以传入满足Fn的闭包。

例4:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
fn main() {
    let x = 3;
    let square = || x * x;
    println!("{}", square());

    foo1(square);
    foo2(square);
    foo3(square);
}

fn foo1<F>(f: F)
where
    F: Fn() -> i32,
{
    println!("{}", f());
}

fn foo2<F>(mut f: F)
where
    F: FnMut() -> i32,
{
    println!("{}", f());
}

fn foo3<F>(f: F)
where
    F: FnOnce() -> i32,
{
    println!("{}", f());
}

上面例1中, trait bound F: Fn() -> i32F: FnMut() -> i32F: FnOnce() -> i32都表示F是一个有0个参数,返回i32的闭包。 它们的区别是闭包从环境中捕获变量的方式不同。后边我们会介绍。

4.闭包捕获变量的三种方式

闭包可以捕获其环境并访问其被定义的作用域的变量,这是函数所不具备的功能。

当闭包从环境中捕获一个值,闭包会在闭包体中存储这个值以供使用,这就会产生额外的内存开销,这点在使用时需要注意。

闭包可以通过三种方式从环境中捕获值,直接对应函数的三种获取参数的方式: 获取所有权、可变借用、不可变借用。 这三种捕获值的方式被编码为前面提到的三个Fn的trait:

  • FnOnce: 闭包以获取所有权的方式捕获其环境中的变量,FnOnce中的Once表示闭包不能多次获取相同变量的所有权,因此它只能被调用一次
  • FnMut: 闭包以获取可变引用的方式捕获其环境中的变量
  • Fn: 闭包以获取不可变引用的方式捕获其环境中的变量

由于闭包至少可以被调用一次,因此所有的闭包都实现了FnOnce trait,这和前面从三个trait的继承关系中推断的结论是一致的。。

可以在闭包的参数列表前使用move关键字,这样将强制闭包以获取所有权的方式捕获其环境中的变量。

例5:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
fn main() {
    let mut word = String::from("hello");

    // Fn
    let len1 = || word.len();
    println!("{}", len1());

    // FnMut
    let mut len2 = || {
        word.push_str("world");
        word.len()
    };
    println!("{}", len2());

    println!("{}", word);

    // FnOnce
    let len3 = move || word.len();
    println!("{}", len3());
    // println!("{}", word); borrow of moved value: `word`
}

5.为闭包实现某个trait

闭包除了可以作为函数被调用,除了以Fn, FnMut, FnOnce trait bounds约束的前提下作为函数参数、函数返回值、自定义结构的名字段外,还可以为其实现某个trait,使其表现出其它的行为。

例6:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
pub trait Handler {
    fn handle(&self, request: String);
}

impl<F> Handler for F
where
    F: Fn(String),
{
    fn handle(&self, request: String) {
        (*self)(request)
    }
}

fn add_handler(handler: impl Handler) {
    // ...
}

fn main() {
    add_handler(|req| println!("{req}"));
}

参考