Rust学习笔记1:闭包

Rust学习笔记1:闭包

什么是闭包?

闭包是一种可以捕获调用者作用域变量的匿名函数。它既可以作为值赋值给变量,又可以作为实参传递给函数,它让函数变成了一种数据类型存在。还有,由于闭包不需要进行参数和返回值的类型标注,这可以省却我们不少事情。

下面这是一个好例子,cat的值是一个闭包,它捕捉了变量x。它的参数和返回值都没有类型标注,又没有fn、花括号,代码显得十分简洁。

fn main() {
   let x = 100;
   let cat = |y| x + y;
   assert_eq!(101, cat(1));
}

闭包的语法

这是闭包的正统语法:

|param1, param2...| {
    语句1;
    语句2;
    返回表达式
}

如果只有一行代码,花括号按约定也可以省去:

|param1,param2...| 返回表达式

当把一个闭包赋值给一个变量时,例如下面这个骚气的闭包:

let action = || 3;

此时是闭包赋值给了action,并不是把闭包的执行结果赋值给了它。这一点非常浅显易知,有经验的程序员一定不会犯这样的判断错误。

谨以此例为例,action在执行之后结果仍然为3,它和let action = 3区别并不大,即使新学者判断失误也没有问题。

类型推导:先入为主

闭包不用标注参数和返回值的类型,编译器会自己推导,但是它的推导有时候会因先入为主而产生“错误”:

fn main() {
    let fn1 = |x| x;
    let s = fn1(String::from("hi"));
    let n = fn1(5);
}

第3行和第4行代码,谁放在后面,谁在编译时报错。**此时决定它们正确与否的关键,只是它们所站的位置不同。**闭包中的参数类型和返回值类型被推导为一种可能后,就会被一直保持为这种可能。后来的会被排斥。

怎么破解?如果我们在下面需要有不同类型参数的调用呢?

解决方法也极其简单:

fn main() {
    let s = (|x| x)(String::from("hi"));
    let n = (|x| x)(5);
    println!("s: {}", s);
    println!("n: {}", n);
}

闭包可以现写现用,相同的闭包代码拷贝一份即可。代码虽然还是那个代码,但闭包已经不是那个闭包。

闭包的所有权⭐

现在我们设想这样一个问题:如果我们在结构中使用了闭包,那么Rust对该闭包的所有权是如何规定的呢?

先看一则非常有Rust意味的代码示例:

struct Cache<T,E>
where 
    T: Fn(E) -> E,
    E: Copy 
{
    query: T,
    value: Option<E>,
}

impl<T,E> Cache<T,E>
where 
    T: Fn(E) -> E,
    E: Copy
{
    fn new(query: T) -> Self {
        Cache {
            query,
            value: None
        }
    }    
    fn value(&mut self, arg: E) -> E {
        match self.value {
            Some(v) => v,
            None => {
                let v = (self.query)(arg);
                self.value = Some(v);
                v 
            }
        }
    }
}

在结构体Cache中,query是一个闭包,它的类型是T,约束T的特征是Fn(E) -> E

代码很简单,不细讲了,下面开始实践。如果我们在两个结构中实例中引用同一个闭包,会发生什么呢?程序会报错吗?

fn main() {
    let a = 1;
    let fn1 = |x| x + a;

    let mut c = Cache::new(fn1);
    println!("{}", c.value(10)); // 11
    println!("{}", c.value(11)); // 仍然是 11

    let mut d = Cache::new(fn1);
    println!("{}", d.value(10)); // 1
    println!("{}", d.value(11)); // 仍然是 10
}

c与d是两个Cache实例,它俩同时使用了fn1赋值query,但是程序并没有因为所有权冲突而报错。

Rust中的闭包在所有权这个议题上稍微有点复杂,究竟有没有发生所有权转移,要看闭包捕获的变量是什么情况。没有捕获或捕获的只是栈上的变量,即实现了Copy的变量,那么闭包也在栈上,并没有所有权转移,而是产生了复制(Copy),跟重写一遍闭包代码没有区别。

如何判断闭包是不是实现了Copy特征呢?和其它复杂的类型一样,看闭包捕获的变量是不是都实现了Copy特征,如果是,则闭包也实现了Copy特征;如果否,则不是。

对于否的这种情况,即捕猎的变量是分配在堆上的,大小是动态的,例如String,那么闭包也分配在堆上,这时候对闭包没有实现Copy特征,对它的使用就涉及到所有权转移了。

下面我们做另外一个实验,我们让闭包捕获一个String类型的变量:

#[test]
fn test() {
    let a = String::from("LY");
    // L的ascii码是76
    let fn1 = |x| x + a.chars().next().unwrap() as u8 as i32-75;
    println!("a={}", a); // 这行代码会报错

    let mut c = Cache::new(fn1);
    assert_eq!(c.value(10), 11);
    assert_eq!(c.value(11), 11);
    println!("a={}", a); // LY

    let mut d = Cache::new(fn1);
    assert_eq!(d.value(10), 11);
    assert_eq!(d.value(11), 11);
    println!("a={}", a); // LY
}

