Rust 2024 中 `impl Trait` 的更改

2024 年 9 月 5 日 · Niko Matsakis 代表 语言团队

Rust 2024 中,返回位置的 impl Trait 的默认工作方式正在发生变化。这些更改旨在简化 impl Trait,使其更好地匹配人们在大多数情况下想要的结果。我们还添加了一个灵活的语法,在您需要时可以完全控制它。

TL;DR

从 Rust 2024 开始,我们将更改通用参数在返回位置的 impl Trait 的隐藏类型中使用的规则

  • 新的默认设置是,返回位置的 impl Trait 的隐藏类型可以使用作用域内的任何通用参数,而不仅仅是类型(仅在 Rust 2024 中适用);
  • 一种显式声明允许使用哪些类型的语法(可在任何版本中使用)。

新的显式语法称为“使用边界”:例如,impl Trait + use<'x, T> 将表示允许隐藏类型使用 'xT(但不允许使用作用域内的任何其他通用参数)。

请继续阅读以了解详情!

背景:返回位置的 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 的生命周期 'dimpl 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> 将表示允许隐藏类型使用 'xT(但不允许使用作用域内的任何其他通用参数)。

现在可以默认使用生命周期

在 Rust 2024 中,默认设置是返回位置的 impl Trait 值的隐藏类型使用作用域内的任何通用参数,无论是类型还是生命周期。这意味着此博客文章的初始示例在 Rust 2024 中可以正常编译(通过在 Playground 中将 Edition 设置为 2024 来自己尝试一下

fn process_data(
    data: &[Datum]
) -> impl Iterator<Item = ProcessedDatum> {
    data
        .iter()
        .map(|datum| datum.process())
}

耶!

Impl Traits 可以包含 use<> 约束来精确指定它们使用的泛型类型和生命周期

作为此更改的副作用,如果您手动将代码迁移到 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 Futureasync fn 之间,或者顶级函数和 trait 函数中返回位置 impl Trait 的语义之间。

感谢版本,我们能够在不破坏现有代码的情况下解决这个问题。随着 Rust 2024 中引入的新规则,

  • 大多数代码在 Rust 2024 中将“正常工作”,避免混淆错误;
  • 对于需要注释的代码,我们现在有更强大的注释机制,可以让您准确地说出您需要的内容。

附录:相关链接

  • 精确捕获在 RFC #3617 中提出,该提案留下了一个关于语法的未解决问题,其跟踪问题是 #123432
  • 未解决的语法问题在 issue #125836 中得到解决,该问题引入了本文中使用的 + use<> 表示法。
  • 实现限制在 #130031 中跟踪。