闭包:捕获其环境的匿名函数

Beginner

This tutorial is from open-source community. Access the source code

简介

欢迎来到「闭包:捕获其环境的匿名函数」。本实验是 Rust 程序设计语言 的一部分。你可以在 LabEx 中练习 Rust 技能。

在本实验中,你将探索 Rust 中的闭包,它是一种匿名函数,可以保存在变量中或作为参数传递,通过从其定义作用域捕获值来实现代码复用和行为定制。

闭包:捕获其环境的匿名函数

Rust 的闭包是一种匿名函数,你可以将其保存在变量中,或者作为参数传递给其他函数。你可以在一个地方创建闭包,然后在其他地方调用该闭包,以便在不同的上下文中对其进行求值。与函数不同,闭包可以从其定义的作用域中捕获值。我们将演示这些闭包特性如何实现代码复用和行为定制。

使用闭包捕获环境

我们首先来研究如何使用闭包从其定义的环境中捕获值以供后续使用。假设这样一个场景:我们的 T 恤公司会时不时地向邮件列表中的某个人赠送一件独家限量版 T 恤作为促销活动。邮件列表中的人可以选择在个人资料中添加他们最喜欢的颜色。如果被选中获得免费 T 恤的人设置了他们最喜欢的颜色,他们就会得到那种颜色的 T 恤。如果这个人没有指定最喜欢的颜色,他们就会得到公司目前库存最多的那种颜色的 T 恤。

实现这个功能有很多种方法。在这个例子中,我们将使用一个名为 ShirtColor 的枚举,它有 RedBlue 两个变体(为了简单起见,限制了可用颜色的数量)。我们用一个 Inventory 结构体来表示公司的库存,该结构体有一个名为 shirts 的字段,它包含一个 Vec<ShirtColor>,表示当前库存的 T 恤颜色。在 Inventory 上定义的 giveaway 方法获取免费 T 恤获奖者的可选颜色偏好,并返回这个人将得到的 T 恤颜色。清单 13-1 展示了这种设置。

文件名:src/main.rs

#[derive(Debug, PartialEq, Copy, Clone)]
enum ShirtColor {
    Red,
    Blue,
}

struct Inventory {
    shirts: Vec<ShirtColor>,
}

impl Inventory {
    fn giveaway(
        &self,
        user_preference: Option<ShirtColor>,
    ) -> ShirtColor {
      1 user_preference.unwrap_or_else(|| self.most_stocked())
    }

    fn most_stocked(&self) -> ShirtColor {
        let mut num_red = 0;
        let mut num_blue = 0;

        for color in &self.shirts {
            match color {
                ShirtColor::Red => num_red += 1,
                ShirtColor::Blue => num_blue += 1,
            }
        }
        if num_red > num_blue {
            ShirtColor::Red
        } else {
            ShirtColor::Blue
        }
    }
}

fn main() {
    let store = Inventory {
      2 shirts: vec![
            ShirtColor::Blue,
            ShirtColor::Red,
            ShirtColor::Blue,
        ],
    };

    let user_pref1 = Some(ShirtColor::Red);
  3 let giveaway1 = store.giveaway(user_pref1);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref1, giveaway1
    );

    let user_pref2 = None;
  4 let giveaway2 = store.giveaway(user_pref2);
    println!(
        "The user with preference {:?} gets {:?}",
        user_pref2, giveaway2
    );
}

清单 13-1:T 恤公司的赠送情况

main 中定义的 store 有两件蓝色 T 恤和一件红色 T 恤,用于这次限量版促销活动的分发 [2]。我们分别对有红色 T 恤偏好的用户 [3] 和没有任何偏好的用户 [4] 调用 giveaway 方法。

