Why can't I do `dynamic_cast` in Rust as I do in C++?

As it is now, you have to provide is as an actual method:

trait Foo: Bar {
    fn foo_method(&self);
    fn as_bar(&self) -> &dyn Bar;
}

playground

2 Likes

I believe the terms used in the Rust community for this problem is "trait object upcasting" or "supertrait upcasting". A workaround is shown in this StackOverflow Answer.

2 Likes

Just a small note: that is not a dynamic_cast in C++, but a static_cast. Here the C++ version of your example.

2 Likes

I think this is a hack but a viable workaround.

Is there any syntactic sugar to do auto-casting without explicitly providing this converter function?

There's some clever trick with adding an extra trait with a blanket impl, but I don't remember it.

Thanks! Glad to know this compiler explorer! Every time I have to use -masm=intel -S to view asm. This is a nice interface!

Actually, as I discovered, Foo is not a Bar and I update the question. I make some modifications on your code, see here. In my code, I use pure C pointer cast, which is definitely unsafe from Rust's perspective.

It sounds like Rust only provides static-polymorphism (see term 2), and no runtime up- or down-cast is provided as dynamic_cast does in C++.

In this way, static_cast cannot be performed since these 2 types are not in a shared inheritance chain.

As you said in your edit, Rust is not designed for inheritance and classic object-oriented approaches. There are valid reasons for that, but this is another story.

However, this does not mean that Rust does not support runtime polymorphism. You can use Any to use runtime polymorphism with erased types, and you can also try downcast_rs to emulate an OO behavior, however I do not recommend doing that. Rust is based on different paradigms, and you will probably find yourself fighting with the borrow checker if you try to use approaches that do not fit very well :slight_smile:

Another small note on you example: the C++ code on godbold does not perform a dynamic cast, what you wrote is a reinterpret_cast with C notation. This can lead to undefined behavior pretty easily if misused. In fact, this (unfortunately) compiles and run... sort of, because it is UB.
On the other hand, in this example the code really uses a dynamic cast, throwing an exception when things are not fine, without triggering UB. However you can see that it has a serious cost -- look at the ASM code.

6 Likes

Yes, I didn't use dynamic_cast but reinterpret_cast. I added the dynamic_cast to the code. The compiler can figure out through the inheritance tree at runtime (yes, there's runtime type reflection overhead). This type of reinterpret_cast or C style cast really needs developers' discretion and do human type-check, which is mostly unreliable. I can see why Rust forbids it.

Yeah, I find the reason for Rust's choice of generic polymorphism instead of OOP classic inheritance polymorphism. Well, to me, it should be called interface oriented paradigm, which is really similar to Java's interface design except Java's interface can access private members by inheritance but Rust doesn't allow that because its interface implementation is an aggregation of the underlying object instance.

Thanks a lot! I really enjoy this community!

The clever trick that @alice refers to is this,

playground

// blanket impl for all sized types, this allows for a very large majority of use-cases
impl<T: Bar> AsBar for T {
    fn as_bar(&self) -> &dyn Bar { self }
}

// a helper-trait to do the conversion
trait AsBar {
    fn as_bar(&self) -> &dyn Bar;
}

// note that Bar requires `AsBar`, this is what allows you to call `as_bar`
// from a trait object of something that requires `Bar` as a super-trait
trait Bar: AsBar {
    fn bar_method(&self) {
        println!("this is bar");
    }
}

// no change here
trait Foo: Bar {
    fn foo_method(&self) {
        println!("this is foo");
    }
}

// now this works
fn foo(foo: &dyn Foo) -> &dyn Bar {
    foo.as_bar()
}
8 Likes

Now I know let foo: &dyn Foo = bar is using static_cast from Bar trait object to Foo trait object and this will fail because they are different types. But how could rust compile tell?

Would the rust compiler generate trait-specific code behind dyn Foo?

I checked this doc, it says this

pub struct TraitObject {
    pub data: *mut (),
    pub vtable: *mut (),
}

is ABI compatible representation of trait object, which means this is not the underlying representation in rust.

Would dyn Foo generate pub struct TraitObjectFoo and dyn Bar generate TraitObjectBar, or something like that to differentiate they are 2 different trait object type?

Yes, something like that. Trait objects are stored like so

