Rust的API详细指南

207 阅读10分钟

在Rust的API中,有一个常见的模式:返回一个相对复杂的数据类型,提供一个我们想要使用的特质实现。许多Rust新手最先遇到的地方之一就是迭代器。例如,如果我想提供一个函数来返回数字1到10的范围,它可能看起来像这样:

use std::ops::RangeInclusive;

fn one_to_ten() -> RangeInclusive<i32> {
    1..=10i32
}

这就掩盖了这里所发生的迭代器的性质。然而,当你开始使事情变得更复杂时,情况就会变得更糟,例如:

use std::iter::Filter;

fn is_even(x: &i32) -> bool {
    x % 2 == 0
}

fn evens() -> Filter<RangeInclusive<i32>, for<'r> fn(&'r i32) -> bool> {
    one_to_ten().filter(is_even)
}

甚至更疯狂:

use std::iter::Map;

fn double(x: i32) -> i32 {
    x * 2
}

fn doubled() ->
    Map<
        Filter<
               RangeInclusive<i32>,
               for<'r> fn(&'r i32) -> bool
              >,
        fn(i32) -> i32
       > {
    evens().map(double)
}

这显然不是我们要写的代码!幸运的是,我们现在有一种更优雅的方式来说明我们的意图:impl Trait 。这个功能允许我们说一个函数返回的值是某个特性的实现,而不需要明确说明具体类型。我们可以用以下方式重写上面的签名:

fn one_to_ten() -> impl Iterator<Item = i32> {
    1..=10i32
}

fn is_even(x: &i32) -> bool {
    x % 2 == 0
}

fn evens() -> impl Iterator<Item = i32> {
    one_to_ten().filter(is_even)
}

fn double(x: i32) -> i32 {
    x * 2
}

fn doubled() -> impl Iterator<Item = i32> {
    evens().map(double)
}

fn main() {
    for x in doubled() {
        println!("{}", x);
    }
}

这对开发来说是个福音,特别是当我们遇到更复杂的情况时(如期货和重度代码)。然而,我想介绍一个案例,impl Trait 展示了一种限制。希望这将有助于解释所有权的一些细微差别及其与该功能的互动。

介绍这个谜语

看一下这段代码,它没有被编译:

// Try replacing with (_: &String)
fn make_debug<T>(_: T) -> impl std::fmt::Debug {
    42u8
}

fn test() -> impl std::fmt::Debug {
    let value = "value".to_string();

    // try removing the ampersand to get this to compile
    make_debug(&value)
}

pub fn main() {
    println!("{:?}", test());
}

在这段代码中,我们有一个make_debug 函数,它完全接受任何值,完全忽略该值,并返回一个u8 。然而,我没有在函数签名中包括u8 ,而是说impl Debug (这完全有效:u8 事实上实现了Debug )。test 函数通过传递一个&Stringmake_debug 来产生自己的impl Debug

当我试图编译这个时,我得到了错误信息:

error[E0597]: `value` does not live long enough
  --> src/main.rs:10:16
   |
6  | fn test() -> impl std::fmt::Debug {
   |              -------------------- opaque type requires that `value` is borrowed for `'static`
...
10 |     make_debug(&value)
   |                ^^^^^^ borrowed value does not live long enough
11 | }
   | - `value` dropped here while still borrowed

在我们尝试理解这个错误信息之前,我想在这里深化一下谜底。我可以对这段代码做大量的修改,以使它能够编译。比如说:

  • T 如果我把make_debug 上的参数换成&String (或更习惯的&str )。代码就可以编译了。由于某些原因,多态性导致了一个问题。
  • 也许更奇怪的是,把签名从make_debug<T>(_: T) 改为make_debug<T>(_: &T) 也能解决这个问题。奇怪的是,T 允许传递引用,那么为什么&T 能解决任何问题?
  • 最后,在对make_debug 的调用中,如果我们传递值(通过移动)而不是值的引用,所有的东西都能编译,例如:make_debug(value) ,而不是make_debug(&value) 。至少从直觉上讲,我希望在使用引用时能得到更少的寿命错误。

这里发生了一些微妙的事情,让我们试着一点一点地去理解它。

使用具体类型的寿命

让我们简化我们的make_debug 函数,明确地接受一个String

