How to deal with `the trait cannot be made into an object` error in rust? Which traits are object-safe and which ain't?

I have a trait Cell:

pub trait Cell: Debug + Clone + Ord {}

But when I try to create a vector of them like this:

pub struct Tuple {
    pub cells: Vec<Box<dyn Cell>>,
}

The compiler gave me the following error:

error[E0038]: the trait `storage::tuple::cell::Cell` cannot be made into an object
   --> src/storage/tuple/tuple.rs:20:24
    |
20  |     pub cells: Vec<Box<dyn Cell>>,
    |                        ^^^^^^^^ `storage::tuple::cell::Cell` cannot be made into an object
    |
note: for a trait to be "object safe" it needs to allow building a vtable to allow the call to be resolvable dynamically; for more information visit <https://doc.rust-lang.org/reference/items/traits.html#object-safety>

So I was baffled after researching for a while:

  • What makes a trait "object safe", and what makes it not?
  • Can we inherit an "unsized" trait and make our child trait "size/object safe" at the same time?
  • What's the usage of a "not object safe" trait?
  • Why we cannot make a Box<dyn trait> when the trait is "not object safe"? Given the fact that we are manipulating objects on heap, why does the compiler still not let me go?

I tried:

  • Remove the "Ord" or "Clone" trait, but the trait doesn't make sense any more.
  • Add "+ Sized" to the trait, it doesn't fix the compile error.

Did you really read the nice compiler error? That contains the link you're interested in: Traits - The Rust Reference

Sized is the main limitation w.r.t object-safety, but there are more constrains, like

  • All supertraits must also be object safe.
  • Sized must not be a supertrait. In other words, it must not require Self: Sized.
  • It must not have any associated constants.
  • It must not have any associated types with generics.
  • ...

For the definition of pub trait Clone: Sized, you already have a Sized as a supertrait.

Read Trait object types - The Rust Reference.

The purpose of trait objects is to permit "late binding" of methods. Calling a method on a trait object results in virtual dispatch at runtime: that is, a function pointer is loaded from the trait object vtable and invoked indirectly.


But you can always write vtables by hand to have dynamic dispatches.

The error output gave you a link.

I don't understand this question. Rust doesn't have inheritance and types are unsized, not traits. And dyn Trait is always unsized.

In case there is some confusion, dyn Trait its own distinct type. It's dynamically sized ("unsized") as any implementer of the trait can be coerced to dyn Trait, and those base types may have different sizes. It also does dynamic dispatch, but it is not dynamically typed.

The only unsized types that can be coerced to dyn Trait are types that are already dyn _, like dyn Trait + Send. And those types of coercions are currently rather restricted to boot. (Eventually we'll get supertrait upcasting.)

The purpose of traits is to provide an interface for code reuse, generics, and interoperability. You've used some not-object-safe traits I'm sure, for example to .clone() something, or to use overloadable operators.

These are useful even when you can't type erase them (i.e when not object safe).

The base types' implementations of Clone return their cloned value on the stack. The compiler would need special behavior for Clone, for every possible heap-based smart pointer type, to support something like that. (Rust doesn't have autoboxing.)

You can get around it by being explicit about cloning into a type-erased Box. Note how the actual cloning happens in not-type-erased code.

However...


