聊聊Rust中的闭包

前两天敲着敲着代码,编译器突然弹出了这么一条错误信息:

let push: |&str| -> ()

cannot borrow `push` as mutable, as it is not declared as mutable

意思是告诉我, push这个闭包需要声明成mutable的, 话说这种提示自从我开始学Rust就不知道收到了多少,基本是自己不注意把后期要进行修改的变量声明成不可变的变量导致的, 但那些都是变量,今天是第一次见到闭包也需要声明成可变的。虽然不管在Go中还是在Rust中,方法和函数这类的东西都算是一等公民, 简单来说你就把它们当成primitive type来看待就好,是作为参数还是作为返回值,还是直接赋值给变量,这些都随你折腾。所以理论上闭包能声明成mutable也没什么奇怪的,但奇怪的是为什么要声明成mutable?在我看来只有我后期要修改这个push的内容的时候我才需要将它声明成mutable, 可是在这个闭包声名之后,我只是在调用它,并没有做任何修改的操作, 而且在我看来闭包从本质上说也只是个匿名函数外加上下文中所捕获的变量信息而已,除非我直接对push这个变量重新赋值,否则很难想到有其他什么方法会改变其内容。一时想不明白就上网搜了一下,发现这一切都不像我想象的那么简单。

 

什么是closure?

 A closure expression produces a closure value with a unique, anonymous type that cannot be written out. A closure type is approximately equivalent to a struct which contains the captured variables.

以上是Rust reference 对闭包类型的定义, 注意是闭包类型, 不是闭包, 也就是说咱们写的那些闭包表达式所产生的闭包也是有类型的,只不过是编译器根据你写的闭包表达式来反推出来的罢了。所以咱们所说的闭包其实在Rust中不是一种类型, 而是好多种闭包类型的总称。最终要的是后面一句, A closure type is approximately equivalent to a struct which contains the captured variables. 意思是闭包其实就像一个包含着被捕获的上下文变量的struct。而且还附上了一个例子:

fn f<F : FnOnce() -> String> (g: F) {
    println!("{}", g());
}

let mut s = String::from("foo");
let t = String::from("bar");

f(|| {
    s += &t;
    s
});
// Prints "foobar".

 generates a closure type roughly like the following:

struct Closure<'a> {
    s : String,
    t : &'a String,
}

impl<'a> FnOnce<()> for Closure<'a> {
    type Output = String;
    fn call_once(self) -> String {
        self.s += &*self.t;
        self.s
    }
}

so that the call to f works as if it were:

f(Closure{s: s, t: &t});

这下我有点理解为啥编译器要我把闭包声明成mutable的了, 但是另一个问题又出现了,如果闭包真的是通过上面例子中的这种方式实现的,那我在闭包中捕获的变量,除非我再返回出来,否则我只要调用闭包,这些被捕获的变量就都无法在后面的代码中使用了。这样闭包的使用价值就基本没有了。但是当我把代码中的闭包改成mutable之后,编译顺利通过,这种情况也没有出现,闭包捕获的变量在后续的代码中依然可以使用,证明这种情况是不存在的。那Rust又是如何解决这个问题的呢?

捕获模式(Capture modes)

其实Rust在确定以何种方式捕获变量的时候是有优先顺序的, 这个顺序如下:

1. immutable borrow

2. unique immutable borrow

3. mutable borrow

4. move

其中1、3和4, 咱们都已经比较熟悉了,Rust的内存管理模型就是以这三种为基础建立起来的。2比较特殊,这是一种闭包中独有的模式, 后面我们会了解到。

从这个顺序我们不难看出,在决定闭包表达式与捕获变量关系的时候,编译器会尽可能选择最低权限。而且对结果起决定作用的只有你写的闭包表达式里面的内容, 与上下文代码没有关系。也就是说,闭包里对捕获变量的操作,如果immutable borrow足够了,那编译器就不会用mutable borrow, immutable borrow就可以满足需求, 编译器也不会选择move。但是如果你在声明闭包时用了move关键字,那就是另一种情况了, 编译器这时候就不会考虑最低需求了, 所有捕获的变量, 除了实现Copy trait的(基本就那么几个primitive type)用copy外,剩下的一律都会用move。这种方式存在的价值在于它可以让闭包的生命周期超过所捕获变量的生命周期, 主要在多线程编程的时候使用。比如下面这段代码:

use std::thread::spawn;

fn my_fun() {
    let mut s: String = String::new();
    let push = move || {
        s.push_str("hello world");
        println!("{}", s);
    };
    let h = spawn(push);
    h.join().unwrap();
}

如果我们将move关键字去掉, 是无法完成编译的, 因为如果单看闭包的内容, 对于变量

