进阶篇-闭包

  • 木头
  • 更新于 2023-03-13 16:02
  • 阅读 1802

匿名函数

Rust 的 闭包(closures)是可以保存在一个变量中或作为参数传递给其他函数的匿名函数。可以在一个地方创建闭包,然后在不同的上下文中执行闭包运算。不同于函数,闭包允许捕获被定义时所在作用域中的值。

闭包的语法

fn main() {
    let add = |x: u32, y: u32| -> u32 { x + y };
}

可以从示例上看出,闭包的语法跟函数很像。三个部分如下:

|参数1, 参数2, ...| 

->返回值类型

{执行体}

闭包类型推断和注解

函数与闭包还有更多区别。闭包并不总是要求像 fn函数那样在参数和返回值上注明类型。函数中需要类型注解是因为他们是暴露给用户的显式接口的一部分。严格定义这些接口对保证所有人都对函数使用和返回值的类型理解一致是很重要的。与此相比,闭包并不用于这样暴露在外的接口:他们储存在变量中并被使用,不用命名他们或暴露给库的用户调用。

闭包通常很短,并只关联于小范围的上下文而非任意情境。在这些有限制的上下文中,编译器能可靠地推断参数和返回值的类型,类似于它是如何能够推断大部分变量的类型一样(同时也有编译器需要闭包类型注解的罕见情况)。

有了类型注解闭包的语法就更类似函数了。如下是一个对其参数加一的函数的定义与拥有相同行为闭包语法的纵向对比。这里增加了一些空格来对齐相应部分。这展示了除了使用竖线以及一些可选语法外,闭包语法与函数语法有多么地相似:

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  ;
  1. 第一行展示了一个函数定义。
  2. 第二行展示了一个完整标注的闭包定义。
  3. 第三行闭包定义中省略了类型注解。
  4. 第四行去掉了可选的大括号,因为闭包体只有一个表达式。

这些都是有效的闭包定义,并在调用时产生相同的行为。调用闭包是 add_one_v3add_one_v4能够编译的必要条件,因为类型将从其用法中推断出来。这类似于 let v = Vec::new();Rust 需要类型注解或是某种类型的值被插入到 Vec 才能推断其类型。

编译器会为闭包定义中的每个参数和返回值推断一个具体类型:

fn main() {
    let a = |x| x;
    a("aa".to_string());
    a(1);
}

注意这个定义没有增加任何类型注解,所以我们可以用任意类型来调用这个闭包。但是如果尝试调用闭包两次,第一次使用 String 类型作为参数而第二次使用 u32,则会得到一个错误:

$ cargo run
   Compiling closure v0.1.0 (/rsut/closure)
error[E0308]: mismatched types
 --> src/main.rs:4:7
  |
4 |     a(1);
  |     - ^- help: try using a conversion method: `.to_string()`
  |     | |
  |     | expected struct `String`, found integer
  |     arguments to this function are incorrect
  |
note: closure parameter defined here
 --> src/main.rs:2:14
  |
2 |     let a = |x| x;
  |              ^

For more information about this error, try `rustc --explain E0308`.
error: could not compile `closure` due to previous error

第一次使用 String 值调用 a 时,编译器推断这个闭包中 x 的类型以及返回值的类型是 String。接着这些类型被锁定进闭包a 中,如果尝试对同一闭包使用不同类型则就会得到类型错误。

捕获变量

闭包可以通过三种方式捕获其环境,它们直接对应到函数获取参数的三种方式:不可变借用,可变借用和获取所有权。闭包会根据函数体中如何使用被捕获的值决定用哪种方式捕获。定义一个捕获名为 listvector 的不可变引用的闭包,因为只需不可变引用就能打印其值:

fn main() {
    let num = 5;
    let closure = |a| println!("a={}, num={}", a, num);

    println!("调用闭包之前 {}", num);
    closure(10);
    println!("调用闭包之前 {}", num);
}

这个示例也展示了变量可以绑定一个闭包定义,并且之后可以使用变量名和括号来调用闭包,就像变量名是函数名一样。

因为同时可以有多个num 的不可变引用,所以在闭包定义之前,闭包定义之后调用之前,闭包调用之后代码仍然可以访问num。代码可以编译、运行并打印:

调用闭包之前 5
a=10, num=5
调用闭包之前 5

我们修改闭包体让它向调用闭包后修改num。闭包现在捕获一个可变引用:

fn main() {
    let mut num = 5;
    let closure = |a| println!("a={}, num={}", a, num);

    closure(10);
    num = 10;
    closure(10);
} 

闭包中使用可变的变量num,然后闭包执行后,外部再修改num的值,然后再一次调用闭包。简单的理解这段代码,以为是第一次输出num=5,第二输出num=10。但是,事情却和想像中的不同,这段代码并不能通过编译:

$ cargo run
   Compiling closure v0.1.0 (/rsut/closure)
warning: value assigned to `num` is never read
 --> src/main.rs:6:5
  |
6 |     num = 10;
  |     ^^^
  |
  = help: maybe it is overwritten before being read?
  = note: `#[warn(unused_assignments)]` on by default

