【Rust学习之旅】函数式语言功能:闭包 (十二)

408 阅读12分钟

上一期我们讲到了生命周期,了解了一些简单生命周期的应用,这一期我们来谈谈我们前端友好的内容:迭代器闭包

闭包 Closure

谈起闭包,前端小伙伴就来精神了,这玩意我们熟呀。一会我们学完在对比对比看看是不是和你想象的闭包一样。

闭包是一种匿名函数,它可以赋值给变量也可以作为参数传递给其它函数,不同于函数的是,它允许捕获调用者作用域中的值.

rust 闭包使用|| 开始 相当于 javascript 剪头函数()=>的作用。 Rust 闭包在形式上借鉴了 Smalltalk 和 Ruby 语言,与函数最大的不同就是它的参数是通过 |parm1| 的形式进行声明,如果是多个参数就 |param1, param2,...|, 下面给出闭包的形式定义:

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

如果只有一个返回表达式的话,定义可以简化为:

|param1| 返回表达式

上例中还有两点值得注意:

  • 闭包中最后一行表达式返回的值,就是闭包执行后的返回值,因此 action() 调用返回了 intensity 的值 10
  • let action = ||... 只是把闭包赋值给变量 action,并不是把闭包执行后的结果赋值给 action,因此这里 action 就相当于闭包函数,可以跟函数一样进行调用:action()

闭包的类型推导

与函数相反,闭包并不会作为 API 对外提供,因此它可以享受编译器的类型推导能力,无需标注参数和返回值的类型。

为了增加代码可读性,有时候我们会显式地给类型进行标注,出于同样的目的,也可以给闭包标注类型:

但不标注会更简洁,可以自己取舍。下面就是一个例子

三种不同的闭包展示了三种不同的省略使用方式:

  • 省略参数
  • 返回值类型
  • 花括号对
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  ;

注意:虽然类型推导很好用,但是它不是泛型,当编译器推导出一种类型后,它就会一直使用该类型

#![allow(unused)]
fn main() {
let example_closure = |x| x;

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

首先,在 s 中,编译器为 x 推导出类型 String,但是紧接着 n 试图用 5 这个整型去调用闭包,跟编译器之前推导的 String 类型不符,就会报错。

结构体中的闭包

我们可以利用结构体配合闭包,实现数据缓存。

我们来实现一个简易缓存,功能是获取一个值,然后将其缓存起来,那么可以这样设计:

  • 一个闭包用于获取值
  • 一个变量,用于存储该值

Fn(u32) -> u32 这里出现了一个我们看起来好像陌生的东西,我们前面学到了where字句,可以约束泛型。显然这里也是,


#![allow(unused)]
fn main() {
struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    query: T,
    value: Option<u32>,
}
}

可以看得出这一长串是 T 的特征约束,再结合之前的已知信息:query 是一个闭包,大概可以推测出,Fn(u32) -> u32 是一个特征,用来表示 T 是一个闭包类型。

那为什么不用具体的类型来标注 query 呢?原因很简单,每一个闭包实例都有独属于自己的类型,即使于两个签名一模一样的闭包,它们的类型也是不同的,因此你无法用一个统一的类型来标注 query 闭包。

而标准库提供的 Fn 系列特征,再结合特征约束,就能很好的解决了这个问题. T: Fn(u32) -> u32 意味着 query 的类型是 T,该类型必须实现了相应的闭包特征 Fn(u32) -> u32

特征 Fn(u32) -> u32 从表面来看,就对闭包形式进行了显而易见的限制:该闭包拥有一个u32类型的参数,同时返回一个u32类型的值

闭包也是一种特殊的函数,所以Fn 特征不仅仅适用于闭包,还适用于函数,因此上面的 query 字段除了使用闭包作为值外,还能使用一个具名的函数来作为它的值

接着我们完善一下

#![allow(unused)]
fn main() {
impl<T> Cacher<T>
where
   T: Fn(u32) -> u32,
{
   fn new(query: T) -> Cacher<T> {
       Cacher {
           query,
           value: None,
       }
   }

   // 先查询缓存值 `self.value`,若不存在,则调用 `query` 加载
   fn value(&mut self, arg: u32) -> u32 {
       match self.value {
           Some(v) => v,
           None => {
               let v = (self.query)(arg);
               self.value = Some(v);
               v
           }
       }
   }
}
}

上面的缓存有一个很大的问题:只支持 u32 类型的值,若我们想要缓存 &str 类型,显然就行不通了,因此需要将 u32 替换成泛型 E,有兴趣可以自己试试看。