s的操作, 用mutable borrow足够了, 但是后面我们把闭包交给了另一个线程来执行, 这时候如果还是用mutable borrow, 整个my_fun可能已经执行完了, s变量已经被销毁, 闭包当中的这个mutable borrow自然也就没法使用了。如果用move关键字, s变量的所有权就转交给了闭包, 这样即使my_fun执行完, s变量也是依然存在的, 闭包可以在其他线程当中顺利执行。

对于捕获模式,还有一点需要注意,那就是组合类型struct,tuple和enum, 即使你只用到了它们其中的某一个字段, 编译器还是会将他们整个捕获, 官方给出了下面这个例子:

struct SetVec {
    set: HashSet<u32>,
    vec: Vec<u32>
}

impl SetVec {
    fn populate(&mut self) {
        let vec = &mut self.vec;
        self.set.iter().for_each(|&n| {
            vec.push(n);
        })
    }
}

这其中的vec就是为了防止捕获整个self而设置的临时变量, 因为如果我们在闭包中使用self.vec.push(n)来替代vec.push(n), 闭包会试图mutable borrow整个self, 而此时self又因为对set的便利而处于immutable borrow的状态, mutable + immutable, 想起来什么了吗?对, 对于一个对象在同一个作用域内只能存在一个mutable reference而且不能与其他reference共存。所以如果替换成直接使用self.vec.push(n), 这段代码是无法编译通过的。

Unique Immutable Borrow

还记得我们刚才提到的那个从来没有见过的unique immutable borrow吗? 官方文档里面对它下的定义是一种特殊的borrow, 只会在闭包中出现, 而且没法显式的写出来, 一切都是编译器在背后分析。说到这里, 我突然觉得Rust的编译器真的好伟大,默默无闻的做了这么多事, 但是话又说回来, 你丫要不是把内存管理这块搞的这么特立独行,也不至于有这么多奇奇怪怪的工作。好了,不多说了, 言归正转, 这种borrow类型只会发生在通过mutable borrow reference来修改reference所指向的内容的时候, 有点绕是吧?看一下下面这个官方给出的例子:

let mut b = false;
let x = &mut b;
{
    let mut c = || { *x = true; };
    // The following line is an error:
    // let y = &x;
    c();
}
let z = &x;

文档当中对这个例子的解释是, 编译器在解析闭包c的时候, 对x的捕获方式应该会倾向采用immutable borrow, 因为对x这个引用本身没有什么修改,所以没有必要用mutable borrow。但如果真的采用immutable borrow, 又没法安全的进行赋值, 因为& &mut没法保证是唯一的, 没看明白?我们一步一步的来推, 首先变量x的类型是&mut这个没问题是吧, 现在假设我们在闭包中采用immutable borrow的方式来捕获x, 那我们得到的就是一个& &mut, 而这个引用归根结底还是一个immutable reference, 只不过它指向的内容是一个mutable reference(这让我回忆起了当年学C++时那个const和指针的位置关系)。既然是immutable reference, 那就允许有多个同时存在, 所以代码中*x = true这个赋值理论上是不安全的。所以unique immutable borrow应运而生, 它是一种immutable borrow, 但是与mutable borrow类似, 它必须是唯一的。例子中被注掉的那一行代码如果解除注释,编译器就会报错。因为很明显,闭包中对变量x的引用不再是唯一的了。

Call Traits And Coercions(调用特性与强制转换)

最后来说一下闭包实现的traits, 或者说是闭包的具体类型。所有的闭包都实现FnOnce这个trait,也就是都可以通过消耗ownership的方式来进行调用(有点别扭, 但是文档里面确实就是这么写的, 不知道是不是我理解的不对)。有些闭包实现了更具体的调用trait:

1. 当一个闭包不转移任何捕获的变量的ownership时, 那它就实现了FnMut trait, 这样就可以用mutable reference的形式来调用它。

2. 当一个闭包不转移同时也不修改任何捕获的变量时, 那它就实现了Fn trait,这样就可以用immutable reference的形式来调用它。 

这里需要注意一点, move闭包同样可能实现FnMut或者Fn, 即使它捕获变量的方式是move。因为这些trait是否实现,是根据闭包对捕获变量做什么样的操作来决定的,与捕获变量的方式无关。

 

总结一下:

Rust中的闭包也没逃过它那内存管理机制的魔爪, ownership仍然很重要,不过官方文档给了一个很好的比喻,那就是把闭包想象成一个由捕获的变量组成的struct, 这样很多情况都会变得很好理解。变量捕获方式总结成一句话,除非你强制用move,否则编译器会选择最低限度的borrow或者move形式,而且这个只与你闭包表达式里的内容有关,跟上下文没有一毛钱关系。Call Trait的实现总结成一句话,  你对捕获变量做的操作是唯一决定你能否多次调用同一个闭包的因素。

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值