error[E0506]: cannot assign to `num` because it is borrowed
 --> src/main.rs:6:5
  |
3 |     let closure = |a| println!("a={}, num={}", a, num);
  |                   --- borrow of `num` occurs here --- borrow occurs due to use in closure
...
6 |     num = 10;
  |     ^^^^^^^^ assignment to borrowed `num` occurs here
7 |     closure(10);
  |     ------- borrow later used here

For more information about this error, try `rustc --explain E0506`.
warning: `closure` (bin "closure") generated 1 warning
error: could not compile `closure` due to previous error; 1 warning emitted

编译器说闭包已经借用了num,取得了num的所有权,外部不能再改变它了。原因三借用的所有权没有归还:

fn main() {
    let mut list = vec![1, 2, 3];
    let mut borrows_mutably = |l| list.push(l);

    borrows_mutably(4);
    borrows_mutably(6);
    list.push(5);
}

虽然这段代码在逻辑上和这一段代码不同了,但它确实通过了编译。原来闭包并不像函数那样,调用完了就完了,因为闭包绑定到了变量closure,在离开closure的作用域之前,闭包一直有效,借用不会归还。第一段代码num=10后面还有对闭包的调用,所以闭包并没有被销毁,而第二段代码第二次调用闭包后,就不再使用了,因此执行完了第二次调用闭包就被销毁了,num=10才能正常执行。

为了实现第一段代码的逻辑,一个闭包是不能完成的,需要两个闭包才能实现:

fn main() {
    let mut num = 5;
    let closure = |a| println!("a={}, num={}", a, num);
    closure(10);

    num = 10;

    let closure = |a| println!("a={}, num={}", a, num);
    closure(10);    
} 

第一个闭包执行完后被销毁,num=10可以被执行,再使用第二个闭包(虽然两个一模一样),第二个闭包再捕获的变量就是改变以后的了,可以到达想像中的效果。

move 闭包

上面说过闭包捕获变量有三种方式,是能过三个特性实现的:

  • Fn:如果闭包只是对捕获变量的非修改操作,闭包捕获的是&T类型,闭包按照Fn trait方式执行,闭包可以重复多次执行
  • FnMut:如果闭包对捕获变量有修改操作,闭包捕获的是&mut T类型,闭包按照FnMut trait方式执行,闭包可以重复多次执行
  • FnOnce:如果闭包会消耗掉捕获的变量,变量被move进闭包,闭包按照FnOnce trait方式执行,闭包只能执行一次

这三种方式是编译器根据闭包中的行为自动选择的,我们并不能指定它。此外,还可以在闭包前添加move关键字,告诉编译器复制一份变量到闭包中,这样,闭包就不再借用外部变量了:

fn main() {
    let mut num = 5;
    let closure = move |a| println!("a={}, num={}", a, num);

    closure(10);
    num = 10;
    closure(10);
    println!("num={}", num);
}

此时,闭包中的num与外部的num不再是同一个变量,外部对变量的修改也不再对闭包产生影响。这次我们没有获取到外部的num的可变借用,我们实际上是把 num move 进了闭包。因为 num具有Copy 属性,因此发生 move之后,以前的变量生命周期并未结束,还可以继续在 println! 中使用。我们打印的变量和闭包内的变量是独立的两个变量。如果我们捕获的环境变量不是 Copy 的,那么外部环境变量被 move进闭包后, 它就不能继续在原先的函数中使用了,只能在闭包内使用。

闭包作为函数参数

上面例子的闭包被保存在变量closure中,当然,它可以作为函数参数进行传递:

fn main() {
    let closure = |a| println!("a = {}", a);
    call_closure(closure);
}

fn call_closure(closure) {
    closure(10);
}

写到这自然会想到,call_closure函数的参数closure是什么类型呢?

事实上,闭包不属于任何类型,它的本质是:特性。要想使用闭包作为函数参数,应该这么写:

fn main() {
    let closure = |a| println!("a = {}", a);
    call_closure(closure);
}

fn call_closure(closure: impl Fn(i32)) {
    closure(10);
}

closure参数指定的不是参数的类型,而是指定参数实现的特性。可以进一步使用泛型来作为函数参数,它同样支持传递闭包:

fn main() {
    let closure = |a| println!("a = {}", a);
    call_closure(closure);
}

fn call_closure<F>(closure: F)
where
    F: Fn(i32),
{
    closure(10);
}

与直接调用不同,使用了泛型以后,我们就有了选择特性的手段:特性绑定。通过特性绑定,我们可以需要自己决定使用哪种变量捕获方式。此时,就需要我们明确的知道自己写的闭包到底是通过Fn、FnMut还是FnOnce的方式。这不是可以随意选择的,必须和创建闭包时编译器选择的一样。

  • 原创
  • 学分: 2
  • 分类: Rust
  • 标签:
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。
127 订阅 31 篇文章

0 条评论

请先 登录 后评论
木头
木头
0xC020...10cf
江湖只有他的大名,没有他的介绍。