同样,这段代码可以用多种方式实现,在这里,为了专注于闭包,我们除了 giveaway 方法的主体使用了闭包外,其他都使用了你已经学过的概念。在 giveaway 方法中,我们将用户偏好作为 Option<ShirtColor> 类型的参数获取,并在 user_preference 上调用 unwrap_or_else 方法 [1]。Option<T> 上的 unwrap_or_else 方法是由标准库定义的。它接受一个参数:一个没有参数且返回 T 类型值的闭包(T 是存储在 Option<T>Some 变体中的相同类型,在这种情况下是 ShirtColor)。如果 Option<T>Some 变体,unwrap_or_else 返回 Some 中的值。如果 Option<T>None 变体,unwrap_or_else 调用闭包并返回闭包返回的值。

我们将闭包表达式 || self.most_stocked() 作为 unwrap_or_else 的参数。这是一个本身没有参数的闭包(如果闭包有参数,它们会出现在两个竖线之间)。闭包的主体调用 self.most_stocked()。我们在这里定义闭包,并且如果需要结果,unwrap_or_else 的实现会在之后计算这个闭包。

运行这段代码会输出以下内容:

The user with preference Some(Red) gets Red
The user with preference None gets Blue

这里一个有趣的方面是,我们传递了一个在当前 Inventory 实例上调用 self.most_stocked() 的闭包。标准库不需要知道我们定义的 InventoryShirtColor 类型,也不需要知道我们在这个场景中想要使用的逻辑。闭包捕获了对 self Inventory 实例的不可变引用,并将其与我们指定的代码一起传递给 unwrap_or_else 方法。另一方面,函数不能以这种方式捕获它们的环境。

闭包类型推断与标注

函数和闭包之间还有更多区别。闭包通常不像 fn 函数那样要求你标注参数和返回值的类型。函数需要进行类型标注,因为类型是暴露给用户的显式接口的一部分。严格定义这个接口对于确保每个人都就函数使用和返回的数值类型达成一致很重要。另一方面,闭包并不用于这样的公开接口:它们存储在变量中,使用时无需命名并暴露给库的用户。

闭包通常很短,并且只在有限的上下文中相关,而不是在任何任意场景中。在这些有限的上下文中,编译器可以推断参数的类型和返回类型,类似于它能够推断大多数变量的类型(在极少数情况下,编译器也需要闭包类型标注)。

与变量一样,如果我们想以比严格必要时更冗长为代价来增加显式性和清晰度,就可以添加类型标注。为闭包标注类型看起来会像清单 13-2 中所示的定义。在这个例子中,我们定义一个闭包并将其存储在变量中,而不是像在清单 13-1 中那样在作为参数传递闭包的地方定义它。

文件名:src/main.rs

let expensive_closure = |num: u32| -> u32 {
    println!("calculating slowly...");
    thread::sleep(Duration::from_secs(2));
    num
};

清单 13-2:在闭包中添加参数和返回值类型的可选类型标注

添加类型标注后,闭包的语法看起来更类似于函数的语法。这里,为了进行比较,我们定义了一个将其参数加 1 的函数和一个具有相同行为的闭包。我们添加了一些空格以使相关部分对齐。这说明了闭包语法与函数语法的相似之处,除了使用管道以及可选语法的数量:

fn  add_one_v1   (x: u32) -> u32 { x + 1 }
let add_one_v2 = |x: u32| -> u32 { x + 1 };
let add_one_v3 = |x|             { x + 1 };
let add_one_v4 = |x|               x + 1  ;

第一行展示了一个函数定义,第二行展示了一个完全标注的闭包定义。在第三行,我们从闭包定义中移除了类型标注。在第四行,我们移除了花括号,因为闭包体只有一个表达式,所以花括号是可选的。这些都是有效的定义,调用时会产生相同的行为。add_one_v3add_one_v4 这两行要求闭包被求值才能编译,因为类型将从它们的使用中推断出来。这类似于 let v = Vec::new(); 需要类型标注或者向 Vec 中插入某种类型的值,Rust 才能推断出类型。

对于闭包定义,编译器会为其每个参数和返回值推断出一个具体类型。例如,清单 13-3 展示了一个简短闭包的定义,它只返回作为参数接收的值。除了这个例子的目的外,这个闭包不是很有用。注意我们在定义中没有添加任何类型标注。因为没有类型标注,我们可以用任何类型调用这个闭包,这里我们第一次用 String 调用。如果我们随后尝试用整数调用 example_closure,就会得到一个错误。

