Rust中怎么实现闭包

今天就跟大家聊聊有关Rust中怎么实现闭包,可能很多人都不太了解,为了让大家更加了解,小编给大家总结了以下内容,希望大家根据这篇文章可以有所收获。

让客户满意是我们工作的目标,不断超越客户的期望值来自于我们对这个行业的热爱。我们立志把好的技术通过有效、简单的方式提供给客户,将通过不懈努力成为客户在信息化领域值得信任、有价值的长期合作伙伴,公司提供的服务项目有:域名与空间、虚拟空间、营销软件、网站建设、山东网站维护、网站推广。

闭包的基本语法

闭包,又称为lambda表达式,可以像函数一样被调用,但具有使用外部环境中的变量的能力,其中外部环境是指闭包定义时所在的作用域。一个典型的闭包如下所示:

let x = 1;
let add = |a: i32| -> i32 {
	return a + x;
};
println!("{}", add(2)); // 输出:3

可以看到,add是一个闭包,它有一个参数,用两个|包围,执行语句包含在{}中,闭包的参数和返回值类型的指定与普通函数的语法相同,但可以省略。若{}中只包含一条语句,{}也可以省略。也就是说,add可以简写为如下形式:

let add = |a| a + x;

在闭包add中,它可以使用外部环境中的变量x,这是和普通函数最大的不同。需要注意的是,对于普通函数fn来说,其无需前向声明,但闭包需要先定义后使用,从这个角度讲闭包更像是一个变量,而且它具有和变量同样的“生命周期”。

下面我们来看一下闭包是如何使用外部环境中的变量的。

闭包的实现原理

对每一个闭包,编译器会自动生成一个匿名struct类型,并通过分析闭包的内部逻辑来决定该结构体包括哪些数据以及数据该如何初始化,如果闭包中使用了外部环境变量,则结构体中会包括该变量。从这个层面讲,闭包其实是一种语法糖。对于上面提到的闭包add,编译器会将其自动转化为如下形式(只是举个例子,并非真正的编译器处理闭包的方式):

struct Closure {
    x: i32,
}

impl Closure {
    fn call(&self, a: i32) -> i32 {
        self.x + a
    }
}
fn main() {
    let x = 1;
    let add = Closure { x: x };
    println!("{}", add.call(2)); // 输出:3
}

可以看到,若想在闭包中使用一个外部环境中的变量,需要分两步:第一步是构造相应的结构体并捕获外部环境变量,就如let add = Closure { x: x };所示;第二步是构造结构体的成员函数并使用外部环境变量,就如fn call(&self, a: i32) -> i32所示。在这两个步骤中,还需要考虑两个问题:

  • 第一个问题关心的是闭包如何捕获外部变量:结构体内部的成员应当使用什么类型呢,是T&T还是&mut T呢?

  • 第二个问题关心的是闭包如何使用外部变量:函数调用的self应当使用什么类型呢,是self&self还是&mut self呢?

如何捕获外部变量

对于闭包如何捕获外部变量,编译器的原则是“按需捕获”:在保证能编译通过的情况下,结构体内部的成员优先选择&T,其次是&mut T,最后选择T。这个原则的核心就是“选择对结构体外部影响最小的存储类型”,具体来说就是:

  • 如果一个外部变量在闭包中只通过借用指针&使用,那么这个变量就可使用&捕获;

  • 如果一个外部变量在闭包中通过可变借用指针&mut使用,那么这个变量就需要使用&mut捕获;

  • 如果一个外部变量在闭包中通过所有权转移方式使用过,那么这个变量就需要使用T捕获;

看下面这个例子:

struct T(i32);

fn by_move(_: T) {}
fn by_ref(_: &T) {}
fn by_mut(_: &mut T) {}

fn main() {
    let x = T(1);
    let y = T(2);
    let mut z = T(3);

    let closure = || {
        by_move(x);
        by_ref(&y);
        by_mut(&mut z);
    };

    closure();
}

以上闭包分别以T&T还是&mut T的方式捕获了外部的变量xy z,所以编译器会自动生成类似于下面这样的结构体:

struct Closure {
    x: T,
    y: &T,
    z: &mut T,
}

需要注意的是,如果承载闭包的变量不再是局部变量,而是被传递出了当前作用域,则该闭包必须选择传递所有权的方式才能保证编译通过,这时可以使用move关键字修饰闭包,强制将闭包中变量的捕获全部使用所有权转移的方式。例如:

fn make_adder(x: i32) -> Box i32> {
    Box::new(move |y| x + y)
}

可以看到,局部变量x被传递出了函数外,如果不加move关键字,编译器会提示:

error[E0373]: closure may outlive the current function, but it borrows `x`, which is owned by the current function
如何使用外部变量

闭包使用外部变量的方式将影响相应的结构体成员函数的第一个参数self的类型,对于self&self&mut self三种类型,Rust提供了三个trait对其进行抽象。这三个trait是FnFnMutFnOnce,它们的定义如下:

pub trait FnOnce {
    type Output;
    extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

pub trait FnMut: FnOnce {
    extern "rust-call" fn call_mut(&mut self, args: Args) -> Self::Output;
}

pub trait Fn: FnMut {
    extern "rust-call" fn call(&self, args: Args) -> Self::Output;
}

可以看到,这三个trait的区别就在于self的类型不同:

  • FnOnce的调用参数为self,表示闭包通过转移所有权的方式来使用外部环境中的变量,所以该闭包只能调用一次;

  • FnMut的调用参数是&mut self,表示闭包通过可变借用的方式来使用外部环境中的变量;

