RPITIT allows more flexible code in comparison with raw RPIT in inherit impl

I've noticed the strange behaviour for me if I try to implement the same function either as an inherit impl or as a trait impl.

I want to use RPIT since I'm working with futures and want to avoid dynamic dispatch.

In the code below you'll find several fn definitions that I've tried to replicate:

  • with RPIT as inherit impl I'm able to successfully compile the definitions but it requires specifying all the lifetimes and I can only use a single one without rustc to complain. Because of the single lifetime I can't call this function twice in the same block
  • I can fix it by using Box + dyn with 2 lifetimes. That seems to work
  • If I introduce special trait and RPITIT in it I can just use with single lifetime specified and the ability to call the function twice in the same block
use std::future::Future;
use std::pin::Pin;
use std::marker::PhantomData;

#[derive(Default)]
struct Context<'a> {
    data: Option<&'a PhantomData<()>>
}

#[derive(Default)]
struct EvalInherit {
    expr: Option<Box<EvalInherit>>
}

impl EvalInherit {
    // NOTE: none of the definions below are working and be able to call eval 2 times
    //
    // fn eval<'a>(&'a self, ctx: &'a mut Context<'a>) -> impl Future + 'a {
    // fn eval<'a, 'b>(&'a self, ctx: &'b mut Context<'a>) -> impl Future + 'b {
    // fn eval<'a>(&'a self, ctx: &'a mut Context<'a>) -> Pin<Box<impl Future + 'a>> {
    // fn eval<'a, 'b>(&'a self, ctx: &'b mut Context<'a>) -> Pin<Box<impl Future + 'b>> {
    // 
    // NOTE: the dyn definition works fine. The lifetimes should be specified explicitly
    fn eval<'a, 'b>(&'a self, ctx: &'b mut Context<'a>) -> Pin<Box<dyn Future<Output = ()> + 'b>> {
        Box::pin(async {
            if let Some(expr) = &self.expr {
                expr.eval(ctx).await;
            }
        })
    }
}

trait Eval {
    fn eval<'a>(&'a self, ctx: &mut Context<'a>) -> impl Future;
}

#[derive(Default)]
struct EvalTrait {
    expr: Option<Box<EvalInherit>>
}

impl Eval for EvalTrait {
    // NOTE: for trait implementation we can call eval multiple times.
    // Also we specify only single lifetime
    fn eval<'a>(&'a self, ctx: &mut Context<'a>) -> impl Future {
        Box::pin(async {
            if let Some(expr) = &self.expr {
                expr.eval(ctx).await;
            }
        })
    }
}


#[tokio::main]
async fn main() {
    let mut ctx = Context::default();
    
    let eval_inherit = EvalInherit::default();
    eval_inherit.eval(&mut ctx).await;
    eval_inherit.eval(&mut ctx).await;
    
    let eval_trait = EvalTrait::default();
    eval_trait.eval(&mut ctx).await;
    eval_inherit.eval(&mut ctx).await;
}

(Playground)

I have following questions about this code:

  1. Is it ok that RPITIT looks much smarter and is able to elide lifetimes automatically and this works as I want to?
  2. Is this a bug or some kind of limitations of the raw RPIT right now?
  3. If this is a bug on what side? Is this RPIT is not working correctly or RPITIT allows too much flexibility for me?

Until edition 2024, RPIT outside of traits doesn't automatically capture lifetimes (whereas RPITIT and async fn do). You can usually fix the "undercapture" with some hacks.

trait Captures<T: ?Sized> {}
impl<T: ?Sized, U: ?Sized> Captures<T> for U {}

impl EvalInherit {
    fn eval<'a, 'b>(&'a self, ctx: &'b mut Context<'a>) 
    -> 
        impl Future + Captures<&'b &'a ()>
    {
        Box::pin(async {
            if let Some(expr) = &self.expr {
                expr.eval(ctx).await;
            }
        })
    }
}
3 Likes

Thank you, it does work.

Could you explain or give some reading links, please, about this hack? How is &'b &'a is different from 'a + 'b?

You need to capture the lifetimes in some way by mentioning them to avoid the "undercapturing", where the compiler insists the return can't rely on the input lifetimes at all. Putting them into the parameter of a trait bound (which is otherwise meaningless) is enough to do this without imposing other unwanted constraints.

In contrast 'a + 'b imposes two bounds on the return type (RetTy: 'a and RetTy: 'b). But in the OP, you're capturing a &'b mut Context<'a> which is only valid for 'b (and also implicitly asserts that 'a: 'b). The only way you can meet the 'a bound is if 'b: 'a as well -- if 'a == 'b. But then you have a &'a mut Context<'a> and that's never what you want. So 'a + 'b does impose other unwanted constraints.

You can read more in the RFC to make all RPITs capture all lifetimes by default.

2 Likes

Thank you

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.