fn make_debug(_: String) -> impl std::fmt::Debug {
    42u8
}

这个参数的生命周期是什么?好吧,make_debug ,完全消耗这个值,然后丢弃它。这个值不能再在函数之外使用。有趣的是,make_debug 丢弃它的事实并没有真正反映在函数的类型签名中;它只是说我们返回一个impl Debug 。为了证明这一点,我们可以返回参数本身而不是我们的42u8

fn make_debug(message: String) -> impl std::fmt::Debug {
    //42u8
    message
}

在这种情况下,message 的所有权从make_debug 函数本身转移到了返回的impl Debug 值。这是一个有趣而重要的观察,我们稍后会回到这里。让我们继续探索,看看一个接受了&Stringmake_debug

fn make_debug(_: &String) -> impl std::fmt::Debug {
    42u8
}

这个引用的寿命是多少?多亏了寿命豁免,我们不需要明确说明。但隐含的寿命是在函数本身的寿命之内。换句话说,当我们的函数退出时,我们对String 的借用就完全失效了。我们可以通过尝试返回引用来进一步证明这一点。

fn make_debug(message: &String) -> impl std::fmt::Debug {
    //42u8
    message
}

我们得到的错误信息有点令人惊讶,但也相当有用:

error: cannot infer an appropriate lifetime
 --> src/main.rs:4:5
  |
2 | fn make_debug(message: &String) -> impl std::fmt::Debug {
  |                                    -------------------- this return type evaluates to the `'static` lifetime...
3 |     //42u8
4 |     message
  |     ^^^^^^^ ...but this borrow...
  |
note: ...can't outlive the anonymous lifetime #1 defined on the function body at 2:1
 --> src/main.rs:2:1
  |
2 | / fn make_debug(message: &String) -> impl std::fmt::Debug {
3 | |     //42u8
4 | |     message
5 | | }
  | |_^
help: you can add a constraint to the return type to make it last less than `'static` and match the anonymous lifetime #1 defined on the function body at 2:1
  |
2 | fn make_debug(message: &String) -> impl std::fmt::Debug + '_ {
  |                                    ^^^^^^^^^^^^^^^^^^^^^^^^^

现在的情况是,我们的签名中基本上有两个寿命。message 的隐含生命期是函数的生命期,而impl Debug 的生命期是'static ,这意味着它要么不借用数据,要么只借用持续整个程序的值(比如一个字符串字面)。我们甚至可以尝试贯彻建议,增加一些明确的生命周期:

fn make_debug<'a>(message: &'a String) -> impl std::fmt::Debug + 'a {
    message
}

fn test() -> impl std::fmt::Debug {
    let value = "value".to_string();
    make_debug(&value)
}

虽然这修复了make_debug 本身,但我们不能再从test 成功调用make_debug

error[E0597]: `value` does not live long enough
  --> src/main.rs:11:16
   |
7  | fn test() -> impl std::fmt::Debug {
   |              -------------------- opaque type requires that `value` is borrowed for `'static`
...
11 |     make_debug(&value)
   |                ^^^^^^ borrowed value does not live long enough
12 | }
   | - `value` dropped here while still borrowed

换句话说,我们从test() 的返回值应该比test 本身的寿命长,但是value 的寿命却没有超过test

挑战题请确保你能向自己(或橡皮鸭)解释:当我们通过值传递而不是通过引用传递时,为什么返回message

对于make_debug 的具体类型版本,我们基本上有一个两两的矩阵:我们是通过值传递还是引用,以及我们是返回所提供的参数还是一个假的42u8 值。让我们把这个清楚地记录下来。

通过值通过引用
使用信息成功:参数由返回值拥有失败:返回值超过了引用的时间
使用哑巴42成功:返回值不需要参数成功:返回值不需要引用

希望刚才描述的具体类型的故事是有意义的。但这给我们留下了一个问题...

为什么多态性会破坏事物?

我们在最下面一行看到,当返回假的42 值时,我们使用逐值传递和逐参考传递都是安全的,因为返回值根本不需要参数。但是由于某些原因,当我们使用参数T 而不是String&String 时,我们得到一个错误信息。让我们通过代码来温习一下我们的记忆:

fn make_debug<T>(_: T) -> impl std::fmt::Debug {
    42u8
}