// for example, the vtables for i32 and Vec<i32>
static VTABLE_FOO_FOR_I32: VTableFoo = VTableFoo { ... };
static VTABLE_FOO_FOR_VEC_I32: VTableFoo = VTableFoo { ... };

pub struct TraitObjectFoo {
    data: *mut (),
    vtable_ptr: &VTableFoo,
}

pub struct VTableFoo {
    layout: Layout,
    drop_in_place: unsafe fn(*mut ()),
    // methods
    foo_method: fn(*mut ()),
    bar_method: fn(*mut ()),
    as_bar: fn(*mut ()) -> &dyn Bar,
}

This VTableFoo is generated for each and every that that implements Foo (modulo optimizations that strip unused impls). Then the fat pointer refers to the vtable by reference. storing something like (data_ptr, &VTABLE_FOO_FOR_I32). Note: the layout of VTableFoo is not related to the layout of VTableBar in any way, shape, or form.

2 Likes

Thank a lot! Save me a lot of time learning Rust! Much appreciated!

One more thing I almost forgot about, the vtable for a given trait implementation may be generated multiple times. This means that pointer equality may spuriously return false if the two vtables were generated in different translation units, even if they should otherwise be equal!

4 Likes

That's interesting to know. I guess there might be duplicate definitions in the anonymous scope within a translation unit

// translation unit: foo.cc
// C++ code
namespace {
struct VTableFoo {
    unsigned Layout;
    void* drop_in_place;
    // methods
   ....
}

dummy_vtable = VTableFoo{ /* init */};

} //  anonymous

// refer this definition in same foo.cc
TraitObjectFoo dummy { /* data address */, &dummy_vtable};

or just within that unit without external reference (just remove the namespace).

Just curious: why not use single definition across different translate units? Is there any specific reason to generate multiple definitions among different units? Like, to speed up the compilation?

This may happen, for example, when you use a DLL. The DLL has one version of the implementation, and your current build may have another version of the same trait impl. But even if you statically link everything I think we may generate duplicate vtables to avoid synchronizing across translation units, which improves performance (last I asked, these were the answers).

2 Likes

Globally unique statically generated vtable is generally not possible. Golang solved this issue by generating and caching vtable object at runtime. But I don't think it's viable for the Rust.

1 Like

Got it! One more thing, in your example (10th floor)

// a helper-trait to do the conversion
trait AsBar {
    fn as_bar(&self) -> &dyn Bar;
}

// note that Bar requires `AsBar`, this is what allows you to call `as_bar`
// from a trait object of something that requires `Bar` as a super-trait
trait Bar: AsBar {
    fn bar_method(&self) {
        println!("this is bar");
    }
}

// no change here
trait Foo: Bar {
    fn foo_method(&self) {
        println!("this is foo");
    }
}

in your answer,

pub struct VTableFoo {
    layout: Layout,
    drop_in_place: unsafe fn(*mut ()),
    // methods
    foo_method: fn(*mut ()),
    bar_method: fn(*mut ()),
    as_bar: fn(*mut ()) -> &dyn Bar,
}

I'm a little bit confused. I thought vtable for Foo will only have only one method, which means foo_method and trait object dyn Foo can only call foo_method and it cannot call bar_method.

Now it seems the vtable should include all methods from siblings. That is to say

Trait A: B, C {}
// flat relation graph
// B, C
//   ^
//   A 

Table for trait object A should have all methods from B, C, which makes the vtable really flat (maybe large in size).

Am I right on this?

// c++: foo.cc
struct C {
  virtual ~C() = default;
};

struct B : public C {
  virtual ~B() = default;
};

struct A : public B {};

int main(int argc, char **argv) { return sizeof(A); }

// hierarchical relation
// C
// ^
// B
// ^
// A

run clang -cc1 -std=c++11 -fdump-record-layouts foo.cc to check memory layout,

*** Dumping AST Record Layout
         0 | struct C
         0 |   (C vtable pointer)
           | [sizeof=8, dsize=8, align=8,
           |  nvsize=8, nvalign=8]

*** Dumping AST Record Layout
         0 | struct B
         0 |   struct C (primary base)
         0 |     (C vtable pointer)
           | [sizeof=8, dsize=8, align=8,
           |  nvsize=8, nvalign=8]