  • Fn的调用参数是&self,表示闭包通过不可变借用的方式来使用外部环境中的变量;

需要注意的是,Fn继承自FnMutFnMut继承自FnOnce,这意味着,如果要实现Fn,就必须实现FnMutFnOnce;如果要实现FnMut,就必须实现FnOnce。这里面蕴含的逻辑是,如果该闭包能够以Fn方式调用,那么它也一定能以FnMutFnOnce方式调用。

看下面这个例子:

fn main() {
    let s = "hello".to_string();
    let use_by_ref = move || {
        println!("{}", s);
    };
    use_by_ref(); // Fn
    use_by_ref();

    let mut s = "hello".to_string();
    let mut use_by_mut = || {
        s.push_str(" world");
        println!("{}", s);
    };
    use_by_mut(); // FnMut
    use_by_mut();

    let s = "hello".to_string();
    let use_by_move = || {
        drop(s);
    };
    use_by_move(); // FnOnce
    // use_by_move(); // error[E0382]: use of moved value: `use_by_move`
}

可以看到,闭包use_by_ref是只读方式使用了外部变量s,如果不使用move关键字,它的捕获方式是&T,使用方式是Fn,这里为了演示,强制使用move关键字将捕获方式改为T,但由于闭包中对变量s的使用仍然是只读,所以使用方式仍然是Fn,而不是FnOnce;闭包use_by_mut是可变方式使用了外部变量s,它的捕获方式是&mut T,使用方式是FnMut;闭包use_bu_move是所有权转移方式使用了外部变量s,它的捕获方式是T,使用方式是FnOnce,所以在第二次调用use_by_move时会报错。

闭包的使用

闭包一定会实现FnFnMutFnOnce三种trait之一,所以可以将闭包作为函数参数和返回类型,但需要注意的是,不要忘记trait是一种DST类型,它的大小在编译阶段是不固定的,从而不能直接作为参数类型或者返回值类型,这也是Rust中的trait和其他语言中的接口的重大区别之一。请看下面的例子,在函数tset中不可以直接使用Bird作为类型,编译器会报错。

trait Bird {
    fn fly(&self);
}

struct Duck;
struct Swan;

impl Bird for Duck {
    fn fly(&self) {
        println!("duck duck");
    }
}

impl Bird for Swan {
    fn fly(&self) {
        println!("swan swan");
    }
}

// error[E0277]: the size for values of type `(dyn Bird + 'static)` cannot 
// be known at compilation time
fn test(arg: Bird) {}

难道我们在Rust中就不能拥有多态了吗?那是不可能的,我们有两种选择:

  • 静态分派:通过泛型的方式,为不同的泛型类型参数生成不同版本的函数,实现编译期静态分派。

fn test(arg: T) {
    arg.fly();
}
  • 动态分派,通过trait objetc的方式,将闭包装箱进入堆内存中,函数传递的是一个胖指针,从实现运行期动态分派。

fn test(arg: Box) {
    arg.fly();
}

下面我们来具体介绍一下静态分派和动态分派。

静态分派

对于闭包,其泛型参数的写法有一些特殊之处,如下面代码所示:

fn call_with_closure(closure: F) -> i32
where
    F: Fn(i32) -> i32,
{
    closure(1)
}

其中泛型参数F的约束条件是F: Fn(i32) -> i32,这使得看起来和普通函数类型更相似从而更易阅读。

使用泛型的方式在函数参数中可以正常使用,但却无法将一个闭包作为函数值返回,因为Rust中只支持函数返回具体类型,而闭包是一个匿名类型,这使得编译器无法自动推断且程序员也无法手动指定。这时,可以使用impl trait语法糖,看下面的例子:

fn multiply(m: i32) -> impl Fn(i32) -> i32 {
    move |x| x * m
}

这里的impl Fn(i32) -> i32表示,这个返回类型,虽然我们不知道它的具体名字,但是知道它满足Fn(i32) -> i32这个trait的约束。这个功能目前还不是特别稳定,建议不要激进使用,推荐使用下面介绍的动态分派的方式来解决返回值的问题。

动态分派

动态分派是通过指针的方式来实现多态,虽然trait是的DST,但指向trait的指针不是DST。如果我们把trait隐藏到指针的后面,那么就称它是一个trait object,而trait object是可以作为参数和返回类型的。

指向trait的指针就是trait object。假如Bird是一个trait的名称,那么dyn Bird就是一个DST动态大小类型,则&dyn BirdBox以及Rc等都是trait object。当指针指向trait的时候,它就变成一个胖指针了,比如Box可以理解为:

pub struct TraitObject {
    pub data: *mut(),
    pub vtable: *mut(),
}

它里面包含了两个指针,第一个表示地址,第二个指向“虚函数表”,里面有我们需要调用的具体函数的地址。这和C++中的虚函数表内存布局有所不同。在C++中,如果一个类型里面有虚函数,则每一个这种类型的变量内部都包含一个指向虚函数表的地址。而在Rust中,对象本身不包含指向虚函数表的指针,这个指针是存在于trait object指针里面的,如果一个类型实现了多个trait,那么不同的trait object指向的虚函数表也不一样。

需要注意的是,并不是所有的trait都可以构造trait object,trait objetc的构造是受到许多约束的。满足以下条件的trait不能构造trait object:

  • 当trait有Self: Sized约束时

  • 当函数除了第一个参数外,有Self类型作为参数或返回类型时

  • 当函数的第一个参数不是Self

  • 当函数有泛型参数时

对于后三种情况,如果想把不满足条件的函数剔除在外,可以为该函数加上Self: Sized约束,例如fn foo(&self) where Self: Sized

看完上述内容,你们对Rust中怎么实现闭包有进一步的了解吗?如果还想了解更多知识或者相关内容,请关注创新互联行业资讯频道,感谢大家的支持。


网站题目:Rust中怎么实现闭包
本文路径:http://abwzjs.com/article/ihpshc.html