fn test() -> impl std::fmt::Debug {
    let value = "value".to_string();
    make_debug(&value)
}

和错误信息:

error[E0597]: `value` does not live long enough
  --> src/main.rs:10:16
   |
6  | fn test() -> impl std::fmt::Debug {
   |              -------------------- opaque type requires that `value` is borrowed for `'static`
...
10 |     make_debug(&value)
   |                ^^^^^^ borrowed value does not live long enough
11 | }
   | - `value` dropped here while still borrowed

make_debug ,我们可以很容易地看到该参数被忽略了。然而,这是很重要的一点:make_debug 的函数签名并没有明确地告诉我们这一点!相反,我们所知道的是: 。相反,这里是我们所知道的。

  • make_debug 需要一个类型为的参数T
  • T 可能包含具有非静态寿命的引用,我们真的不知道(同样:非常重要!)。
  • 返回值是一个impl Debug
  • 我们不知道这个返回值有什么具体的类型,但是它一定有一个'static
  • impl Debug 可能依赖于T 参数内的数据

这样做的结果是:如果T 有任何引用,那么它们的寿命必须至少和返回值impl Debug 的寿命一样大,这就意味着它必须是一个'static 的寿命。这确实是我们得到的错误信息:

opaque type requires that `value` is borrowed for `'static`

注意,这发生在调用 make_debug ,而不是在make_debug 。我们的make_debug 函数是完全有效的,它只是有一个隐含的生命周期。我们可以用更明确的方式。

fn make_debug<T: 'static>(_: T) -> impl std::fmt::Debug + 'static

为什么变通的方法有效

我们之前通过使参数的类型具体化来解决编译失败的问题。有两种相对简单的方法可以解决编译失败的问题,并且仍然保持类型的多态性。它们是:

  1. 将参数从_: T 改为_: &T
  2. 将调用地点从make_debug(&value) 改为make_debug(value)

挑战在阅读下面的解释之前,请试着根据我们到目前为止的解释,自己弄清楚这些变化对编译的修正。

将参数改为&T

我们对T 的隐含要求是,它所包含的任何引用都有一个静态寿命。这是因为我们无法从类型签名中看出impl Debug 是否持有T 中的数据。然而,通过使参数本身成为一个引用,我们完全改变了球赛的情况。突然间,我们有了两个隐含的寿命,而不是'staticT 的单一隐含寿命:

  • 引用的寿命,我们称之为'a
  • T 值和impl Debug 的寿命,这两个都是隐含的。'static

更加明确的是:

fn make_debug<'a, T: 'static>(_: &'a T) -> impl std::fmt::Debug + 'static

虽然我们无法从这个类型签名中看出impl Debug 是否依赖于T 内的数据,通过impl Trait 特征本身的定义,我们知道它并不依赖于'a 的寿命。因此,对引用的唯一要求是它的寿命与对make_debug 本身的调用一样长,这实际上是真的。

将调用改为按值传递

另一方面,如果我们把参数保留为T (而不是&T ),我们可以通过用make_debug(value) (而不是make_debug(&value) )逐值传递来解决编译的问题。这是因为传入的T 值的要求是它有'static 的寿命,而没有引用的值确实有这样的寿命(因为它们是由函数拥有的)。更直观的是:make_debug 占有T 的所有权,如果impl Debug 使用该T ,它将从make_debug 占有它的所有权。否则,当我们离开make_debugT 将被丢弃。

按表格审查

为了总结多态的情况,让我们再分出一个表,这次是比较参数是T 还是&T ,以及调用是make_debug(value) 还是make_debug(&value)

参数是T参数是&T
make_debug(value)成功:String 的寿命是'static类型错误:传递一个String ,而预期是一个引用。
make_debug(&value)寿命错误:&String 没有寿命。'static成功:引用的寿命是'a

总结

就我个人而言,我发现impl Trait 的这种行为最初是令人困惑的。然而,通过上面的步骤,我对这种情况下的所有权有了更好的理解。impl Trait 是Rust语言中的一个伟大的功能。然而,在某些情况下,我们可能需要对值的生命期有更明确的认识,这时可能需要恢复到原来的大类型签名方法。希望这些情况少而又少。通常,一个明确的clone,虽然效率不高,但可以节省大量的工作。