Traits are more than interfaces

Rust

2023-04-17

I've been paid for writing Rust for 1.5 years now, but I still have eye-opening experiences now and then.

Even though I've invested quite some time in learning functional languages, the first language I've used a lot was C++ and I've written a lot of object-oriented code. Given this background, I've mostly interpreted traits as Interfaces or Abstract Classes as they're called in C++. However, that doesn't do them justice. If you implement an interface, you create a new class, that implements it, and the interface tells the compiler that there are certain methods on the new class, which allows it to be mixed with other classes that share the same interface.

You can use traits as interfaces perfectly well, but since you can implement a trait for existing types, it's also a (weaker1) replacement for function overloading. That was not the revelation, btw. Because even when looking at it as a tool that replaces function overloading, I always looked at it as the possibility to overload the first argument of the function. The revelation was, that it doesn't need to be the first argument. It could be any argument, or the return type. That was the revelation. It's been in front of my eyes all the time (e.g. with str::parse), but I never really realized that that was something I could do myself easily. Now that I found out, I feel empowered, and I figured that other people might enjoy this insight too.

To round this short article out, I want to give a short example: I'm currently working on a side project, a small scripting language. The VM of this language stores data using an enum:

#[derive(Debug, Serialize, Deserialize, Clone)]
pub enum Data {
    String(String),
    Vec(Vec<Data>),
}

(yes, currently only strings, or arrays of strings are supported). Since it's statically typed, the VM knows the type of stored variables, so it's not necessary to check which variant is in use, and I want to be able to just get the data like this:

let bin_name = state.memory.get_as::<&str>(bin_ref);

The way to do this goes through this trait:

pub trait DataAs<'a>: std::fmt::Debug {
    fn get_as(data: &'a Data) -> Self;
}

As you can see, the Self type is used as return value. One might "unspoken" think "Self is the first argument, always" because we've used it like that so many times, but it's perfectly valid to use it like this. With this trait, get_as can be implemented like this:

impl Data {
    pub fn get_as<'a, T: DataAs<'a>>(&'a self) -> T {
        <T as DataAs>::get_as(&self)
    }
}

and then be implemented for types, e.g. like this:

impl<'a> DataAs<'a> for &'a str {
    fn get_as(data: &'a Data) -> &'a str {
        if let Data::String(s) = data {
            &s
        } else {
            utils::bug!("tried to get Data as &str, but it is: {:#?}", data);
        }
    }
}

I hope some of you find this to be as eye-opening as I did. Thanks for reading :)

Addendum

The discussion in the Reddit thread brought up two important points, that I want to include here:

Generically implementing Traits

You can implement traits generically, for every type that implements another trait, which is pretty powerful. For example, you could define this trait:

trait TermFormat {
    // formats self as a string formated with the provided ansi term codes
    fn format<'a>(&self, formatting: impl IntoIterator<Item = &'a str>) -> String;
}

and then implement it for all Types that already implement Display like this:

impl<T: std::fmt::Display> TermFormat for T {
    fn format<'a>(&self, formatting: impl IntoIterator<Item = &'a str>) -> String {
        let formatters: Vec<_> = formatting.into_iter().collect();
        format!("\x1b[{}m{}\x1b[0m", formatters.join(";"), self)
    }
}

so now, you can use it like this:

fn main() {
    println!("{}", "hello".format(["31", "1", "4"]));
}

which will print "hello" to your terminal in red, underlined and bold

From and TryFrom

If you do want to do type conversions, it might be a good Idea to consider implementing std::convert::From or std::convert::TryFrom instead of creating custom traits like I did above.


  1. I say weaker, because function overloading allows you to overload on all arguments, which is called multiple dispatch while traits only allow you to overload one argument, which is called single dispatch