Trait objects of trait objects


#1

Why doesn’t this compile? (playground)

trait T1 { fn f(&self); }
trait T2 { fn g(&self); }

struct S;

impl T1 for S {
    fn f(&self) {
        println!("<S as T1>::f");
    }
}

impl T2 for T1 {
    fn g(&self) {
        println!("<T1 as T2>::g");
        self.f();
    }
}

fn main() {
    let s = &S;

    let t1_object: &T1 = s;
    let t2_object: &T2 = t1_object;
}

The intent of the second impl is to implement T2 for trait objects of T1. It seems like this should produce a fat pointer to a fat pointer, which seems perfectly meaningful, if ungainly.

CC @fitzgen @jorendorff


#2

I think for fat-pointer to fat-pointer you need

impl T1 for S {
    fn f(&self) {
        println!("<S as T1>::f");
    }
}

impl<'a> T2 for &'a T1 {
    fn g(&self) {
        println!("<T1 as T2>::g");
        self.f();
    }
}

?

Plait T1 is not a fat-pointer, it’s an unsized type.


#3

Okay; with that impl, I can convert &t1_object to &T2. That’s a fat T2 pointer to a reference to a fat T1 pointer. Why is that intermediate reference necessary?

According to the Nomicon:

  • T coerces to U if T: CoerceUnsized<U>.
  • Pointer<T>: CoerceUnsized<Pointer<U>> for all pointer types, where T: Unsize<U>.
  • Finally, Unsize is only implemented automatically, but the Nomicon says T: Unsize<Trait> where T: Trait.

I would expect that to apply to my original code. Following the rules backwards:

  • I have impl T2 for T1, so T1: Unsize<T2>.
  • Since the Pointer constructor there should cover references, I would expect &T1: CoerceUnsized<&T2>.
  • So &T1 should coerce to &T2. QED.

By this argument I’d expect my impl to not require the intermediate reference, and work for Box<T1>, Rc<T1>, etc.

My pretty toy is broken!! [pouts]


#4

Is the goal to be able to call g() or to actually have a &T2 trait object? Cause if it’s the former, you can already call g() on any T1 trait object with just your original code (without the t2_object line), and it’ll work on &T1, Box<T1>, Rc<T1>, Arc<T1>, etc.


#5

The goal is to actually have a &T2 trait object.

(Well, the real goal is to understand the rules better. In practice, I can get around this.)


#6

According to RFC 401, coercion from &T to &U is valid “where T is a concrete type which implements the trait U.” This means that trait-object-to-trait-object coercions are not valid under this rule (and the Nomicon should be changed to mention this).

According to the RFC, this should be allowed at least in the case where T1: T2 (playground), but this isn’t implemented yet.


#8

What’s the rationale for this restriction? It seems like Rust has everything it needs to create a trait object &T2 given a trait object &T1. The vtable of a &T2 fat pointer should point to an implementation of g whose self argument has type &T1, and whose body consults that fat pointer’s vtable for operations on self. There’s no reason this can’t work, is there?


#9

To add to @mbrubeck’s reply,

impl T2 for T1 {
    fn g(&self) {
        println!("<T1 as T2>::g");
        self.f();
    }
}