捕获作用域中的值

捕获作用域中的值.我们看下面的例子,闭包中可以直接使用x变量。

fn main() {
    let x = 4;

    let equal_to_x = |z| z == x;

    let y = 4;

    assert!(equal_to_x(y));
}

对于函数来说,就算你把函数定义在 main 函数体中,它也不能访问 x

fn main() {
    let x = 4;

    fn equal_to_x(z: i32) -> bool {
        z == x
    }

    let y = 4;

    assert!(equal_to_x(y));
}

报错如下:

error[E0434]: can't capture dynamic environment in a fn item // 在函数中无法捕获动态的环境
 --> src/main.rs:5:14
  |
5 |         z == x
  |              ^
  |
  = help: use the `|| { ... }` closure form instead // 使用闭包替代

如上所示,编译器准确地告诉了我们错误,甚至同时给出了提示:使用闭包来替代函数,这种聪明令我有些无所适从,总感觉会显得我很笨。

闭包对内存的影响

当闭包从环境中捕获一个值时,会分配内存去存储这些值。对于有些场景来说,这种额外的内存分配会成为一种负担。与之相比,函数就不会去捕获这些环境值,因此定义和使用函数不会拥有这种内存负担。

javascript不合理私用闭包会造成内存泄漏

三种 Fn 特征

闭包捕获变量有三种途径,恰好对应函数参数的三种传入方式:转移所有权、可变借用、不可变借用,因此相应的 Fn 特征也有三种:

  1. FnOnce,该类型的闭包会拿走被捕获变量的所有权。Once 顾名思义,说明该闭包只能运行一次:

    fn fn_once<F>(func: F)
    where
        F: FnOnce(usize) -> bool,
    {
        println!("{}", func(3));
        println!("{}", func(4));
    }
    
    fn main() {
        let x = vec![1, 2, 3];
        fn_once(|z|{z == x.len()})
    }
    
    

    实现 FnOnce 特征的闭包在调用时会转移所有权,所以显然不能对已失去所有权的闭包变量进行二次调用。

    因为 F 没有实现 Copy 特征,所以会报错,那么我们添加一个约束,试试实现了 Copy 的闭包:where F: FnOnce(usize) -> bool + Copy

    func 的类型 F 实现了 Copy 特征,调用时使用的将是它的拷贝,所以并没有发生所有权的转移。

    如果你想强制闭包取得捕获变量的所有权,可以在参数列表前添加 move 关键字,这种用法通常用于闭包的生命周期大于捕获变量的生命周期时,例如将闭包返回或移入其他线程。

    
    #![allow(unused)]
    fn main() {
    use std::thread;
    let v = vec![1, 2, 3];
    let handle = thread::spawn(move || {
        println!("Here's a vector: {:?}", v);
    });
    handle.join().unwrap();
    }
    
  2. FnMut,它以可变借用的方式捕获了环境中的值,因此可以修改该值:

    fn main() {
        let mut s = String::new();
    
        let mut update_string =  |str| s.push_str(str);
        update_string("hello");
    
        println!("{:?}",s);
    }
    

    注意:想要在闭包内部捕获可变借用,需要把该闭包声明为可变类型

  3. Fn 特征,它以不可变借用的方式捕获环境中的值 让我们看看下面这个例子

fn main() {
    let mut s = String::new();

    let update_string =  |str| s.push_str(str);

    exec(update_string);

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

fn exec<'a, F: Fn(&'a str)>(mut f: F)  {
    f("hello")
}

运行代码就会报错:我们的闭包实现的是 FnMut 特征,需要的是可变借用,但是在 exec 中却给它标注了 Fn 特征,因此产生了不匹配,再来看看正确的不可变借用方式,我们简单修改一下。

fn main() {
    let s = "hello, ".to_string();

    let update_string =  |str| println!("{},{}",s,str);

    exec(update_string);

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

fn exec<'a, F: Fn(String) -> ()>(f: F)  {
    f("world".to_string())
}

在这里,因为无需改变 s,因此闭包中只对 s 进行了不可变借用,那么在 exec 中,将其标记为 Fn 特征就完全正确。

move 和 Fn

在上面,我们讲到了 move 关键字对于 FnOnce 特征的重要性,但是实际上使用了 move 的闭包依然可能实现了 Fn 或 FnMut 特征。

因为,一个闭包实现了哪种 Fn 特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们move 本身强调的就是后者,闭包如何捕获变量:

fn main() {
    let s = String::new();

    let update_string =  move || println!("{}",s);

    exec(update_string);
}

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