文件名:src/main.rs

let example_closure = |x| x;

let s = example_closure(String::from("hello"));
let n = example_closure(5);

清单 13-3:尝试用两种不同类型调用类型被推断的闭包

编译器给我们这个错误:

error[E0308]: mismatched types
 --> src/main.rs:5:29
  |
5 |     let n = example_closure(5);
  |                             ^- help: try using a conversion method:
`.to_string()`
  |                             |
  |                             expected struct `String`, found integer

我们第一次用 String 值调用 example_closure 时,编译器推断出 x 的类型和闭包的返回类型为 String。然后这些类型被锁定在 example_closure 中的闭包中,当我们下次尝试用不同类型与同一个闭包一起使用时,就会得到一个类型错误。

捕获引用或转移所有权

闭包可以通过三种方式从其环境中捕获值,这三种方式直接对应于函数获取参数的三种方式:不可变借用、可变借用和获取所有权。闭包将根据函数体对捕获值的操作来决定使用哪种方式。

在清单 13-4 中,我们定义了一个闭包,它捕获了对名为 list 的向量的不可变引用,因为它只需要一个不可变引用来打印值。

文件名:src/main.rs

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

  1 let only_borrows = || println!("From closure: {:?}", list);

    println!("Before calling closure: {:?}", list);
  2 only_borrows();
    println!("After calling closure: {:?}", list);
}

清单 13-4:定义并调用一个捕获不可变引用的闭包

这个例子还说明了一个变量可以绑定到闭包定义 [1],并且我们可以在之后通过使用变量名和括号来调用闭包,就好像变量名是一个函数名一样 [2]。

因为我们可以同时对 list 有多个不可变引用,所以在闭包定义之前、闭包定义之后但在闭包调用之前以及闭包调用之后的代码中,list 仍然是可访问的。这段代码可以编译、运行并打印:

Before defining closure: [1, 2, 3]
Before calling closure: [1, 2, 3]
From closure: [1, 2, 3]
After calling closure: [1, 2, 3]

接下来,在清单 13-5 中,我们更改闭包体,使其向 list 向量中添加一个元素。现在闭包捕获一个可变引用。

文件名:src/main.rs

fn main() {
    let mut list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

    let mut borrows_mutably = || list.push(7);

    borrows_mutably();
    println!("After calling closure: {:?}", list);
}

清单 13-5:定义并调用一个捕获可变引用的闭包

这段代码可以编译、运行并打印:

Before defining closure: [1, 2, 3]
After calling closure: [1, 2, 3, 7]

请注意,在 borrows_mutably 闭包的定义和调用之间不再有 println!:当定义 borrows_mutably 时,它捕获了对 list 的可变引用。在闭包调用之后我们不再使用该闭包,所以可变借用结束。在闭包定义和闭包调用之间,不允许进行不可变借用以进行打印,因为当存在可变借用时不允许有其他借用。尝试在那里添加一个 println!,看看会得到什么错误消息!

如果你想强制闭包获取它在环境中使用的值的所有权,即使闭包体并不严格需要所有权,你可以在参数列表之前使用 move 关键字。

这种技术在将闭包传递给新线程以转移数据以便新线程拥有它时最为有用。我们将在第 16 章讨论并发时详细讨论线程以及为什么要使用它们,但现在,让我们简要探讨一下使用需要 move 关键字的闭包来创建一个新线程。清单 13-6 展示了对清单 13-4 的修改,以便在新线程中而不是在主线程中打印向量。

文件名:src/main.rs

use std::thread;

fn main() {
    let list = vec![1, 2, 3];
    println!("Before defining closure: {:?}", list);

  1 thread::spawn(move || {
      2 println!("From thread: {:?}", list)
    }).join().unwrap();
}

清单 13-6:使用 move 强制线程的闭包获取 list 的所有权

