Trait objects and default implementations of methods: why not possible?

Dear experts,

it is not possible to provide default implementations for trait methods that will be called through trait objects. For example the following will not compile (playground):

trait Printable {
    fn print(&self) where Self: std::fmt::Display + Sized {
        println!("{}", self);
    }
}

impl Printable for i32 { }

impl Printable for f64 { }

fn main() {
    let mut v: Vec<Box<dyn Printable>> = vec![];
    v.push(Box::new(123));
    v.push(Box::new(3.14));

    for i in &v {
        i.print();
    }
}

I understand how to fix this by moving the definition of print to the trait implementations. Interestingly, it is even possible to keep the functionality inside the trait definition (in a method print_generic), and then call it from otherwise empty specialized print methods: playground.

I also understand that print as defined in the snippet above is a generic function, and generic functions cannot be called through a vtable in general (pun intended).

However, in this particular (and probably common) case, the concrete implementations of print for the concrete types i32 and f64 would no longer have any generic parameters left. Why doesn't the compiler use the default implementation to provide the concrete implementations?

Am I missing some problem with this approach, or is it just an unnecessary restriction of the compiler?

Many thanks in advance (once again)!

it does. for example:

let x = 42;
x.print();

what you are trying to do is not calling the method on i32 or f64, you are trying to call Printable::print on trait objects, i.e. dyn Printable, and that, doesn't implement Printable by default. you have to manually do it your trait is not object safe, so trait objects cannot be dynamically dispatched:

the reason trait object doesn't implement the trait by default is because not all trait are "object safe".

1 Like

Note that (for your example, which I appreciate is not necessarily full code), you can change your trait to be object-safe, and then it works exactly as you expect:

rust playground

trait Printable: std::fmt::Display {
    fn print(&self) {
        println!("{}", self);
    }
}

impl Printable for i32 {}

impl Printable for f64 {}

fn main() {
    let mut v: Vec<Box<dyn Printable>> = vec![];
    v.push(Box::new(123));
    v.push(Box::new(3.14));

    for i in &v {
        i.print();
    }
}
6 Likes

first, remove the Sized bounds. because the trait object dyn Printable is not Sized, your print method cannot be called on an trait object.

then, you'll have to add the move the std::fmt::Display as super trait instead of a bound on the method, because otherwise dyn Printable only implements Printable, not Display, thus it doesn't meet the bound of the print method.

trait Printable: std::fmt::Display {
    fn print(&self)  {
        println!("{}", self);
    }
}

PS: in current version, you can kind of get rid of the super trait requirement, by hacking the type system like this:

trait Printable{
    fn print(&self) where Self: std::fmt::Display  {
        println!("{}", self);
    }
}

impl std::fmt::Display for dyn Printable {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        todo!()
    }
}

but this is very wrong and the compiler emits hard warnings about it.

6 Likes

This answers all my questions. Many thanks to all of you! (I don't know which reply to mark as "answer" since all are relevant.)

1 Like

This is a write-up of what was actually wrong with the short program listed at the beginning of this thread. I post this here in the hope that someone may find it interesting. I’m also grateful for any corrections and comments.

First problem

The bound Self: std::fmt::Display of fn print looks harmless, however it turns out to be related to a subtle bug in Rust.

The bug is demonstrated here: playground. This program will compile (with a warning) but segfault when executed, although it contains no unsafe code!

The problem appears when a trait X is defined that requires a method which restricts Self is some way (here where Self: Trait). The compiler allows the implementation of trait X for some concrete type (here: the empty tuple) even if the concrete implementation of said method does not repeat the bound on Self. (I’m actually not sure why the compiler does not complain at that point.) But for static polymorphism everything is still OK: the compiler will require that Self: Trait when foo is actually called for a concrete type. For example ().foo() will fail.

One could wonder why this restriction is necessary. After all, the implementation of trait X for () could simply happen to be less restrictive and not need Trait. However, traits in Rust are declarative and could have some meaning that cannot be checked by the compiler but that is essential for X in general.

Now it happens that Rust allows implementing Trait for dyn X and then it allows said method to be called on X trait objects, even if the concrete types for which X has been implemented do not themselves implement Trait. (However, I’m unsure why this actually leads to a crash, instead of behaving as if the bound where Self: Trait has been removed.)

So, translated to my code, it could be that some concrete type for which Printable is implemented does not implement std::fmt::Display and that would be bad.

Second problem

In order to avoid the first problem, I added a bound Self: Sized to the method print. This marked the method as explicitly non-dispatchable, and made the warning disappear.

Third problem

The bound Self: Sized prevents the method print to be invoked dynamically, because trait objects do not have a statically known size.

2 Likes

If I understand correctly, here's what is going on:

  • Call is compiled "as if" there was foo in vtable for () as dyn X, i.e. compiler inserts a jump to the address stored relatively to vtable position. In assembly this is call qword ptr [rip + .L__unnamed_2+24].
  • This vtable (under the .L__unnamed_2 label) actually contains only drop_in_place, so after the offset the read lands in the unexpected place (namely, at the .zero 8 instruction - not sure what it means here though; if we remove the Self: Trait restriction, these zeroes are replaced with function pointer, so this might even be intentional - to semi-reliably get the segfault and not to jump somewhere randomly).
  • Anyway, bytes on this place doesn't correspond to the address of callable code. So when CPU tries to call it, program segfaults.

Good idea to examine the assembly in godbolt!

Indeed. It would be interesting to know why the compiler chooses to emit an invalid vtable instead of aborting with an error, as it does when calling the method on () statically. The title of the rustc issue implies that the method is “nonexistent”, but even so code for it (<() as example::X>::foo:) is emitted - although there seems to be no way to call it.

By the way, if one adds impl Trait for () {}, then the assembly output is the same whether where Self: Trait is present or not.

It's also interesting that the exact way how to deal with this issue hasn't been decided yet. So, it seems, the concept of object safety is not yet properly defined in Rust. (Perhaps the situation is similar to Rust's memory model.)

Because making this an error (and not a forward-compatibility lint) would be a breaking change, I guess?

I assumed that the zero entry in the vtable has been the original buggy behavior as reported in rust issue #50781. But perhaps you are right and it is part of a first non-breaking fix, and the segfault reported in the issue had a different mechanism. I tried to find out with godbolt, but all I can see is that the zero entry goes back to Rust 1.13 (which is way older than the issue), and the even older Rust 1.12 simply ignores the missing trait implementation and produces a working executable.

Assuming that setting the vtable entry to zero is an intentional way to signal an error, wouldn't it be better if it was instead set to the address of a piece of code that panics with some useful message?

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.