RPITIT Interaction with Borrow Checker

Greetings,
I ran into behavior I don't understand with the (relatively) new return-position impl trait in trait feature's interaction with the borrow checker. See the following example.
The following code compiles fine (playground):

#![allow(unused)]
use std::collections::HashMap;
use std::hash::Hash;

trait Foo {
    fn foo(&self) -> impl Hash + Eq;
}

impl Foo for () {
    fn foo(&self) -> impl Hash + Eq {
        (1i32, 3i32)
    }
}

trait Bar {
    type T: Hash + Eq;
    fn bar(&self) -> Self::T;
}

impl Bar for () {
    type T = (i32, i32);
    fn bar(&self) -> Self::T {
        (1, 3)
    }
}

fn main() {
    let mut m = HashMap::new();
    let mut closure = || {
        let f = ();
        m.insert(f.bar(), 0);
    };

    closure();
}

However, if you replace

        m.insert(f.bar(), 0);

with

        m.insert(f.foo(), 0);

then the compilation fails:

   Compiling playground v0.0.1 (/playground)
error[E0597]: `f` does not live long enough
  --> src/main.rs:31:18
   |
28 |     let mut m = HashMap::new();
   |         ----- lifetime `'1` appears in the type of `m`
29 |     let mut closure = || {
30 |         let f = ();
   |             - binding `f` declared here
31 |         m.insert(f.foo(), 0);
   |                  ^------
   |                  |
   |                  borrowed value does not live long enough
   |                  argument requires that `f` is borrowed for `'1`
32 |     };
   |     - `f` dropped here while still borrowed

For more information about this error, try `rustc --explain E0597`.
error: could not compile `playground` (bin "playground") due to 1 previous error

What causes this? What causes the difference to the Bar trait? From what I gather from the rust reference, the return type is de-sugared to be the return type in the trait implementation.
So this means, that if I change the trait implementation from:

    fn foo(&self) -> impl Hash + Eq {

to

    fn foo(&self) -> (i32, i32) {

the code compiles as well.
However, this is not good for real world scenarios, where you'd bound something to the trait, and then we're left with the more opaque impl Eq + Hash type.

Why is the borrow checker assuming the return value is a reference? Moreover, even if you change the bound requirements to be Hash + Eq + Clone, and you attempt to clone the returned value, that doesn't satisfy the borrow checker either.
I also considered the possibility that the lifetime of the type itself isn't long enough, so I tried adding 'static to the return type bounds as well, and that didn't change anything (though I must admit I don't understand what that means well enough, anyway).

I guess the main question is, out of curiosity, is there any way around this other than falling back to the associated type syntax?

RPITITs, like async fns, "capture" all of the generic inputs to the method. That includes the lifetime on the &self parameter. It's sort as if Foo used a GAT instead of a non-generic associated type...

trait Foo {
    type T<'a>: Hash + Eq where Self: 'a;
    fn foo(&self) -> Self::T<'_>;
}

...except as an opaque type, callers have to assume that the type made use of all the generic parameters.

If you're familiar with non-in-trait RPIT, they capture all type parameters, but only capture lifetime parameters mentioned in the return type. However, the plan is that they will capture all parameters in the next edition. You can follow some links to the RFC and other conversations to read more about capturing.

The plan is to also introduce some inline way of specifying which parameters are captured. And also, at least eventually, allow using impl Trait in associated type position (TAITIT? I think?):

impl Bar for () {
    type T = impl Hash + Eq;
    fn bar(&self) -> Self::T {
        (1, 3)
    }
}

The only difference between this and your workaround is that the associated type is now opaque, allowing you (as the implementer) more flexibility. However, another important aspect of TAITIT is that it would work with unnameable types... e.g. unboxed closures and futures.

You can model "precise capturing" more completely with a GAT parameterized over exactly what you want to capture (plus TAITIT on GAT to complete the picture).

5 Likes

Ew, gross. Magic by default, ugly use syntax if you don't want that.

Having more experience with (fully capturing) RPITIT now, and having (partially capturing) RPIT to compare against, I think I would have preferred having to declare captures from the start. Lifetime capturing RPIT-esques have the same problems that deny_elided_lifetimes_in_paths addresses...

struct S<'a>(&'a Whatever);

// 100k lines away, invisibly reborrow Self
fn method(&self) -> S { .. }

...but, until we get precise capturing, no great way to make the capture visible.

-> impl<..> Trait is what I had in mind, but I'll take use<..> over nothing.[1]

(I also felt the downsides of only having the TAIT solution to overcapturing would be large enough to not migrate in some cases, and that the "we'll infer it all for you" approach was doomed to be incomplete. So from a migration/overcapturing-mitigation POV, I'm happy that precise capturing is what we're getting at the switch-over point.)


  1. I'm even open to being convinced having use<> for various reasons is best, but haven't had the time to digest the arguments. ↩ī¸Ž

Certainly, but:

  • the current direction seems to be the exact opposite of that: everything is captured by default, and you have to opt out – that's the wrong way around, nothing should be captured unless explicitly mentioned
  • I find the "use" syntax ugly: both the keyword and the ordering feel really out of place in the context of traits, but that's a purely syntactic consideration.
1 Like

We're in agreement.

1 Like