我们创建一个新线程,将一个闭包作为参数传递给该线程来运行。闭包体打印出列表。在清单 13-4 中,闭包只使用不可变引用来捕获 list,因为打印它所需的对 list 的访问权限最少。在这个例子中,即使闭包体仍然只需要一个不可变引用 [2],我们也需要通过在闭包定义的开头放置 move 关键字 [1] 来指定 list 应该被转移到闭包中。新线程可能在主线程的其余部分完成之前完成,或者主线程可能先完成。如果主线程保持对 list 的所有权但在新线程之前结束并释放 list,则线程中的不可变引用将无效。因此,编译器要求将 list 转移到传递给新线程的闭包中,这样引用才会有效。尝试移除 move 关键字,或者在闭包定义之后在主线程中使用 list,看看会得到什么编译器错误!

从闭包中移出捕获的值与 Fn 特质

一旦闭包从其定义的环境中捕获了引用或获取了值的所有权(从而影响了有什么东西被移入闭包),闭包体中的代码就定义了在稍后对闭包求值时,这些引用或值会发生什么(从而影响有什么东西被移出闭包)。

闭包体可以执行以下任何操作:将捕获的值移出闭包、变异捕获的值、既不移出也不变异该值,或者一开始就不从环境中捕获任何东西。

闭包从环境中捕获和处理值的方式会影响闭包实现的特质,而特质是函数和结构体指定它们可以使用哪些类型的闭包的方式。闭包会根据其体处理值的方式,以累加的方式自动实现这些 Fn 特质中的一个、两个或全部三个:

  • FnOnce 适用于只能被调用一次的闭包。所有闭包至少都实现这个特质,因为所有闭包都可以被调用。一个将捕获的值移出其体的闭包只会实现 FnOnce,而不会实现其他任何 Fn 特质,因为它只能被调用一次。
  • FnMut 适用于不会将捕获的值移出其体,但可能会变异捕获的值的闭包。这些闭包可以被调用多次。
  • Fn 适用于不会将捕获的值移出其体且不会变异捕获的值的闭包,以及不从其环境中捕获任何东西的闭包。这些闭包可以在不改变其环境的情况下被调用多次,这在诸如并发多次调用闭包的情况下很重要。

让我们看看清单 13-1 中使用的 Option<T> 上的 unwrap_or_else 方法的定义:

impl<T> Option<T> {
    pub fn unwrap_or_else<F>(self, f: F) -> T
    where
        F: FnOnce() -> T
    {
        match self {
            Some(x) => x,
            None => f(),
        }
    }
}

回想一下,T 是表示 OptionSome 变体中的值的类型的泛型类型。那个类型 T 也是 unwrap_or_else 函数的返回类型:例如,在 Option<String> 上调用 unwrap_or_else 的代码将得到一个 String

接下来,注意 unwrap_or_else 函数有一个额外的泛型类型参数 FF 类型是名为 f 的参数的类型,f 是我们在调用 unwrap_or_else 时提供的闭包。

在泛型类型 F 上指定的特质约束是 FnOnce() -> T,这意味着 F 必须能够被调用一次,不接受任何参数,并返回一个 T。在特质约束中使用 FnOnce 表达了 unwrap_or_else 最多只会调用 f 一次的约束。在 unwrap_or_else 的体中,我们可以看到如果 OptionSome,就不会调用 f。如果 OptionNone,就会调用 f 一次。因为所有闭包都实现 FnOnce,所以 unwrap_or_else 接受种类最多的闭包,并且尽可能灵活。

注意:函数也可以实现所有三个 Fn 特质。如果我们想做的事情不需要从环境中捕获值,那么在我们需要实现其中一个 Fn 特质的地方,我们可以使用函数名而不是闭包。例如,在 Option<Vec<T>> 值上,如果值是 None,我们可以调用 unwrap_or_else(Vec::new) 来获取一个新的空向量。