*** Dumping AST Record Layout
         0 | struct A
         0 |   struct B (primary base)
         0 |     struct C (primary base)
         0 |       (C vtable pointer)
           | [sizeof=8, dsize=8, align=8,
           |  nvsize=8, nvalign=8]

There's only one vtable for A, B, and C. But for Rust, there is a vtable implementation for each trait (e.g., A, B and C).

To me, even though Rust inheritance is not the same as C++, but the vtable layout, in the end, is the same for struct A and trait A. The only difference is that C++ vtable lives inside of each object whereas rust lives in the fat pointer, alongside data pointer.

In the long run, the number of objects is far more than the number of traits, this decision may save some space.

One more question that I have.

How does vtable check which method to be used at runtime if it has many methods in the table? Is the method offset compile-time known? E.g., if foo_method is called, rust will remember its offset, let's say 3 at compile-time, and at runtime use that index 3 to load the method for foo?

Yeah, the vtable for Foo does have bar_method. It's possible that it has it by having a pointer to the vtable for the Bar impl, but I don't know.

I don't know for certain, but I am pretty sure that it does indeed know the offset in the vtable and hardcodes that offset.

1 Like

After spending some time learning ASM, here's an example of how vtable works.

struct Bar {
    virtual void method1() {
        std::puts("method1: this is bar");
    }

    virtual void method2() {
        std::puts("method2: this is bar");
    }
    virtual ~Bar() {};
};

struct Foo: public Bar {
    virtual void method1() {
        std::puts("method1: hi, I'm foo");
    }
    virtual ~Foo() {};
};

int main() {
    Bar bar;
    Foo foo;
    Bar* bar_arr[2];
    bar_arr[0] = &bar;
    bar_arr[1] = dynamic_cast<Bar*>(&foo);

    for (auto ptr: bar_arr) {
        ptr->method1();
        ptr->method2();
    }
    return 0;
}

This is intel asm syntax for vtable.

vtable for Foo:
        .quad   0
        .quad   typeinfo for Foo
        .quad   Foo::method1()
        .quad   Bar::method2()
        .quad   Foo::~Foo() [complete object destructor]
        .quad   Foo::~Foo() [deleting destructor]
vtable for Bar:
        .quad   0 // q-word -> 8 bytes, will be used as offset
        .quad   typeinfo for Bar
        .quad   Bar::method1()
        .quad   Bar::method2()
        .quad   Bar::~Bar() [complete object destructor]
        .quad   Bar::~Bar() [deleting destructor]

For each class, it carries a table (if there's no override, it will carry the same vtable from the parent).

let's check this line,

    for (auto ptr: bar_arr) {
        ptr->method1();
        ptr->method2();
    }

First, it needs to store the vtable address into the stack along with the variable bar and foo,

        // bar memory layout is a simple vtable pointer, size is 8
        mov     eax, OFFSET FLAT:vtable for Bar+16
        mov     QWORD PTR [rbp-56], tax
        // same for foo
        mov     eax, OFFSET FLAT:vtable for Foo+16
        mov     QWORD PTR [rbp-64], rax

The compiler knows the offset of method1 and method2 in vtable.

        mov     rax, QWORD PTR [rbp-48] // temp var, store current arr iterator: ptr = bar_arr[0]
        mov     rax, QWORD PTR [rax]
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-48]
        mov     rdi, rax // set up parameters for function call
        call    rdx // call method1 from vtable stored in the object, e.g., bar and foo
        mov     rax, QWORD PTR [rbp-48]
        mov     rax, QWORD PTR [rax]
        add     rax, 8  // method1 + 8 offset -> method2
        mov     rdx, QWORD PTR [rax]
        mov     rax, QWORD PTR [rbp-48]
        mov     rdi, rax
        call    rdx

a vis for stack: <rbp - number> | c syntax

-24 | ptr (Bar*)          | // like counter index i
-32 | bar_arr (Bar**)     |
-40 | bar_arr + 2         | // end of bar_arr[]
-48 | temp var            |
-56 | bar (a vtable ptr)  |
-64 | foo (a vatble ptr)  |
-72 | &foo (bar_arr[1])   |
-80 | &bar (bar_arr[0])   |

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.