Understanding proper usage of precise capturing in the 2024 edition

(This post is about upcoming changes in the not-yet-stable 2024 language edition.)
Consider the following code:

use std::future::Future;

pub struct Foo(Bar);
struct Bar;

impl Foo {
    pub fn do_thing(&self) -> impl Future<Output = ()> + Send + 'static {
        self.with_ref(|bar| bar.do_thing())
    }
    fn with_ref<R>(&self, f: impl FnOnce(&Bar) -> R) -> R {
        f(&self.0)
    }
}
impl Bar {
    fn do_thing(&self) -> impl Future<Output = ()> + Send + 'static {
        async {}
    }
}

This code compiles in the 2021 edition. In the 2024 edition (still unstable), it fails, as expected due to the new return-position-impl Trait capture rules, which specify that Bar::do_thing() captures the self lifetime:

error: lifetime may not live long enough
  --> src/lib.rs:10:29
   |
10 |         self.with_ref(|bar| bar.do_thing())
   |                        ---- ^^^^^^^^^^^^^^ returning this value requires that `'1` must outlive `'2`
   |                        |  |
   |                        |  return type of closure `impl Future<Output = ()> + Send + 'static` contains a lifetime `'2`
   |                        has type `&'1 Bar`

The solution to this problem is to use the precise capturing syntax (also unstable, but stabilized for 1.82) to specify that the futures don't capture anything, recovering the previous meaning:

#![feature(precise_capturing)]

pub struct Foo(Bar);
struct Bar;

impl Foo {
    pub fn do_thing(&self) -> impl Future<Output = ()> + Send + use<> {
        self.with_ref(|bar| bar.do_thing())
    }
    fn with_ref<R>(&self, f: impl FnOnce(&Bar) -> R) -> R {
        f(&self.0)
    }
}
impl Bar {
    fn do_thing(&self) -> impl Future<Output = ()> + Send + use<> {
        async {}
    }
}

Notice that the impl Futures no longer include + 'static — they don't seem to need it. My question, then, is: is there any remaining use for specifying + 'static or any other lifetime bound on the type? If so, does it benefit the caller or the callee?

I believe I understand that the reason use<> has to be introduced is that + use<'a, 'b> means that the value is valid for the intersection of 'a and 'b, whereas + 'a + 'b means that the value is valid for the union of 'a and 'b. But do they differ in any other way? Does + use<> have all the same effects as + 'static for the user of the impl Trait type?

What consequences should we be thinking about for our API designs and semver compatibility when using these new capturing rules and syntax?

2 Likes

Sure, for example:

trait Trait {
    type Associated: std::fmt::Display + Default + 'static;
}
impl Trait for &() {
    type Associated = i32;
}

// try remove the 'static and see ^^
// (`use<T>` would also be the implicit default)
fn f<T: Trait>() -> impl std::fmt::Display + use<T> + 'static {
    T::Associated::default()
}

fn g(x: impl std::fmt::Display + 'static) {
    // imagine this function can leak x into global memory, etc… 
    // and this required `'static` for the argument
}

fn h<'a>(_: &'a ()) {
    g(f::<&'a ()>())
}

fn main() {
    let x = ();
    h(&x);
}

I don’t think I’m fully able to explain the current model of reasoning behind how these opaque types work, that depend on some (type or lifetime) parameters but might not inherit their lifetimes, i.e. can potentially live longer.

I would also have to dig a bit to find the relevant discussions around when this behavior was introduced. IIRC, it has not always been this way, I swear I’ve looked at at least one of the relevant PRs (the discussion, no the code) before a decent time ago.

Any actual type in Rust currently does follow the rule of “if it’s P is a parameter of T, then T: 'a requires P: 'a”, but I suppose opaque types are not actual types, so we can have the opaque type with Opaque: 'static, and Opaque: use<T> but without T: 'static.

N.b. I'm assuming no extreme changes have happened between the RFC acceptance and today.

That's an additional bound on the output type. In the example, the bound can be assumed, because the opaque has no inputs (due to use <>) that enable it to capture something non-'static.

It's similar to outlives for projections. If you have a impl Trait<T1, T2, T3> for T0, and all of T*: 'static, then there is no way that <T0 as Trait<T1, T2, T3>>::Assoc could "name" (capture) a non-'static lifetime, either directly or via a non-'static-bound-meeting type. Or in other words: if all of the associated item's "inputs" are 'static, the associated item is too.

Precise capturing is a way to limit the inputs to the opaque type.


However, if use<..> isn't empty, then + 'lifetime will still be an additional bound on the type, like it is today. And currently, any non-lifetime generics must be used.[1] So in those cases, a lifetime bound will still make a difference.

fn use_generic<T>() -> impl Future<Output = ()> + Send + use<T> {
    async {}
}

// Fails unless a `T: 'static bound is added
fn example<T>() -> impl Sized + 'static {
    use_generic::<T>()
}

// Fails: all type parameters are required to be mentioned in the precise captures list
fn must_use_generic<T>() -> impl Future<Output = ()> + Send + use<> {
    async {}
}

Here's some breadcrumbs from one of my prior dives. The second link is to Niko explaining a model based on trait projection.


  1. In traits, that would include all non-lifetime inputs to the trait, including Self ... according to the RFC. But use in traits is not being stabilized yet I see. ↩︎

Hmm, I think I'll have to play with this some more to make it make sense to my brain, but thanks for the pointers to which situations to think about.

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.