在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 函数通过传递一个&String 到make_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 值。这是一个有趣而重要的观察,我们稍后会回到这里。让我们继续探索,看看一个接受了&String 的make_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需要一个类型为的参数TT可能包含具有非静态寿命的引用,我们真的不知道(同样:非常重要!)。- 返回值是一个
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
为什么变通的方法有效
我们之前通过使参数的类型具体化来解决编译失败的问题。有两种相对简单的方法可以解决编译失败的问题,并且仍然保持类型的多态性。它们是:
- 将参数从
_: T改为_: &T - 将调用地点从
make_debug(&value)改为make_debug(value)
挑战在阅读下面的解释之前,请试着根据我们到目前为止的解释,自己弄清楚这些变化对编译的修正。
将参数改为&T
我们对T 的隐含要求是,它所包含的任何引用都有一个静态寿命。这是因为我们无法从类型签名中看出impl Debug 是否持有T 中的数据。然而,通过使参数本身成为一个引用,我们完全改变了球赛的情况。突然间,我们有了两个隐含的寿命,而不是'static 对T 的单一隐含寿命:
- 引用的寿命,我们称之为
'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_debug ,T 将被丢弃。
按表格审查
为了总结多态的情况,让我们再分出一个表,这次是比较参数是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,虽然效率不高,但可以节省大量的工作。