Traits and Tribulations

Traits and Tribulations

Here's two useful ways in which Rust traits are their own thing.

·

5 min read

One of the core questions rocking the world of anyone engaging with Rust for more than a few weeks becomes: so, are traits just interfaces? I've seen many people dwell on this for far too long, sort of not seeing the forest for the trees. Let's dive into two distinct things a trait can do that an interface (in say, C# or Java can't).

Traits are for everyone

If you define a trait, you can mix it into any piece of data - yours or not. For example,

trait Special {
    fn specialize(&self) -> Self;
}

impl Special for String {
    fn specialize(&self) -> Self {
        format!("**{}**", self) // strings **sparkle**
    }
}

impl Special for u32 {
    fn specialize(&self) -> Self {
        self + 100 // ints **inflate**
    }
}

In OOP, because interfaces force a hierarchy, things end up being pretty closed-off, requiring specialized hierarchies of simple wrappers.

Functions can now use this trait as a generic bound, similar to interfaces. Because of the openness, however, these functions can now blanket a much wider field of types.

fn use_special_power<S: Special>(s: S) { ... }

Trait-bounds can also be bundled: in a game, especially one driven by components (disregarding how they're stored, not talking about ECS), you can have something like the following snippet, where we showcase composition of capabilities that's hard to get in OOP naturally -- it's harder to make objects that fit the bill without them sharing hierarchies (leading to the famous "What is a duck?" question and its complex OOP answers).

fn try_move<E: HasMove + HasSight>(entity: &E) {
    ...
}

// or, maybe cleaner:
fn try_move<E>(entity: &E)
   where E: HasMove + HasSight {
    ...
}

But we said that traits are for everyone, so let's go a step or two further: we can generalize over whole families of implementers by making generic impl blocks:

/// HackerDebug turns anything that can be printed via the
/// standard debug printer into a masterpiece
trait HackerDebug {
    fn hacked(&self) -> String;
}

impl<T: Debug> HackerDebug for T {
    fn hacked(&self) -> String {
        format!("[l33t {:?}", self)
    }
}

Let's try this out on some random types:

println!("{} {} {}", 
    5.hacked(), 
    3.14f32.hacked(), 
    "h4xx0r".hacked());

Because of the explicitness of the types relating to both concurrency and memory layout, this same method enables us to limit our implementations to only some very specific models of our data. For example, if we only want heap-allocated and fully owned objects to be a target for our trait, we can do that:

impl<T: Debug> HackerDebug for Box<T> { ... }

Or if you need thread-safe shared state, you'd go for:

impl<T: Debug> HackerDebug for Arc<Mutex<T>> { ... }

Traits define behavior, not membership

This might be weird, and I might find a way to say it better, but let's take an example of an interface:

interface IHackerDebug {
    String hacked();
}

Nowhere is it mentioned, but this hacked method is instance-level on the implementer: it just simply is. It works on the this object when implemented and we're simply rolling along nicely. Here's the same in Rust, let's spot the difference:

trait HackerDebug {
    fn hacked(&self) -> String;
}

If we want to pass an instance in, we have to annotate self in some way. In this example, we don't want to consume the value (we're not sure how Copyable it is, after all, but if we wanted, we could have requested HackerDebug: Copy) so we use an immutable reference to self to denote our intent: we need a bound object.

Here's a different version of this trait:

trait HackerDebug {
    fn hacked() -> String;
}

What is this saying now? In interface parlance, it's an abstract static method! The list of things we'd need to get into to explain why these don't work in other languages is huge and includes everything from bytecode limitations, to how functions are stored in memory, to the C++ ODR rule which... let's just say it gets tossed around by people who know what they're talking about but with little to no explanation for those who aren't in that group.

Java teases us with static abstract methods, saying that the following code doesn't compile because the method needs to be abstract:

interface MyInterface {
    static void myStaticMethod(); // <-- needs abstract
}

Once you give it abstract, it simply says...

error: illegal combination of modifiers: abstract and static

I guess it's illegal. These kinds of limitations introduce the need for more factories, delegates, and all the other jazz that makes you feel high on life once you're done. I'm here wanting to call a function, looking at the world weirdly. I just want to have this behavior. It's a part of this trait - this characteristic, this capability - but not part of the data; it's not significantly attached to a member. Traits define behavior. They don't push membership.

You might wonder about why this is even remarkable or wanted. Here's a piece of the std library that's used very often:

pub trait Default: Sized {
    fn default() -> Self;
}

This trait defines the default() function that serves to create a default object of some type. It's that simple. It's dead simple. I'll leave it as an exercise to the reader to try and do Default in other languages. It's not a challenge, but it's interesting that it's not as simple.