Trying to fake downcasts with traits defined on traits

I'm still struggling to come up with a convenient way to do downcasts (preferably with type checks, so Any is out).

This code compiles with no warnings:

struct Boring {}
struct Interesting {}
trait Both {}
impl Both for Boring {}
impl Both for Interesting {}

trait Dispatch {
    fn action(&self);
}

impl Dispatch for Both { // NB: default via trait
    fn action (&self) {
        println!("default action");
    }
}

impl Dispatch for Interesting {
    fn action (&self) {
        println!("interesting action");
    }
}

fn main()
{
    let b1 : Box<Both> = Box::new(Boring {});
    let b2 : Box<Both> = Box::new(Interesting {});
    b1.action();
    b2.action();
}

I hope the intent is clear: I want to recover the Interesting object using the second trait. But the output, when run, is:

default action
default action

This is not too surprising because if this worked, it would be easy to implement OOP-style downcasts. Curiously, I did not find any existing discussion of this fairly obvious way to attempt to implement downcasts.

What goes on under the covers? Is the vtable for the trait object picked at the time of the conversion from the concrete type (which happens to be a trait, Both) to the trait Dispatch? Programmers with a vague idea of generic method dispatch in Common Lisp may have a very different idea about how this supposed to work (which does not mean that the Rust behavior is undesirable in general or wrong, of course).

I think this doesn't even get to actual vtables -- the calls on Box<Both> are statically compiled to <Both + 'static as Dispatch>::action. You can see this in the MIR output on the playground:

fn main() -> () {
//...
    bb7: {
        StorageDead(_6);                 // scope 1 at <anon>:26:51: 26:51
        StorageDead(_7);                 // scope 1 at <anon>:26:51: 26:51
        StorageLive(_9);                 // scope 2 at <anon>:27:5: 27:7
        _9 = &(*_1);                     // scope 2 at <anon>:27:5: 27:7
        _8 = <Both + 'static as Dispatch>::action(_9) -> [return: bb8, unwind: bb5]; // scope 2 at <anon>:27:5: 27:16
    }

    bb8: {
        StorageDead(_9);                 // scope 2 at <anon>:27:17: 27:17
        StorageLive(_11);                // scope 2 at <anon>:28:5: 28:7
        _11 = &(*_5);                    // scope 2 at <anon>:28:5: 28:7
        _10 = <Both + 'static as Dispatch>::action(_11) -> [return: bb9, unwind: bb5]; // scope 2 at <anon>:28:5: 28:16
    }

I'm not sure if this helps your downcasting goals, but Box<Dispatch> does print what you wanted:

struct Boring {}
struct Interesting {}

trait Dispatch {
    fn action (&self) {
        println!("default action");
    }
}

impl Dispatch for Boring { }

impl Dispatch for Interesting {
    fn action (&self) {
        println!("interesting action");
    }
}

fn main()
{
    let b1 : Box<Dispatch> = Box::new(Boring {});
    let b2 : Box<Dispatch> = Box::new(Interesting {});
    b1.action();
    b2.action();
}

playground

default action
interesting action

Box<Both> also works if you had trait Both: Dispatch and an impl Dispatch for Boring, with the default action still defined in Dispatch itself. But I suspect you're trying to avoid touching Boring?

It might also work once we get specialization, then you'd have a default impl<T:Both> Dispatch for T and specialize impl Dispatch for Interesting.

1 Like

Sure, but in my scenario, a library would know only about Boring and Interesting and hand that down as Both.

I'm not sure what you mean to change in my example. Yes, when Boring is defined, Dispatch does not exist yet.

(The workaround is to use an enum-based approach, but I'm curious if there is any way to translate the safe downcast idiom more explicitly.)

EDIT: I see what you mean—Both: Dispatch and not Dispatch: Both, which was what I tried. Yes, that would break encapsulation in my case.

Something like this: Rust Playground

Note that they don't have to be neighbors at all - they can even live in different crates. But the impl Dispatch for Boring has to be in a crate that defines one of those and can see the other, or else you fall afoul of the orphan rules.

Supposing you did get this printing example working exactly as you want, you'll only have achieved some dynamic dispatch. How would you extend this to get your fake downcasts? Trait-object safety doesn't let you do pretty much anything with Self, so I'm not sure how you planned to escape.

The approach is very explicit, a bit verbose on the implementation side (around Both), and encodes a closed-world assumption:

struct Boring {}
struct Interesting {}

enum BothRef<'r> {
    Boring(&'r Boring),
    Interesting(&'r Interesting),
}

trait Both {
    fn downcast(&self) -> BothRef;
}

impl Both for Boring {
    fn downcast(&self) -> BothRef {
        BothRef::Boring(&self)
    }
}

impl Both for Interesting {
    fn downcast(&self) -> BothRef {
        BothRef::Interesting(&self)
    }
}

trait Dispatch: Both {
    fn action(&self);
}

impl Dispatch for Both {
    fn action (&self) {
        println!("default action");
    }
}

impl Dispatch for Interesting {
    fn action (&self) {
        println!("interesting action");
    }
}

fn main()
{
    let b1 : Box<Both> = Box::new(Boring {});
    let b2 : Box<Both> = Box::new(Interesting {});
    for b in [&b1, &b2].iter() {
        match b.downcast() {
            BothRef::Interesting(_) => {
                println!("interesting action")
            }
            _ => {
                println!("default action")
            }
        }
    }
}

At this point, it may be more natural to return an enum (similar to BothRef, but without the references, or with boxes), and do away with the trait. But that's not what you would do in other languages (downcasts are much more common than sum types), and I wanted to preserve as many aspects of the original library code I'm porting.

Ah, the closed enum is a nice trick. But yeah, if you have to go through an enum anyway, it does seem like the downcasting is a wasted abstraction.

I tried to come up with an additional approach based on Any, but the (necessary) life-time erasure that comes with Any (and its constant type ID) seems to make it impossible to present a trait-based interface (Both in the example) and the completely untyped one (Any) through the same function, without additional allocations and indirections.