Playing with HRTBs

I decided to poke at the edges of what higher-ranked type bounds can and can’t do, and I thought the results might be interesting to some people here. In particular, the behaviors for impl Trait and dyn Trait are a bit surprising.

trait Trait where for<'a> &'a Self: Trait {
    fn foo(self)->() where Self:Sized {()}
}

// This is ok
impl<'a, 'b, T:?Sized> Trait for &'a &'b T where &'b T:Trait {}

// This complains of not being general enough
// impl<'a, 'b:'a, T:?Sized> Trait for &'a &'b T where &'b T:Trait {}

// All of these are ok
impl<'a, T> Trait for &'a Vec<T> {}
impl<T> Trait for Vec<T> {}
impl<'a, T> Trait for &'a [T] {}

// This works (with arbitrary numbers of `&`s)
fn f1(x:&Vec<()>) {
    x.foo();
}

// Typechecker infinite recursion
//fn f2(x:&impl Trait) {
//    x.foo()
//}

// This works, so Trait is object safe
fn f3(x:Vec<()>) {
    let _:&dyn Trait = &x;
}

// Complains that `foo` cannot be invoked on a trait object
// but `dyn Trait` implements `Trait`
// which means `&dyn Trait` (which is Sized) *must* implement Trait per the where clause
//fn f3(x:&dyn Trait) {
//    (&&x).foo()
//}

(Playground)

Errors:

   Compiling playground v0.0.1 (/playground)
warning: function is never used: `f1`
  --> src/lib.rs:17:4
   |
17 | fn f1(x:&Vec<()>) {
   |    ^^
   |
   = note: `#[warn(dead_code)]` on by default

warning: function is never used: `f3`
  --> src/lib.rs:27:4
   |
27 | fn f3(x:Vec<()>) {
   |    ^^

warning: 2 warnings emitted

    Finished dev [unoptimized + debuginfo] target(s) in 0.31s

There is indeed an issue / a limitation of the compiler here:

  1. Adding a where clause is a "gained property" within the block that contains it, and an added "burden" for users / implementors.

    • This is why you can add:
      fn bar (self) where Self : Sized { self.foo(); }
      
      inside the trait without problem: Self is there, within the default implementation, some unknown impl ?Sized + Trait that also carries the for<'any> &'any Self : Trait and Self : Sized properties, hence allowing the usage of .foo().
  2. Outside of such block, thus, we are mostly dealing with an added burden.

    Although sometimes, the Rust compiler is indeed smart enough to figure out a chain of causation (e.g., that T : Trait must imply that for<'any> &'any T : Trait since any implementor would have had that burden on them), so as to grant a property, this is not (yet) always the case. See the RFC for "implied bounds" for more info.

    I suspect you are hitting this situation here.

  3. Whatever the situation, dyn Trait is indeed special: you can create that type "whenever you want", and we'd expect dyn Trait : Trait to hold. But if you have never had the burden to impl Trait for &'_ dyn Trait {}, then for sure the Rust compiler will not invent that impl for you! So even if implied bounds or something along those lines were to work, I don't think we'll ever be able to have dyn Trait : Trait without the required impl on the reference type.

  4. This makes it so dyn Trait may not be covered by impl ?Sized + Trait; but given such a type T : ?Sized + Trait, then implied bounds ought to give us that for<'any> &'any T : Trait.

  5. Your example with the "infinite type-checking recursion" does show us that it is not yet the case. So the impl Trait sugar is unusable: we need to add the "property" ourselves, even if it may seem redundant:

    trait Trait
    where
        for<'any> &'any Self : Trait,
    {
        fn foo (self)
        where
            Self : Sized,
        {}
    }
    
    impl<'lt, T : ?Sized> Trait for &'_ &'lt T
    where
        &'lt T : Trait,
    {}
    
    fn check<ImplTrait : ?Sized + Trait> (x: &'_ ImplTrait)
    where
        for<'any> &'any ImplTrait : Trait,
    {
        x.foo();
    }
    
  6. Another option, depending on your use case, is to get rid of the burden altogether with a super generic impl:

    impl<T : ?Sized + Trait> Trait for &'_ T {}
    

    (at which point we can get rid of the where clause).

    And then everything Just Works, but because we have changed the "rules of the game".


Yeah, that's just stupid, I hope that gets fixed soon, it can become quite annoying: implied lifetime bounds (such as 'inner : 'outer) are currently not part of the initial set of constraints, so if you make them explicit, Rust considers you have lost generality :woman_facepalming:.


These limitations are not really tied to HRTB, rather to where clauses on traits that are not of the form where Self : ... (since those can be treated as supertraits / superbounds).

1 Like

This might be worth adding to the object-safety rules: if there are any where clauses that constrain related types, it's should probably illegal to create a dyn Trait if those bounds aren't satisfied for the resulting object.


This is unfortunate as I originally hit this when trying to define a marker trait that served as a shorthand for a complicated set of type bounds. I suppose that'll have to be a macro's job until the implied bounds work gets more advanced.


That's the solution I ended up with, which doubled the number of methods in the trait (consuming + by ref), with a default implementation of the consuming method that dispatches to the reference one if it's not overridden.


Combined with the occasional T might not live long enough errors that force the lifetime bounds to be explicitly spelled out, this sometimes makes the for<'a> construction completely unusable. If I run into this again, I'll try to reduce it to something that can go in a bug report.

1 Like