Generic methods over object-safe traits


#1

We can add generic methods to otherwise object-safe traits by adding a Sized requirement:

trait T {
    fn spec(&self) -> u32;
    fn gen<X: From<u32>>(&self) -> X where Self: Sized {
        From::from(self.spec())
    }
}

but calling generic functions from type-erased trait objects is not possible:

// okay
fn f(t: &T) -> u32 { t.spec() }

// error: the `gen` method cannot be invoked on a trait object
// fn g_(t: &T) -> u32 { t.gen() }

This is quite understandable (generic functions cannot be included in a vtable), but we can get around it:

impl<'a> T for &'a T {
    fn spec(&self) -> u32 {
        (*self).spec()
    }
}

fn g(t: &T) -> u32 { (&t).gen() }

(also with impl T for Box<T> { ... }).

Why does the above work? Because the trait-object &'a T is a Sized object, and the above impl rule creates a vtable for it. But there’s one niggling issue: the need to use (&t).gen() instead of t.gen().

Actually, we can get around that issue by making gen consume self instead of &self, but this causes t.gen() to consume t:

trait T {
    ...
    fn gen_consume<X: From<u32>>(self) -> X where Self: Sized {
        From::from(self.spec())
    }
}

fn h(t: &T) -> u32 { t.gen_consume() }

but calling gen_consume on a type which is not already a reference (or supports Copy) therefore consumes the object, unless manually referenced (e.g. (&o).gen_consume()).

This approach is workable, except perhaps for one thing: the error message on g_ neither suggests the above impl rule, nor is affected by it, so there is no hint that users can fix it simply by replacing t with (&t). Since many users find traits bewildering and may not understand how dynamic dispatch works I consider this a significant drawback — is it fixable?

Link to the above on play.rust-lang.org

There are some alternatives: using an extension trait separates the object-safe and non-object-safe parts of the trait, and thereby allows use of generic methods on the type-erased parent trait:

trait XT: T + Sized {
    fn gen<X: From<u32>>(&self) -> X {
        From::from(self.spec())
    }
    fn gen_consume<X: From<u32>>(self) -> X {
        From::from(self.spec())
    }
}
impl<TT: T + Sized> XT for TT {}

fn g(t: &T) -> u32 { t.gen() }

What’s more, this has reasonable error messages if the extension trait is not in scope:

error[E0599]: no method named `gen` found for type `&T` in the current scope
  --> src/main.rs:29:29
   |
29 |     fn g(t: &T) -> u32 { &t.gen() }
   |                             ^^^
   |
   = help: items from traits can only be used if the trait is in scope
   = note: the following trait is implemented but not in scope, perhaps add a `use` for it:
           candidate #1: `use XT;`

Serde uses a similar trick on type-erased trait-objects, mainly different in that the generic trait does not inherit the object-safe trait.

I should also mention Huon Wilson’s blog post on allowing generic methods and object-safe traits.


So why write all this?

  1. These are tricky concepts to understand and it is not immediately obvious how or why the tricks work; hopefully this helps a few people understand.
  2. To ask: is there a way to make t.gen::<u32>() work for t: &T without the extra trait?
  3. Can the compiler at least suggest usage of (&t).gen()?

#2

If we contrast the above with the boxed version:

impl T for Box<T> {
    fn spec(&self) -> u32 {
        (*self).spec()
    }
}

fn i(t: Box<T>) -> u32 { t.gen() }

we see that it’s not necessary to write (&t).gen() even though gen is implemented in this case for Box<T> and therefore takes parameter of type &Box<T>. Why: because Rust has a special rule to automatically reference the object in a function call, used very commonly (e.g. in o.spec() the object o is implicitly referenced).

The exception is when the object is already referenced (as in t.spec()); unfortunately it appears this exception rule does not apply as desired when the function is implemented for both t and &t but only one is actually usable, as in our example t.gen().


#3

@dhardy, Box version is giving me a stack overflow in the playground. The reference version does work.

I’m also wondering what that code is actually good for. Yes a generic method is invoked but it’s going to be the same method whatever the underlying type?..


#4

Oh, sometimes useful generic methods can be built on top of non-generic ones, as in Iterator and Rng.


#5

You’re right, I got the Box version wrong. This code appears to work:

impl T for Box<T> {
    fn spec(&self) -> u32 {
        (**self).spec()
    }
}

fn i(t: Box<T>) -> u32 { t.gen() }

    i(Box::new(o));

#6

Right, but it does get rather confusing doesn’t it?

struct Foo;

impl T for Foo {
    fn spec(&self) -> u32 {42}
    fn gen<X: From<u32>>(&self) -> X where Self: Sized {
        From::from(11)
    }
}

Would it be less confusing if instead

fn gen<X: From<u32>>(t : &T) -> u32

was implemented as a stand-alone method outside of the trait?


#7

If you code it like that, yes. My opinion is that implementations have no business re-implementing Iterator::last, Rng::gen etc. — or in your example Foo should not implement T::gen.

Yes, gen(&r) is another possibility, but not functionally different than use XT; (&r).gen() and potentially less convenient (e.g. in the case of Iterator users would have to use std::iter::last; if that were a free function).