Inspirer

理解 Rust 2018 edition 的两个新关键字 —— impl 和 dyn

Rust 2018 edition 虽然实际发布时间还没到(本文开始写的时间是 18 年七月底),但是有些 2018 edition 的特性已经随着 Rust 的新版本发布放出,这些已经进入 stable 版的特性必然是应当了解并学习的。其中就有两个本文所要讨论的关键字 —— impldyn

最先出现的 impl 是大家已经熟悉的关键字,不过这次这个关键字除了用于表示实现一个 Trait,还有新的意义,即表达一个既存类型(Existential types),我们可以理解为一个实现了一个特征的 具体对象

官方原文介绍:impl Trait https://rust-lang-nursery.github.io/edition-guide/2018/transitioning/traits/impl-trait.html

impl Trait is the new way to specify unnamed but concrete types that implement a specific trait. There are two places you can put it: argument position, and return position.

trait Trait {}

// argument position
fn foo(arg: impl Trait) {
}

// return position
fn foo() -> impl Trait {
}

不过其意义是什么?与我们另一个要介绍的 dyn Trait 又有什么关系?下面我们正式开始。

使用抽象的一些问题

在使用 Rust 时,我们常常带入一些之前其他语言的惯性思维,无论是 Java、Go 还是 PHP,我们可以通过定义接口来抽象一个函数或方法的返回值,只要这个返回值是这个接口的实现即可。Rust 有一个和这些语言类似的东西:Trait

A trait tells the Rust compiler about functionality a particular type has and can share with other types. We can use traits to define shared behavior in an abstract way. We can use trait bounds to specify that a generic can be any type that has certain behavior.

即 trait 允许我们进行另一种抽象:他们让我们可以抽象类型所通用的行为。trait 告诉 Rust 编译器某个特定类型拥有可能与其他类型共享的功能。在使用泛型类型参数的场景中,可以使用 trait bounds 在编译时指定泛型可以是任何实现了某个 trait 的类型,并由此在这个场景下拥有我们希望的功能。

不过这里需要强调,trait 与 interface 是存在差异的

当我们在某些函数需要返回一个 trait 的实现时,我们可能写出如下代码:

// 注:Iterator 是一个迭代器 trait
fn get_iter() -> Iterator<Item=u8> {
    // ...
}

这样的代码将会报错,因为 Rust 要求必须返回一个具体的类型而不是一个抽象,因为抽象对于 Rust 是一个模糊的不具名信息,无法在编译期确定很多细节(这其实是可以解决的,不过由于 Rust 目前对于 DST 即动态大小类型的支持还在未来特性中,为保证 Rust 的稳定推进,目前只能这样)。那如何解决呢?可以通过装箱语法实现:

fn build_trait() -> Box<Iterator<Item=u8>> {
    // ...
}

使用装箱语法意味着我们在返回时需要使用 Box::new() 包装,但是使用装箱意味着这一过程属于运行时的动态分派,无法再将对象定于栈上。除此之外,我们可能还有另外的需求,就是返回一个匿名函数,这在当下业务场景中十分常见,根据上面的描述,我们若想要返回一个匿名函数,代码得如下书写:

fn foo<T>(add: u8) -> Box<T>
    where T: Fn(u8) -> u8
{
    Box::new(move |origin: u8| {
        origin + add
    })
}

因为匿名函数是编译器生成的匿名类型,根本不存在具体对象一说,这意味着它无法有一个明确的 size,所以只能被放置于 堆内存 之中,并取得一个 胖指针(即除了原始指针以外还包括对指针、指针指向内存的额外描述信息等的 “指针”),我们知道,凡是非静态分派,又和堆内存打交道的(废话),性能开销相对于栈上的工作,都是十分可观的,因此我们的新语法呼之欲出。

impl Trait

我们继续刚刚返回匿名函数的例子,使用新语法后代码如下:

fn foo<T>(add: u8) -> impl Fn(u8) -> u8
{
    move |origin: u8| {
        origin + add
    }
}

该语法表示返回值是一个满足其指明的 trait 的约束的具体类型。另外,由于这个实现是该函数返回值自行指定,还解决了某些场景使用泛型时的一些问题,比如上面的代码例子中,我们使用了泛型,而泛型的实际类型是由调用者决定的,这在使用装箱语法时会报错,虽然你在返回时通过 where 指明了泛型 T 的约束,但那并不是指示泛型具体类型的。

而通过 impl Trait 则是一个具体类型,且由返回者指定。

不过我想看了这部分内容的,都可能还有点模糊的地方,就是 调用者指定类型,或者说有没有更直观例子来辨别,当然有,我们以官方对于这部分说明的例子来写:

trait Trait {}

fn foo<T: Trait>(arg: T) {
}

fn foo(arg: impl Trait) {
}

上述两种实现,前者是泛型,表示 T 泛型需要是一个 Trait 的实现,后者不是泛型但也是要求满足 Trait 约束,这两个乍一看是类似的,实际却大不一样。

使用泛型时我们说,其类型是调用者决定,具体代码上体现就是我们可以这样调用 foo::<usize>(1) 表示我们传入的参数是 usize 类型,亦或 let a: usize = 1 然后 foo(a),在编译时,编译器会将泛型转换为实际被调用者传入的类型: usize,这就是所谓的调用者决定其类型。

而对于 impl Trait 这种形式,则无需调用者指定,仅需要保证满足约束即可。

dyn 来解决另外的歧义

我们在之前例子中,说过在没有 impl Trait 这种语法糖之前,需要靠装箱解决问题。这个地方其实还有一个问题,我们看代码:

fn my_function() -> Box<Foo> {
    // ...
}

上述代码存在一个歧义,Foo 到底是 trait 还是一个具体的类型?这两个是有明显区别的。通过新的关键字来明确两者差异。以下是官方例子:

trait Trait {}

impl Trait for i32 {}

// old
fn function1() -> Box<Trait> {
}

// new
fn function2() -> Box<dyn Trait> {
}

对于使用新关键字后的代码则不再存在歧义,且后续可能将不再支持 Box<Trait> 的写法,而是 Box<dyn Trait>。当然,对于目前而言,这两者似乎并没什么区别,关于这个语法其实还有很多讨论,可以查看 reddit 的这篇内容了解:https://www.reddit.com/r/rust/comments/8su7r3/i_dont_understand_the_purpose_of_dyn/