Rust 2024 中 `impl Trait` 的变更

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

Rust 2024 中,返回位置的 `impl Trait` 的默认行为将发生变化。这些变化旨在简化 `impl Trait`,使其更好地匹配大多数人的需求。我们还增加了一种灵活的语法,让你在需要时能够完全控制。

概要

从 Rust 2024 开始,我们正在更改返回位置 `impl Trait` 的隐藏类型可以使用哪些泛型参数的规则:

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

新的显式语法称为“使用界定符”(use bound):例如,`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 上亲自尝试

在这种情况下你会得到的错误消息(“hidden type captures lifetime”)不是最直观的,但它确实提供了一个有用的建议来修复它:

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())
}

在此版本中,数据的生命周期 `'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 总是允许使用类型参数,并且有时允许使用生命周期参数(如果它们出现在界定符中)。如上所述,这个默认值是错误的,因为大多数函数实际上确实希望它们的返回类型允许使用生命周期参数:至少这有一个变通方法(暂且不提一些将在下面讨论的细节)。但这个默认值也是错误的,因为有些函数希望显式声明它们在返回类型中使用类型参数,而目前没有办法覆盖这一点。最初的意图是 type alias impl trait(类型别名 impl trait)会解决这个用例,但这将是一个非常不符合人体工程学的解决方案(而且由于其他复杂性,type alias 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` 函数对 `data`(类型为 `T`)中的每个元素应用 `context.process`。因为返回值使用了 `context`,所以它被声明为 `+ 'c`。我们在这里真正的目标是允许返回类型使用 `'c`;编写 `+ 'c` 达到了这个目标,因为 `'c` 现在出现在界定符列表中。然而,虽然编写 `+ 'c` 是一种使 `'c` 出现在界定符中的便捷方法,但它也意味着隐藏类型必须长于 `'c`。这个要求不是必需的,并且实际上会导致本例中出现编译错误(在 playground 上尝试)。

发生此错误的原因有点微妙。隐藏类型是基于 `data.into_iter()` 结果的迭代器类型,其中将包含类型 `T`。由于 `+ 'c` 界定符,隐藏类型必须长于 `'c`,这反过来意味着 `T` 必须长于 `'c`。但 `T` 是一个泛型参数,所以编译器需要一个 `where` 子句,如 `where T: 'c`。这个 `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);
  • 一种显式声明允许使用哪些类型的语法(可在任何版本中使用)。

新的显式语法称为“使用界定符”(use bound):例如,`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())
}

好耶!

`impl Trait` 可以包含 `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 中,默认是相反的。这意味着像这样调用 `indices()` 的代码将在 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()
}

这个实现限制只是暂时的,希望很快会被解除!你可以在 跟踪问题 #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` 的变体,它返回 newtype 的索引 `I` 而不是 `usize` 值,因此它包含一个 `use<I>` 声明。)

结论

本示例展示了 Edition 如何帮助我们消除 Rust 的复杂性。在 Rust 2021 中,关于 `impl Trait` 中何时可以使用生命周期参数的默认规则并未随着时间推移而得到很好的适应。它们经常未能表达用户的需求,并导致需要晦涩的变通方法。它们导致了其他矛盾,例如 `-> impl Future` 和 `async fn` 之间的矛盾,或者顶级函数和 trait 函数中返回位置的 `impl Trait` 语义之间的矛盾。

得益于 Edition,我们能够在不破坏现有代码的情况下解决这个问题。随着 Rust 2024 中新规则的到来,

  • 大多数代码将在 Rust 2024 中“正常工作”,避免了令人困惑的错误;
  • 对于需要标注的代码,我们现在有了更强大的标注机制,可以让你准确地表达你需要表达的内容。
  • 精确捕获(precise capture)在 RFC #3617 中提出,该 RFC 留下了一个关于语法的未解决问题,其跟踪问题是 #123432
  • 未解决的语法问题在 问题 #125836 中解决,该问题引入了本文使用的 `+ use<>` 符号。
  • 实现限制在 #130031 中跟踪。