【Fitz】Rust 闭包 Closure

说明:本文内容大部分来自于Rust语言圣经相关章节内容和Databend闭包部分的课程,是对这两部分的学习整理。

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

fn call(f: fn()) {  //传入参数为函数指针
    fn();
}

fn main() {
    let a = 1;
    let f1 = || println!("abc");  //匿名函数的函数指针
    let f2 = || println!("{}", &a);  //闭包
  
    call(f1);
    call(f2);  //报错,需要一个函数指针,但传入了一个闭包
}

对于定义的函数,可以将其赋值给某个变量,如 let p_x = plus_x; 其中 plus_x 定义为 fn plus_x(x: i32) -> i32 { x + 1 }。而有时为了避免参数x变动涉及的代码修改,可以定义 plus_x 的闭包来直接捕获作用域内变量 x

Rust闭包与函数最大的不同就是参数通过 |parm1| 的形式进行声明,如果是多个参数就是 |parm1, parm2, ... |,闭包的形式定义如下:

//闭包的形式定义
|parm1, parm2, ... | {
	语句1;
	语句2;
	返回表达式
}
//如果只有一个返回表达式,定义可以简化为
|parm1| 返回表达式

let action = || ... 只是把闭包赋值给变量 action,而不是把闭包执行结果赋值给 action,因此这里 action 就相当于闭包函数,可以跟函数一样进行调用:action()

闭包由一个结构体组成,当引用了外部的自由变量时就是有大小的,并且引用的是指针;如果没有引用外部自由变量,就是一个空的结构体,大小就是0。所有闭包名称都是唯一的。Rust 闭包底层是用结构体实现的。那么闭包是如何找到函数的?实际上是在内部直接写死了函数指针的地址,这是在编译期完成的操作。

Rust 中的闭包耦合了泛型、生命周期和所有权,所有这些都是为了保证程序运行时的安全和效率

闭包的类型推导

开发者必须手动为函数的所有参数和返回值指定类型,因为函数通常作为API提供给用户,但是闭包并不会作为API对外提供,因此可以享受编译器的类型推导能力而无需标注参数和返回值的类型。虽然编译器会对闭包进行类型推导,但是当推导出一种类型后,就会一直使用该类型,当传入其他类型参数时就会报错。

// 同一功能的函数和闭包实现形式:
fn add_one_v1 (x: i32) -> i32 { x + 1 }
let add_one_v2 = |x: i32| -> i32 { x + 1 };  //与函数最像
let add_one_v3 = |x| { x + 1 };  //省略参数和返回值
let add_one_v4 = |x| x + 1;  //省略花括号对

结构体中的闭包

//简易缓存的实现
struct Cacher<T>
where
    T: Fn(u32) -> u32,
{
    query: T,
    value: Option<u32>,
}

//为结构体Cacher实现方法
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
            }
        }
    }
}

其中,Fn(u32) -> u32 用来描述T是一个闭包类型,意味着 query 类型是 T,该类型必须实现了相应的闭包特征。Fn 特征不仅仅适用于闭包,还适用于函数,query 字段除了使用闭包作为值之外,还能使用一个具体的函数最为它的值。

捕获作用域中的值

当闭包从环境中捕获一个值时,会分配内存去存储这些值。对于有些场景来说,这种额外的内存分配会成为一种负担。

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

1. FnOnce

该类型的闭包会拿走被捕获变量的所有权Once 说明闭包只能运行一次。仅实现 FnOnce 特征的闭包在调用时会转移所有权,所以不能对已失去所有权的闭包变量进行二次调用。

如果闭包实现了 Copy 特征,那么调用时使用的将是拷贝,并没有发生所有权的转移。

fn fn_once<F> (func: F)
where
    F: FnOnce(usize) -> bool + Copy,  //此处声明F实现了 Copy trait
{
    println!("{}", func(3));
    println!("{}", func(4));
}

fn main() {
    let x = vec![1, 2, 3];
    fn_once(|z| {z == x.len()})  //拷贝数据,没有发生所有权转移
    // fn_once(move |z| {z == x.len()})  //move关键字用法
}

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

2. FnMut

FnMut 表示以可变引用(&mut T)的方式捕获了环境中的值,因此可以修改该值。要实现可变借用捕获变量,需要将该闭包声明为可变类型。把闭包当做一个普通变量,看起来也就没有那么“反直觉”了。

