Rust: Generics Considered Colorful

4 minute read

Published:

This post shows that Rust’s generics are colorful. I’ll demonstrate an example to show what I mean, and what the problems are.

Motivating Example

Consider this silly code:

trait MyTrait {
    fn foo(&self);
}

struct S1;

impl MyTrait for S1 {
    fn foo(&self) {
        println!("S1::foo()");
    }
}

fn call_foo<T>(t: &T) where T: MyTrait {
    t.foo();
}

fn main() {
    let s1 = S1{};
    call_foo(&s1);
}

This seems fine so far.

Now, let’s suppose we have an collection of MyTraits, like this:

// Previous code not shown.

struct S2;

impl MyTrait for S2 {
    fn foo(&self) {
        println!("S2::foo()");
    }
}

fn main() {
    let v: Vec<&dyn MyTrait> = vec![&S1{}, &S2{}];
    for x in v {
        call_foo(x);
    }
}

This produces this compilation error:

Compiling playground v0.0.1 (/playground)
error[E0277]: the size for values of type `dyn MyTrait` cannot be known at compilation time
  --> src/main.rs:28:18
   |
28 |         call_foo(x);
   |         -------- ^ doesn't have a size known at compile-time
   |         |
   |         required by a bound introduced by this call
   |
   = help: the trait `Sized` is not implemented for `dyn MyTrait`

The problem is that Rust generics are monomorphized, but monomorphization is not supported for trait objects.

call_foo is a colored function. The code doesn’t compile because trait objects are the wrong color.

Does this Matter In Real Life?

Yes. Here’s an example: The Rust bindings for interacting with the Z3 theorem prover have a trait z3::ast::Ast to represent terms, constants, and expressions. As you’re building a theory, you may want to maintain a vector of your constants in a Vec<Box<dyn z3::ast::Ast>>. Once Z3 has constructed a model that satisfies your theory, you’ll probably want to query the model for the values of constants via the method pub fn get_const_interp<T: Ast<'ctx>>(&self, ast: &T) -> Option<T>.

Well, you just shot your foot off. You can’t call this method on a trait object, so now you need to redo the work you just did. And the new code is going to be a whole lot uglier.

Fix 1: Prefer Trait Objects

In contrast to the orthodox Rust opinion, we should prefer to use trait objects unless we explicitly need to combine multiple trait bounds or dynamic dispatch is a performance issue. Here’s what I mean:

// Previous code not shown.

fn call_foo(x: &dyn MyTrait) {
    x.foo();
}

fn main() {
    let v: Vec<&dyn MyTrait> = vec![&S1{}, &S2{}];
    for x in v {
        call_foo(x);
    }
}

Note that this trait object is general enough to work with many data structures. For example, we can still use a Box with this implementation:

// Previous code not shown.

fn main() {
    let v2: Vec<std::boxed::Box<dyn MyTrait>> = vec![std::boxed::Box::new(S1{}), 
                                                     std::boxed::Box::new(S2{})];
    for x in &v2 {
        call_foo(x.as_ref());
    }
    
    call_foo(v2[0].as_ref());
}

And, of course, we can still use call_foo on a specific instance:

// Previous code not shown.

fn main() {
    let s = S1{};
    call_foo(&s);
}

Fix 2: Always Implement Your Traits for Trait Objects

You should just always implement your traits for trait objects:

// Previous code not shown.

impl MyTrait for &dyn MyTrait {
    fn foo(&self) {
        (**self).foo();
    }
}

fn call_foo<T>(x: &T) where T: MyTrait {
    x.foo();
}

fn main() {
    let v: Vec<&dyn MyTrait> = vec![&S1{}, &S2{}];
    for x in v {
        call_foo(&x);
    }
}

Note that this code also works on other kinds of trait objects:

// Previous code not shown.

fn main() {
    let v2: Vec<std::boxed::Box<dyn MyTrait>> = vec![std::boxed::Box::new(S1{}), 
                                                     std::boxed::Box::new(S2{})];
    for x in &v2 {
        call_foo(&x.as_ref());
    }

    call_foo(&v2[0].as_ref());

    let v3: Vec<std::rc::Rc<dyn MyTrait>> = vec![std::rc::Rc::new(S1{}), 
                                                 std::rc::Rc::new(S2{})];
    for x in &v3 {
        call_foo(&x.as_ref());
    }

    call_foo(&v3[0].as_ref());
}

If you create a trait then you must be the one that implements it for trait objects. Per the coherence rule a trait can only be implemented for a type by the crate that defines the trait or defines the type.

Fix 3: Fix Rust

There’s a lot of code in the wild that share the same pain-point as the Z3 example I mentioned. It shouldn’t be difficult to use generics. Effective Rust does explain the reason for the current design rather well. But I feel like this is an area that can be improved on.