How to fix lifetime mismatch in this situation?

I want to write a generic function named exec that can handle similar callback function. But I ran into a lifetime error. Rewriting exec into a macro_rules! fixes the problem, but I still prefer generic. How to fix exec so that it works?

Code that I own:

use std::fmt::Display;
use foreign_crate::ForeignType;

fn main() {
    dbg!(exec(ForeignType::foo));
    dbg!(exec(ForeignType::bar));
}

fn exec<Callback, Value>(callback: Callback) -> Option<String>
where
    Callback: FnOnce(&ForeignType) -> Option<Value>,
    Value: Display,
{
    let param = ForeignType;
    callback(&param).map(|x| x.to_string())
}

Code from a crate that I do not own:

use std::borrow::Cow;

pub struct ForeignType;

impl ForeignType {
    pub fn foo(&self) -> Option<&str> {
        unimplemented!()
    }

    pub fn bar(&self) -> Option<Cow<str>> {
        unimplemented!()
    }
}

Playground

Part of the puzzle is that callback types need higher-ranked for<'a> lifetime, so that you can have lifetime of the callback argument be limited to just the function call rather tried to some outer large scope.

Callback: for<'a> FnOnce(&'a ForeignType) -> Option<Cow<'a, str>>

But the second problem here is that Value doesn't take a lifetime and is declared outside of Callback: for<'a> scope, so there's no syntactic way to explain to the borrow checker that it's limited by the ad-hoc lifetime 'a.

Sadly, Callback: for<'a> FnOnce(&'a ForeignType) -> Option<impl Display + 'a> is not supported.

2 Likes

What you're saying is Rust doesn't have sufficient feature (other than macros) to make this works?

I suspect so, but maybe there's some clever workaround?

A non-zero-cost way to make it work is to return Box<dyn Display + 'a>.

I think macro is already zero-cost enough.

Anyway, is there already an RFC or a discussion at https://internals.rust-lang.org/ to solve this?

It does. A key thing to recognize here is that any type parameters introduced outside of a higher-ranked setting, like Value (and Callback) here...

fn exec<Callback, Value>(callback: Callback) -> Option<String>
where
    Callback: FnOnce(&ForeignType) -> Option<Value>,
    // ^^ AKA Callback: for<'x> FnOnce(&'x ForeignType) -> Option<Value>
    // HR setting:      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    Value: Display,

...correspond to a single monomorphized type (and lifetime-carrying structs with different lifetimes are different types). Here, Rust doesn't let you elide the return type (on stable [1]) or use impl Display in this position or do anything else to sneak in a generic, lifetime-dependent type underneath the higher-ranked binder directly. The helper trait CallOnce let's you introduce such a type (MaybeBorrowed) with bounds for a single lifetime, and then you can wrap a higher-ranked condition around that (CallAny, not strictly needed but more ergonomic).

This approach gets you further, but you still may hit other inference or normalization issues.


  1. on unstable, you can often write a trait for this type of scenario, because the unboxed_closures features lets you write the bounds without mentioning the output type correctly; however, sometimes you hit issue 90950, and for this particular case, it doesn't work because you need a bound on Value and not the output of the function, Option<Value> ↩︎

2 Likes

Damn, that's complicated! Assuming I want to redo that same thing but with different trait bounds (replacing Display with a different trait), does it mean I have to create another pair of CallOnce and CallAny?

It shouldn't [1], but it does, due to the aforementioned (in a ... note) #90950 or similar. That is, you should be able to do this:

 trait CallOnce<'a, LifetimeGuard = &'a Self> where /* ... */ {
-     type MaybeBorrowed: 'a + Display;
+     type MaybeBorrowed: 'a;
 }

 fn exec<Callback: CallAny>(callback: Callback) -> Option<String>
+where
+    for<'x> <Callback as CallOnce<'x>>::MaybeBorrowed: Display,
+    // (alternatively hiding this bound in `CallAny` doesn't work either)
 {

...but as you can see in the playground, normalization fails to apply, or something.


  1. unless I'm missing something ↩︎

1 Like

I did find a way to push the bound into the higher-ranked trait, sort of. Basically, the trait method on the implementation can take advantage of the high-ranked bound, where for whatever reason the stand-alone function with the same bounds cannot. Sorry for the random comments in the code, they're mostly for myself in case I want to add more to the issue on GitHub.

There's so much boilerplate involved, I don't know that you really save anything though. I'd probably want a macro for either approach if I needed many of these.