这个测试通过了,它没有报错。并且a一直都能打钱。为什么?

这是因为闭包fn1对a的使用,仅是引用,并没有夺取它的所有权。我们如果在闭包前面加上一个move,情况就不一样了。

let fn1 = move |x| x + a.chars().next().unwrap() as u8 as i32-75;

程序不能编译能过了,为什么?因为发生了所有权转移,当a被闭包占有后,后面的代码就不能再使用它了。

move的含义是:不管原来有没有发生所有权转移,加上move一定会发生所有权转移。

三种Fn特征⭐

闭包会捕获调用者作用域中的变量,按照捕获方式不同,及闭包可以执行一次或多次,Rust给闭包分配了三种特征,涵盖所有情况。

1,FnOnce,对待捕获变量的方式是转移所有权,仅能执行一次。如果所有权仅可转移一次,所以闭包也仅能执行一次。

FnOnce特征示例:

fn fn_once<F>(f: F)
where
    F: FnOnce(usize) -> bool,
{
    println!("{}", f(3));
    // println!("{}", f(4));
}

fn main() {
    let x = String::from("123");
    fn_once(|z| {
      let y = x;
      z == y.len()
    });
    // println!("{}", x); // 这里不可用,x已经被move了
}

第6行代码不可执行,因为f作为传递进来的闭包仅可执行一次。第15行代码也不可执行,因为x的所有权已经在第12行被闭包转移给了y。

对于被FnOnce特征约束的闭包,有没有办法执行两次甚至多次呢?

当然有。

当我们给闭包实现Copy特征后,它就可以多次调用了:

fn fn_once<F>(f: F)
where
    F: Copy + FnOnce(usize) -> bool,
{
    println!("{}", f(3));
    println!("{}", f(4));
}

fn main() {
    let x = String::from("123");
    fn_once(|z| {
      let y = x.clone();
      z == y.len()
    });
    println!("{}", x); // 这里的x可以使用了
}

第6行与第15行的代码都可以正常运行了。第6行可以运行,是因为闭包f实现了Copy特征。第15行可以运行,是因为在第12行,y是对x的克隆,x的所有权并没有被转移进来。

在这个示例中,我们虽然对闭包使用了FnOnce特征,但同时还有其他特征加在它的身上,它已经不纯粹了,自然也不能单纯地继续恪守“只能执行一次”的规则了。

2,FnMut,对待捕获变量的方式是可变借用,可执行多次。但这种方式在多线程中不具有安全性。

FnMut示例:

fn main() {
  let mut s = String::from("123");
  let mut update =  |str| s.push_str(str); 
  update("45");
  exec(update, "67");
  println!("{:?}", s); // 1234567
}

fn exec<'a, F: FnMut(&'a str)>(mut f: F, s: &'a str) {
  f(s)
}

第3行是一个闭包,并且赋值给了变量update。该闭包捕获了变量s,并且是以可变借用的方式捕获并使用的。我们单独执行第4行代码,编译器要求在update前面加上mut修饰符。当闭包要修改其捕获的变量时,在闭包变量前面必须加上mut修饰符。

但是,如果我们单执行第5行代码,第3行中的mut修饰符又可以去掉,为什么?

对闭包使用mut修饰符和应用FnMut特征是两套体系,对于后面,第9行的特征约束已经保证闭包对捕获的变量应用可变借用的方式。这一块实现拿捏不准,可依编译器为准,Rust编译器是Rust程序员的vb

3,Fn,对待捕获变量的方式是不可变借用,可执行多次。这种方式在多线程中具有安全性。

Fn特征示例:

fn main() {
  let s = String::from("123");
  let update =  |str| format!("{}{}", str, s); 
  update("45".to_string());
  exec(update, "67".to_string());
  println!("{:?}", s); // 123
}

fn exec<'a, F: Fn(String) -> String>(f: F, s: String) -> String {
  f(s)
}

第3行,update闭包对参数str拥有所有权,对捕获的变量s只是不可变借用,无论是独立调用,还是通过exec调用,都没有问题。第6行,s没有变化,输出仍然是“123”。

三种Fn特征之间的继承关系⭐

这是它们的简化版源码:

pub trait Fn<Args> : FnMut<Args> {
    ...
}

pub trait FnMut<Args> : FnOnce<Args> {
    ...
}

pub trait FnOnce<Args> {
    ...
}

从源码可以看出来,三种Fn特征存在继承关系:Fn继承于FnMut,FnMut又继承于FnOnce。如果画一张图,该关系应该是这样的:

Fn → FnMut → FnOnce