上面的闭包中使用了 move 关键字,所以我们的闭包捕获了它,但是由于闭包对 s 的使用仅仅是不可变借用,因此该闭包实际上实现了 Fn 特征。

细心的读者肯定发现我在上段中使用了一个  字,这是什么意思呢?因为该闭包不仅仅实现了 FnOnce 特征,还实现了 Fn 特征,将代码修改成下面这样,依然可以编译:

fn main() {
    let s = String::new();

    let update_string =  move || println!("{}",s);

    exec(update_string);
}

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

三种 Fn 的关系

实际上,一个闭包并不仅仅实现某一种 Fn 特征,规则如下:

  • 所有的闭包都自动实现了 FnOnce 特征,因此任何一个闭包都至少可以被调用一次
  • 没有移出所捕获变量的所有权的闭包自动实现了 FnMut 特征
  • 不需要对捕获变量进行改变的闭包自动实现了 Fn 特征

闭包作为函数返回值

看到这里,相信大家对于如何使用闭包作为函数参数,已经很熟悉了,但是如果要使用闭包作为函数返回值,该如何做?

先来看一段代码:


#![allow(unused)]
fn main() {
fn factory() -> Fn(i32) -> i32 {
    let num = 5;

    |x| x + num
}

let f = factory();

let answer = f(1);
assert_eq!(6, answer);
}

Rust 要求函数的参数和返回类型,必须有固定的内存大小,这里会发生错误

绝大部分类型都有固定的大小,但是不包括特征,因为特征类似接口,对于编译器来说,无法知道它后面藏的真实类型是什么,因为也无法得知具体的大小。

我们做一些限制:

#![allow(unused)]
fn main() {
fn factory(x:i32) -> impl Fn(i32) -> i32 {

    let num = 5;

    if x > 1{
        move |x| x + num
    } else {
        move |x| x - num
    }
}
}

在前面我们提到过trait 作为返回值,所有这里同样适用,impl Trait 的返回方式有一个非常大的局限,就是你只能返回同样的类型,所以这里还是会报错,就算签名一样的闭包,类型也是不同的

还记得我们上次错误处理时候遇到的Box<dny Error> 吗,这里我们也可以用类似的,使用特征对象


#![allow(unused)]
fn main() {
fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> {
    let num = 5;

    if x > 1{
        Box::new(move |x| x + num)
    } else {
        Box::new(move |x| x - num)
    }
}
}

Rust闭包 vs JavaScript闭包

那我们来说一下Rust和JavaScript的闭包有什么区别:

相同点:

  1. 两种语言的闭包都定义了作为函数对象的代码块。

  2. 闭包都可以在定义时访问其定义环境的变量。

不同点:

  1. Rust闭包和JavaScript闭包对其捕获的变量的所有权和生命周期处理方式不同。
  • Rust闭包的所有权和生命周期限制更为严格,Rust编译器会检查闭包捕获的变量的生命周期是否符合规定。一般情况下,Rust闭包会复制其捕获的变量,从而拥有自己的内存所有权并避免了内存问题。而在JavaScript中,不需要担心所有权和生命周期的问题,因为JS使用的是垃圾回收机制,它会自动释放变量的内存空间。

  • Rust闭包可以使用move关键字获取其捕获的变量所有权,这意味着闭包可以移动变量,使变量在闭包被转移时不再可用。而JavaScript没有类似的特性。

  1. Rust闭包使用外部标记 #[derive(Copy, Clone)] 来实现复制其捕获的变量,而JavaScript没有类似的语言机制。
  • Rust闭包捕获的每个变量必须显式复制或移动,否则不能超过闭包的定义范围,因为Rust编译器需要在编译时知道变量的生命周期。这种复制和移动会增加一些复杂性,但也可以有效避免一些内存问题。而JavaScript闭包捕获的变量没有显示复制或移动的过程。
  1. Rust闭包使用命名参数传递捕获的变量,而JavaScript使用变量名。
  • Rust闭包中,捕获变量可以通过传递其名称给闭包来实现。而在JavaScript中,捕获到的变量可以直接使用变量名访问。

Rust闭包对变量所有权和生命周期的控制更加严格,同时也需要显式复制或移动闭包捕获的变量。而JavaScript闭包则由于使用了垃圾回收机制,对变量所有权和生命周期没有那么严格的限制。

结语

关于rust的闭包我们就讲完了,对于JavaScript来说,我们需要做的可能比较少,rust是一门对安全性要求严格的语言,语法上还是会有很大的区别。