The Ord supertrait is even more problematic. This is basically the example in the RFC linked from the documentation.

 pub trait Ord: Eq + PartialOrd<Self> {
    fn cmp(&self, other: &Self) -> Ordering;

How would this work with dyn Ord? You would get two &dyn Ord that might not even have the same underlying base type. You could be attempting to compare Strings to i32s to ()s. But the implementations available only compare String to String, i32 to i32, etc.

2 Likes

Thank you for your assistance!

Unfortunately, the articles on rust-long.org lack a thorough discussion of this topic, making them barely helpful.

Upon reflecting on my issue, I have identified a core demand and at least three approaches to address it:

Demand:

  • I need a list of cells to represent a row in my database, and each row should be able to have its own type.

Approaches:

  • Write a Cell trait that inherits from various super-traits to cooperate with the current codebase.
  • Remove all super-traits that make the Cell trait non-object-safe and explore other options.
    Use an enum rather than a trait to represent a cell.

Next, I will try the enum approach.

By the way, Rust’s strict coding restrictions do encourage programmers to think critically about their logic; however, these restrictions can often be frustrating.

2 Likes

When you have a closed list of types (i.e. when you can enumerate all of them in advance and don't expect this list to change often), this might be the most ergonomic solution in the end.

Thank you for your message!

I understand the last part about dyn Ord, and perhaps it would be better for me to review the rest of my code instead of trying to make it work with this new trait.

As for the earlier passages, they were beyond my current understanding of Rust. I will come back to them later when I have a better understanding of the language.

Yes, the enum solution was the first idea that came to my mind as well. However, I was concerned about the memory usage of String and its impact on performance. :grinning:

But now I realize that I may have been overthinking it. Perhaps it would be better to focus on getting the program to compile first before worrying about any potential performance issues.

Could you explain this point a little? It's possible that you were using enum in some not entirely idiomatic way, which would indeed noticeably hurt performance, but can be easily fixed to perform better.

My concern stems from a lack of understanding regarding the memory footprint of enums.

I am currently developing a toy database and require a row that can hold various types of data. Here is the relevant code:

I created a sample code to test the memory usage of my enum and list of enums, which can be found here:

The output of this code is as follows:

type: EnumC
size of enum: 32
size of enum: 32
size of enum list: 24

I noticed that the size of EnumC is 32, which raises lots of issues for me:

  1. 32 bytes is too much for an i8
  2. Why is there only 32 bytes for my string object EnumC::C(random_str)? I did not write a box to put it on the heap (if it was put on the heap, who is in charge of its lifetime?), nor did I use a static string.
  3. Since the string does not exist in the binary, where is my string (stack/heap/binary/universe)?
  4. Why isn’t it (string/vector) a non-object-safe object?
  5. Why does the compiler consider it to be safe, given that this string can be of arbitrary length?

That’s actually a different topic. I may post it as a separate thread later on.

Yes, enum size must be enough to fit any variant, since it will be passed around in the same way for every one of them.

Well, if you go for boxing, you will have a little less (two pointer sizes plus u8 itself - 17 bytes), at the cost of extra allocation and indirection. Not sure if this is a good tradeoff.

String puts its data on the heap itself - in-place it is just a triple "pointer, size, capacity", that is, three usizes, or 24 bytes.

2 Likes

Really appreciate your detailed response!

I used to be confused about the memory layout of vectors, but now it all makes sense since vectors are allocated on the heap. (I used to believe that "zero cost abstraction" meant that the compiler never put objects on the heap unless instructed to do so.)

By default, Rust uses "static dispatch," which means that the compiler has full knowledge of every type involved. It uses this information to tailor the code generation at every call site, which allows for more complicated interfaces than would be possible with dynamic dispatch.

Static dispatch isn't always appropriate, though, because it requires every type to be defined at compile time. This prevents you from doing things like storing lots of different types with the same interface inside a single collection, such as a Vec.

Being "object-safe" is a property of a trait, and doesn't really have any meaning for concrete types like String, Vec, or an enum. It means that the trait's definition is simple enough that the compiler can describe the entire interface with a set of function pointers, enabling a dynamic-dispatch approach.

1 Like

That's generally correct - that is, compiler will put object on the heap only if somewhere in the code is an explicit instruction to do so. In case of Vec, it is in its implementation (and it has to be somewhere - dynamically-sized data can't be reasonably stored anywhere else). In case of Box, well, it's a little more complicated, but the gist is the same - the type itself requires that there's a heap allocation, it's not inserted implicitly.

3 Likes

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.