从继承关系看,Fn对函数的约束最大,FnOnce最小,从描述上看,确实也是如此:

  • FnOnce:转移所有权,调用一次,线程不安全
  • FnMut:可变借用,可调用多次,线程不安全
  • Fn:不可变借用,可调用多次,线程安全

从上往上看,对闭包的约束越来越强了。转移所有权是Rust的默认执行方式。

三种Fn特征是一种对函数的约束特征,并且这种约束是通过函数本身的代码决定的,Rust编译器也是通过代码进行的类型推断。下面是一种示例,展示了如果是我们人为对函数施加约束,则可能对同一个函数,三种约束都是ok的:

fn main() {
  let s = String::from("1234567890");
  let update =  || println!("{}",s);
  exec1(update); // 1234567890
  exec2(update); // 1234567890
  exec3(update); // 1234567890
}

fn exec1<F: FnOnce()>(f: F)  {
  f()
}

fn exec2<F: FnMut()>(mut f: F)  {
  f()
}

fn exec3<F: Fn()>(f: F)  {
  f()
}

但是,如果我将update的代码修改一下:

let update =  || {
  let x = s;
  println!("{}",x);
};

update转移了变量s的所有权,此时exec2、exec3的调用都不再合适,只有exec1的调用无误,因为exec1的特征约束是FnOnce,与update的代码呈现出来的对变量的使用方式是相同的。一个闭包实现的是三类中的哪种特征,取决于它如何使用捕获的变量。

move与mut

move与mut是语法层面的修饰符,move代表强制转移所有权,作用与FnOnce相当,mut代表闭包对捕获的变量将有修改,是可变借用,作用与FnMut相当。我们不使用Fn特征,使用这两个修饰符有时也能达到类似的语法效果。

mut示例:

fn main() {
  let mut s = String::from("123");
  let mut update =  |str| s.push_str(str);
  update("456"); 
  println!("{}", s); // 123456
}

变量s被捕获,在第2行要被修改,所以第2行变量前要加上mut。在第3行,因为闭包对捕获的变量要进行修改,所以在update变量前也要加上mut修饰符。Rust注重内存安全,凡是涉及到内存变动的地方,

move示例:

fn main() {
  let s = String::from("123");
  let update = move |str| println!("{}{}", s, str);
  update("456"); // 123456
  // println!("{}", s); // 这里不可运行
}

因为变量s的所有权被消耗了,所以第5行不能运行。

函数如何返回闭包?⭐

Rust是静态语言,函数的参数和返回值必须是固定大小,但是闭包并不是固定大小的。举个例子说明一下,对于下面的闭包:

let s = String::from("hello");
let closure2 = |x| x + s.len(); // 捕获一个 String

在编译器编译后,会生成一个结构体,并实现一个方法:

fn main() {
  let c1 = Closure2 { s: String::from("hello") };
  let result = c1.call(1); // 调用 call 方法
  println!("{}", result);  // 输出 6
}

// 手动实现的结构体
struct Closure2 {
  s: String, // 捕获的环境变量
}

// 为结构体实现一个普通的 call 方法
impl Closure2 {
  fn call(&self, x: i32) -> i32 {
      x + self.s.len() as i32
  }
}

我们对闭包的调用,相当于第3行对编译后call方法的调用。

由于Closure2中s是一个动态变量,它的大小是不固定的,所以闭包的大小也是不固定的。

有人说,如果闭包捕获的是一个固定大小的变量呢,例如let x = 1

即使如此,闭包的大小也是按照“不确定的大小”对待的,至少目前Rust编译器目前是这样做的。

好,我们已经明确,闭包大小不是确定的,那么在函数中返回闭包,我们怎么做呢?

下面是一个函数返回闭包的示例:

fn main() {
  let f = factory();
  let answer = f(1);
  assert_eq!(6, answer); // 通过
}

fn factory() -> Box<dyn Fn(i32) -> i32> {
  let num = 5;
  Box::new(move |x| x + num)
}

第9行返回了一个使用Box包装的闭包。Box是智能指针,它的大小是固定的,通常需要解引用(*)访问其内容,在第3行并没有解引用直接调用了,这是因为Rust对Box有自动解引用的语法机制。在第3行,(*f)(1)f(1)是相同的。

最后再总结一下,为什么函数直接返回一个闭包不可以,返回使用Box包装的闭包就可以呢?闭包的大小是不固定的,不能作为函数的返回值返回,把它分配在堆上,让Box的指针指向它,Box作为智能指针它的大小是固定的,这样就可以满足Rust编译器的要求了。在许多地方,当Rust编译器要求固定大小,而代码实际情况不满足的时候,Rust都是这样解决的。

2025年3月8日

该文由 rustpress 编译。

版权所有

本文链接:

许可证:署名-非商业性 4.0 国际 (CC-BY-NC-4.0) 查看许可说明

分享这篇文章

评论

微信小游戏开发

微信小游戏开发

学习微信小游戏开发技术,掌握游戏开发全流程

查看详情