Calling t1_trait_object.g() is statically dispatched - there’s no indirection to call g itself. But having the above impl does not allow you to go from trait_object_T1 to trait_object_T2. If it were allowed (by, say, patching the vtbl part of the fat pointer to point to T2), then supporting real coercion (i.e. taking S's T2 vtbl, if it did implement it) would be problematic I think: which vtbl would be picked? By writing impl<'a> T2 for &'a T1 I think it makes it explicit what it is that you’re requesting.


#10

Yes, t1_trait_object.g() being statically dispatched is what I would expect.

I wouldn’t expect any patching of fat pointers’ vtables to be necessary. The &T2 fat pointer’s referent is the &T1 fat pointer, whose referent is the S. Pretending S isn’t zero-sized, here’s what I’d expect:

[EDIT: this diagram doesn’t match the code. Clearly, the diagram shows t2_object pointing to t1_object, which points to an S, but the code tries to convert t1_object to a &T2 directly — there’s one pointer too few. Trying to rethink this.]


#11

I don’t think there’s any ambiguity over implementations here, since S does not implement T2. Only trait objects &T1, Box<T1>, etc. implement T2.


#12

It doesn’t in this example but what if it did? Or was added later? I think if you were to always pick the trait object impl you’d sort of lock yourself out of supporting the case of discovering the concrete type’s vtbl (via some means) if that feature is ever implemented. Or it would be ambiguous which to pick if both exist.


#14

If S implements T2, then the impl T2 for T1 already shadows S's implementation, when I use static dispatch on a &T1 trait object. The code at bottom (playground) prints:

<S as T2>::g
<S as T1>::f

<T1 as T2>::g
<S as T1>::f

Why should dynamic dispatch be any different?

Code:

trait T1 { fn f(&self); }
trait T2 { fn g(&self); }

struct S;

impl T1 for S {
    fn f(&self) {
        println!("<S as T1>::f");
    }
}

impl T2 for S {
    fn g(&self) {
        println!("<S as T2>::g");
        self.f();
    }
}


impl T2 for T1 {
    fn g(&self) {
        println!("<T1 as T2>::g");
        self.f();
    }
}

fn main() {
    let s = &S;

    let t1_object: &T1 = s;
    s.g();
    println!();
    t1_object.g();
}

What this means to me is that a trait object like &T1 is really its own type, not just a restricted pointer to the underlying type. It doesn’t make sense to allow impl T2 for T1 if &T1 is expected to surface other impls for its referent’s run-time type.


#15

impl T2 for T1
Isn’t a well documented feature, most newcomers most likely want supertrait (or generic) instead.

Personally think it is good that what you originally write does not work. To avoid confusion. Your code in comment 14 show the behaviour you get which is not the same as a more normal case offered using supertraits.

A similar erroneous alternative for the original would be;

fn foo<T: T2>(a:T) {
    a.g();
};
foo(t1_object);

#16

(Agreed about it being an obscure feature, and rarely what one wants.)

Well, only it’s halfway good, right? We do permit impl T1 for T2, so confusion is already our honored guest. We just add inconsistency to our transgressions by making the static and dynamic cases different.


#17

Right, it is its own type (or rather, a class of types since the conversation could also be had about Box<T1>, Rc<T1>, etc). I’m not sure it should also imply that we can coerce T1 trait objects to T2 trait objects, although it’s likely feasible at the technical level (“patch” the vtbl ptr portion of the fat pointer).

Edit: @jimb I just realized you’re the co-author of the O’Reilly book - kudos on it, it’s fantastic!


#18

Thanks, I’m really glad to hear it’s been useful to you! (Did the hand-drawn diagram give me away?)


#19

So thinking about this some more, I don’t think this patching works (at least as easily as it may seem at a quick glance). The vtbl contains other attributes of the erased type, such as a destructor/cleanup func to call, its alignment and size. The vtbl is also a static (ie compile time) structure the compiler generates. If you have an impl T2 for T1, then the method dispatch table is shared across all T1s but the destructor, alignment and size aren’t. And since there’s an infinite number of possibilities for the concrete type underlying the trait object, you couldn’t fabricate all the requisite vtables a priori. So the “patching”, where presumably you’d keep the destructor, alignment and size attributes but provide a new method dispatch table, would need to be done dynamically. And that’s a whole new ball of wax :slight_smile:.


#20

Filed as issue 46337. Let’s see what the compiler people say about this.


#21

The code I posted in the first example is not correct. In trying to figure out what I actually meant I’ve come across a bunch of angles i don’t quite know what to do with at the moment. I closed the issue until I can ask a better question.

But shouldn’t this work? Difference is the use of two boxes instead of one reference, to avoid type lifetime questions.

trait T1 { fn f(&self); }
trait T2 { fn g(&self); }

struct S;

impl T1 for S {
    fn f(&self) {
        println!("<S as T1>::f");
    }
}

impl T2 for T1 {
    fn g(&self) {
        println!("<T1 as T2>::g");
        self.f();
    }
}

fn main() {
    let s = Box::new(S);

    let t1_object: Box<T1> = s;
    let t2_object: Box<T2> = Box::new(t1_object);
}

Compilation fails:

error[E0277]: the trait bound `std::boxed::Box<T1>: T2` is not satisfied
  --> src/main.rs:23:30
   |
23 |     let t2_object: Box<T2> = Box::new(t1_object);
   |                              ^^^^^^^^^^^^^^^^^^^ the trait `T2`
   |                  is not implemented for `std::boxed::Box<T1>`
   |
   = note: required for the cast to the object type `T2`

So it will happily convert Box<S> to Box<T1> given S: T1, but it won’t convert Box<T1> to Box<T2>, given T1: T2.

Here’s the succession of states I would expect the series of let initializations to produce, from left to right:


#22

Again, I (tentatively) think that you’ll get exactly this picture, if you change the impl to impl T2 for Box<T1>.