现在让我们看看标准库在切片上定义的方法 sort_by_key,看看它与 unwrap_or_else 有何不同,以及为什么 sort_by_key 在特质约束中使用 FnMut 而不是 FnOnce。闭包以对正在考虑的切片中的当前项的引用的形式获取一个参数,并返回一个可排序的 K 类型的值。当你想根据每个项的特定属性对切片进行排序时,这个函数很有用。在清单 13-7 中,我们有一个 Rectangle 实例的列表,我们使用 sort_by_key 按它们的 width 属性从低到高对它们进行排序。

文件名:src/main.rs

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    list.sort_by_key(|r| r.width);
    println!("{:#?}", list);
}

清单 13-7:使用 sort_by_key 按宽度对矩形进行排序

这段代码打印:

[
    Rectangle {
        width: 3,
        height: 5,
    },
    Rectangle {
        width: 7,
        height: 12,
    },
    Rectangle {
        width: 10,
        height: 1,
    },
]

sort_by_key 被定义为接受一个 FnMut 闭包的原因是它会多次调用该闭包:对切片中的每个项调用一次。闭包 |r| r.width 不会从其环境中捕获、变异或移出任何东西,所以它满足特质约束要求。

相比之下,清单 13-8 展示了一个只实现 FnOnce 特质的闭包的示例,因为它从环境中移出了一个值。编译器不会让我们将这个闭包与 sort_by_key 一起使用。

文件名:src/main.rs

--snip--

fn main() {
    let mut list = [
        Rectangle { width: 10, height: 1 },
        Rectangle { width: 3, height: 5 },
        Rectangle { width: 7, height: 12 },
    ];

    let mut sort_operations = vec![];
    let value = String::from("by key called");

    list.sort_by_key(|r| {
        sort_operations.push(value);
        r.width
    });
    println!("{:#?}", list);
}

清单 13-8:尝试将 FnOnce 闭包与 sort_by_key 一起使用

这是一种人为设计的、复杂的(且不起作用的)尝试计算对 list 进行排序时 sort_by_key 被调用次数的方法。这段代码试图通过将 value(闭包环境中的一个 String)推入 sort_operations 向量来进行计数。闭包捕获 value,然后通过将 value 的所有权转移到 sort_operations 向量,将 value 移出闭包。这个闭包只能被调用一次;尝试第二次调用它将不起作用,因为 value 不再在环境中,无法再次被推入 sort_operations!因此,这个闭包只实现了 FnOnce。当我们尝试编译这段代码时,我们会得到一个错误,提示 value 不能从闭包中移出,因为闭包必须实现 FnMut

error[E0507]: cannot move out of `value`, a captured variable in an `FnMut`
closure
  --> src/main.rs:18:30
   |
15 |       let value = String::from("by key called");
   |           ----- captured outer variable
16 |
17 |       list.sort_by_key(|r| {
   |  ______________________-
18 | |         sort_operations.push(value);
   | |                              ^^^^^ move occurs because `value` has
type `String`, which does not implement the `Copy` trait
19 | |         r.width
20 | |     });
   | |_____- captured by this `FnMut` closure

错误指向闭包体中移出 value 的那一行。要解决这个问题,我们需要更改闭包体,使其不从环境中移出值。在环境中保留一个计数器,并在闭包体中递增其值,是一种更直接的计算 sort_by_key 被调用次数的方法。清单 13-9 中的闭包可以与 sort_by_key 一起使用,因为它只捕获对 num_sort_operations 计数器的可变引用,因此可以被调用多次。

文件名:src/main.rs

--snip--

fn main() {
    --snip--

    let mut num_sort_operations = 0;
    list.sort_by_key(|r| {
        num_sort_operations += 1;
        r.width
    });
    println!(
        "{:#?}, sorted in {num_sort_operations} operations",
        list
    );
}

清单 13-9:允许使用 FnMut 闭包与 sort_by_key 一起使用

在定义或使用利用闭包的函数或类型时,Fn 特质很重要。在下一节中,我们将讨论迭代器。许多迭代器方法都接受闭包参数,所以在我们继续学习时,请记住这些闭包的细节!

总结

恭喜你!你已经完成了「闭包:捕获其环境的匿名函数」实验。你可以在 LabEx 中练习更多实验来提升你的技能。