fn main() {
    let mut s = String::new();
    let mut push_str = |str| s.push(str);
    push_str("hello");
}

FnMut 闭包作为函数传入参数示例:

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

3. Fn

Fn 表示以不可变引用(&T)的方式捕获环境中的值。下为示例;

fn main() {
    let s = "hello, ".to_string();
    let update_string =  |str| println!("{},{}",s,str);  //该闭包仅对s进行了不可变借用
    exec(update_string);
    println!("{:?}",s);
}

fn exec<'a, F: Fn(String) -> ()>(f: F)  {  //对于不可变借用闭包,仅将其标记为Fn特征即可
    f("world".to_string())
}

move 和 Fn

使用了 move 的闭包依然可能实现了 FnFnMut 特征。

因为一个闭包实现了哪种 Fn 特征取决于该闭包如何使用被捕获的变量,而不是取决于闭包如何捕获它们。使用的 move 关键字,强调的就是“闭包如何捕获变量”。

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

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

    exec(update_string);
}

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

例如上面的代码中,闭包不仅仅实现了 FnOnce 特征,还实现了 Fn 特征(因为该闭包对于s的使用仅仅是不可变引用),因此将代码中的 FnOnce 修改为 Fn 也是可以编译通过的。

三种Fn的关系

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

  • 所有的闭包都自动实现了 FnOnce 特征,因此任何一个闭包都至少可以被调用一次;
  • 没有移出所捕获变量的所有权的闭包自动实现了 FnMut 特征;
  • 不需要对捕获变量进行改变的闭包自动实现了 Fn 特征。
fn main() {
    let mut s = String::new();
    let update_string = |str| -> String {s.push_str(str); s };
    exec(update_string);
}

fn exec<'a, F: FnMut(&'a str) -> String>(mut f: F) {  //错误,实现了FnOnce
    f("hello");
}

上面示例代码中,闭包从捕获环境中移出了变量s的所有权,因此闭包自动实现了 FnOnce,未实现 FnMut 和 Fn。

Fn 的前提是实现 FnMut,FnMut 的前提是实现 FnOnce,因此要实现 Fn 就要同时实现 FnMut 和 FnOnce。

实际项目中,建议先使用 Fn 特征,然后编译器会告诉正误以及该如何选择。

捕获值在实现上的变动

对于闭包 || a.x + 1 ,2018的实现是捕获整个结构体a,但是现在只捕获所需要用的 x。这个特性会导致一些对象在不同时间点被释放(dropped),或是影响了闭包是否实现 SendClone trait,所以 cargo 会插入语句 let _=&a 引用完整结构体来修复这个问题。这个变动其实很大,细节可以参考官方文档。

闭包作为函数返回值

Rust要求函数的参数和返回类型,必须有固定的内存大小。绝大部分类型都有固定的大小,但是不包括特征,因为特征类似接口,编译器无法知道其真实类型或具体大小。因此编译器会提示使用 impl 关键字,如:

fn return_closure(x: i32) -> impl Fn(i32) -> i32 {
    let num = 5;

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

但是上面代码不能通过,因为两个分支返回了不同的闭包类型。即使是签名相同的闭包,类型也是不同的。可以用 Box 实现特征对象:

fn factory(x:i32) -> Box<dyn Fn(i32) -> i32> {  //dyn是trait对象类型的前缀
    let num = 5;

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

通过上面的方式可以将闭包作为函数返回值。

下为两个将闭包作为返回值,同时将闭包定义中的堆上的数据所有权转移至闭包的代码示例。

fn returns_closure() -> impl FnOnce() {  //函数的返回值是一个闭包
	let s = String::from("test");
	move || println!("s is {}", s)  //使用move可以将堆上数据的所有权转移至闭包,使得在离开该作用域时也不会drop掉
}

fn main() {
	let f = returns_closure();  //s的所有权已转移至闭包f
	f()  //执行闭包f()
}
fn test() -> impl FnMut(char) {  //函数返回值是FnMut()的闭包
	let mut s = String::from("董泽润的技术笔记");
	move |c| { s.push(c); }  //使用move将s的所有权转移至闭包
}

fn main() {
	let mut c = test();  //s所有权已转移至闭包c
	c('d');
	c('e');
}
posted @ 2022-03-13 12:54  AlphaFitz  阅读(468)  评论(0)    收藏  举报