Rust 2024 中,返回位置的 impl Trait
的默认工作方式正在发生变化。这些更改旨在简化 impl Trait
,使其更好地匹配人们在大多数情况下想要的结果。我们还添加了一个灵活的语法,在您需要时可以完全控制它。
TL;DR
从 Rust 2024 开始,我们将更改通用参数在返回位置的 impl Trait
的隐藏类型中使用的规则
- 新的默认设置是,返回位置的
impl Trait
的隐藏类型可以使用作用域内的任何通用参数,而不仅仅是类型(仅在 Rust 2024 中适用); - 一种显式声明允许使用哪些类型的语法(可在任何版本中使用)。
新的显式语法称为“使用边界”:例如,impl Trait + use<'x, T>
将表示允许隐藏类型使用 'x
和 T
(但不允许使用作用域内的任何其他通用参数)。
请继续阅读以了解详情!
impl Trait
背景:返回位置的 这篇博客文章关注的是返回位置的 impl Trait
,例如以下示例
fn process_data(
data: &[Datum]
) -> impl Iterator<Item = ProcessedDatum> {
data
.iter()
.map(|datum| datum.process())
}
这里在返回位置使用 -> impl Iterator
表示该函数返回“某种迭代器”。实际类型将由编译器根据函数体确定。它被称为“隐藏类型”,因为调用者无法确切知道它是什么;他们必须针对 Iterator
trait 进行编码。但是,在代码生成时,编译器将根据实际的精确类型生成代码,这确保了调用者可以完全优化。
虽然调用者不知道确切的类型,但他们确实需要知道它将继续借用 data
参数,以便他们可以确保在迭代发生时 data
引用保持有效。此外,调用者必须能够仅基于类型签名来弄清楚这一点,而无需查看函数体。
Rust 当前的规则是,返回位置的 impl Trait
值只有在该引用的生命周期出现在 impl Trait
本身时才能使用引用。在此示例中,impl Iterator<Item = ProcessedDatum>
没有引用任何生命周期,因此捕获 data
是非法的。您可以在 playground 上自行查看。
在此场景中您收到的错误消息(“隐藏类型捕获生命周期”)不是最直观的,但它确实附带了一个有关如何修复它的有用建议
help: to declare that
`impl Iterator<Item = ProcessedDatum>`
captures `'_`, you can add an
explicit `'_` lifetime bound
|
5 | ) -> impl Iterator<Item = ProcessedDatum> + '_ {
| ++++
遵循此建议的稍微更明确的版本,函数签名变为
fn process_data<'d>(
data: &'d [Datum]
) -> impl Iterator<Item = ProcessedDatum> + 'd {
data
.iter()
.map(|datum| datum.process())
}
在此版本中,data
的生命周期 'd
在 impl Trait
类型中被显式引用,因此允许使用它。这也是对调用者的一个信号,即 data
的借用必须持续到迭代器在使用中,这意味着它(正确地)标记了类似这样的示例中的错误(在 playground 上尝试一下)
let mut data: Vec<Datum> = vec![Datum::default()];
let iter = process_data(&data);
data.push(Datum::default()); // <-- Error!
iter.next();
此设计的可用性问题
关于 impl Trait
中可以使用哪些通用参数的规则是早期根据有限的示例确定的。随着时间的推移,我们注意到它们存在许多问题。
不是正确的默认值
对主要代码库(包括编译器和 crates.io 上的 crate)的调查发现,绝大多数返回位置的 impl trait 值都需要使用生命周期,因此不捕获的默认行为没有帮助。
不够灵活
当前的规则是,返回位置的 impl trait 总是允许使用类型参数,并且有时允许使用生命周期参数(如果它们出现在边界中)。如上所述,此默认设置是错误的,因为大多数函数实际上确实希望其返回类型允许使用生命周期参数:至少有一个解决方法(减去我们将在下面提到的一些细节)。但是默认设置也是错误的,因为某些函数希望显式声明它们不使用返回类型中的类型参数,并且现在无法覆盖该设置。最初的意图是类型别名 impl trait 可以解决此用例,但这将是一个非常不符合人体工程学的解决方案(并且由于其他复杂性,稳定类型别名 impl trait 的时间比预期的要长)。
难以解释
由于默认设置不正确,用户经常会遇到这些错误,而且这些错误也很微妙且难以解释(如这篇文章所证明的那样!)。添加编译器提示以建议 + '_
会有所帮助,但用户必须遵循他们不完全理解的提示并不是很好。
不正确的建议
向 impl Trait
添加 + '_
参数可能令人困惑,但它并非特别困难。不幸的是,它通常是错误的注释,导致不必要的编译器错误——并且正确的修复要么很复杂,要么有时甚至不可能。考虑以下示例
fn process<'c, T> {
context: &'c Context,
data: Vec<T>,
) -> impl Iterator<Item = ()> + 'c {
data
.into_iter()
.map(|datum| context.process(datum))
}
在这里,process
函数将 context.process
应用于 data
(类型为 T
)中的每个元素。由于返回值使用 context
,因此将其声明为 + 'c
。我们这里的真正目标是允许返回类型使用 'c
;编写 + 'c
可以实现该目标,因为 'c
现在出现在边界列表中。但是,虽然编写 + 'c
是使 'c
出现在边界中的一种便捷方法,但也意味着隐藏类型必须比 'c
更长寿。此要求是不必要的,并且实际上会导致此示例中的编译错误(在 playground 上尝试一下)。
出现此错误的原因有点微妙。隐藏类型是基于 data.into_iter()
结果的迭代器类型,它将包括类型 T
。由于 + 'c
边界,隐藏类型必须比 'c
更长寿,这反过来意味着 T
必须比 'c
更长寿。但是 T
是一个通用参数,因此编译器需要一个类似 where T: 'c
的 where 子句。此 where 子句表示“创建对类型 T
的生命周期为 'c
的引用是安全的”。但实际上我们没有创建任何此类引用,因此不需要 where 子句。之所以需要它只是因为我们使用了添加 + 'c
到我们的 impl Trait
边界的便捷但有时不正确的解决方法。
就像以前一样,此错误很模糊,涉及到 Rust 类型系统中更复杂的方面。与之前不同,没有简单的修复方法!实际上,此问题在编译器中经常发生,导致了一种称为 Captures
trait 的模糊解决方法。太糟糕了!
我们调查了 crates.io 上的 crate,发现绝大多数涉及返回位置 impl trait 和泛型的情况都有过强的边界,这可能会导致不必要的错误(尽管它们通常以不会触发错误的方式简单使用)。
与 Rust 的其他部分不一致
当前的设计还引入了与 Rust 其他部分的不一致之处。
async fn 脱糖
Rust 将 async fn
定义为脱糖为返回 -> impl Future
的普通 fn
。因此,您可能期望像 process
这样的函数
async fn process(data: &Data) { .. }
...会(大致)脱糖为
fn process(
data: &Data
) -> impl Future<Output = ()> {
async move {
..
}
}
实际上,由于围绕可以使用哪些生命周期的规则存在问题,这不是实际的脱糖。实际的脱糖是使用一种特殊的 impl Trait
,它可以使用所有生命周期。但是,这种形式的 impl Trait
没有向最终用户公开。
trait 中的 impl trait
当我们追求 trait 中 impl trait 的设计(RFC 3425)时,我们遇到了许多与生命周期捕获相关的挑战。为了获得我们想要的工作对称性(例如,可以在 trait 中编写 -> impl Future
并以预期效果实现),我们必须更改规则以允许隐藏类型统一使用所有通用参数(类型和生命周期)。
Rust 2024 设计
上述问题促使我们在 Rust 2024 中采用一种新方法。该方法是两件事的组合
- 新的默认设置是,返回位置的
impl Trait
的隐藏类型可以使用作用域内的任何通用参数,而不仅仅是类型(仅在 Rust 2024 中适用); - 一种显式声明允许使用哪些类型的语法(可在任何版本中使用)。
新的显式语法称为“使用边界”:例如,impl Trait + use<'x, T>
将表示允许隐藏类型使用 'x
和 T
(但不允许使用作用域内的任何其他通用参数)。
现在可以默认使用生命周期
在 Rust 2024 中,默认设置是返回位置的 impl Trait
值的隐藏类型使用作用域内的任何通用参数,无论是类型还是生命周期。这意味着此博客文章的初始示例在 Rust 2024 中可以正常编译(通过在 Playground 中将 Edition 设置为 2024 来自己尝试一下)
fn process_data(
data: &[Datum]
) -> impl Iterator<Item = ProcessedDatum> {
data
.iter()
.map(|datum| datum.process())
}
耶!
use<>
约束来精确指定它们使用的泛型类型和生命周期
Impl Traits 可以包含 作为此更改的副作用,如果您手动将代码迁移到 Rust 2024(不使用 cargo fix
),您可能会在具有 impl Trait
返回类型的函数的调用者中开始收到错误。这是因为现在假设这些 impl Trait
类型可能会使用输入生命周期,而不仅仅是类型。要控制这一点,您可以使用新的 use<>
约束语法,该语法显式声明隐藏类型可以使用哪些泛型参数。我们移植编译器的经验表明,很少需要进行更改——大多数代码在新默认值下实际上效果更好。
上述情况的例外是当函数接收一个引用参数,该参数仅用于读取值,而不包含在返回值中时。一个这样的例子是以下函数 indices()
:它接收一个类型为 &[T]
的切片,但它唯一做的就是读取长度,该长度用于创建迭代器。返回值中不需要切片本身。
fn indices<'s, T>(
slice: &'s [T],
) -> impl Iterator<Item = usize> {
0 .. slice.len()
}
在 Rust 2021 中,此声明隐式表示 slice
未在返回类型中使用。但在 Rust 2024 中,默认值相反。这意味着像这样的调用者将在 Rust 2024 中停止编译,因为它们现在假设 data
被借用直到迭代完成。
fn main() {
let mut data = vec![1, 2, 3];
let i = indices(&data);
data.push(4); // <-- Error!
i.next(); // <-- assumed to access `&data`
}
这实际上可能是您想要的!这意味着您可以稍后修改 indices()
的定义,使其实际上将 slice
包含在结果中。换句话说,新的默认值延续了 impl Trait
的传统,即保持函数更改其实现而不破坏调用者的灵活性。
但如果这不是您想要的呢?如果您想保证 indices()
不会在其返回值中保留对其参数 slice
的引用呢?您现在可以通过在返回类型中包含 use<>
约束来显式声明哪些泛型参数可以包含在返回类型中。
在 indices()
的情况下,返回类型实际上不使用任何泛型,所以我们理想情况下应该写 use<>
fn indices<'s, T>(
slice: &'s [T],
) -> impl Iterator<Item = usize> + use<> {
// -----
// Return type does not use `'s` or `T`
0 .. slice.len()
}
实现限制。不幸的是,如果您今天在 nightly 版本上尝试上述示例,您会看到它无法编译(自己尝试一下)。这是因为 use<>
约束仅部分实现:目前,它们必须始终至少包含类型参数。这对应于早期版本中 impl Trait
的限制,即必须始终捕获类型参数。在这种情况下,这意味着我们可以编写以下内容,这也避免了编译错误,但仍然比必要的保守(自己尝试一下)
fn indices<T>(
slice: &[T],
) -> impl Iterator<Item = usize> + use<T> {
0 .. slice.len()
}
此实现限制只是暂时的,希望很快能够解除!您可以在 跟踪 issue #130031 中关注当前状态。
替代方案:'static
约束。对于捕获任何引用的特殊情况,也可以使用 'static
约束,如下所示(自己尝试一下)
fn indices<'s, T>(
slice: &'s [T],
) -> impl Iterator<Item = usize> + 'static {
// -------
// Return type does not capture references.
0 .. slice.len()
}
在这种情况下,'static
约束很方便,特别是考虑到当前 use<>
约束的实现限制,但 use<>
约束总体上更灵活,因此我们预计它们会更频繁地使用。(例如,编译器有一个 indices
的变体,它返回新类型化的索引 I
而不是 usize
值,因此它包含一个 use<I>
声明。)
结论
此示例演示了版本如何帮助我们消除 Rust 中的复杂性。在 Rust 2021 中,关于何时可以在 impl Trait
中使用生命周期参数的默认规则并没有很好地发展。它们经常没有表达用户需要的内容,并导致需要使用晦涩的解决方法。它们导致其他不一致,例如 -> impl Future
和 async fn
之间,或者顶级函数和 trait 函数中返回位置 impl Trait
的语义之间。
感谢版本,我们能够在不破坏现有代码的情况下解决这个问题。随着 Rust 2024 中引入的新规则,
- 大多数代码在 Rust 2024 中将“正常工作”,避免混淆错误;
- 对于需要注释的代码,我们现在有更强大的注释机制,可以让您准确地说出您需要的内容。
附录:相关链接
- 精确捕获在 RFC #3617 中提出,该提案留下了一个关于语法的未解决问题,其跟踪问题是 #123432。
- 未解决的语法问题在 issue #125836 中得到解决,该问题引入了本文中使用的
+ use<>
表示法。 - 实现限制在 #130031 中跟踪。