Rust Needs Metaphors, Part 2: Traits
When I began learning Haskell, typeclasses were confusing to me: at first, because I didn’t fully understand why they were needed, and later, because I didn’t understand the advantages they offered over Java’s interfaces. If you’re new to Rust but haven’t used Haskell before, you’ll likely be in the same boat, but moreso; traits are pervasive in the ecosystem.
At heart, traits are very similar to interfaces in Java or C♯. They describe the methods that must be implemented for a type in order to treat it as being bound by the contract. For example, if we use the classic example of animals that can make a sound:
trait Speaker {
fn speak() -> Option<String>;
}
struct Dog {}
impl Speaker for Dog {
fn speak() -> Option<String> {
Some("Woof!".to_string())
}
}
struct Cat {}
impl Speaker for Cat {
fn speak() -> Option<String> {
Some("Meow!".to_string())
}
}
struct Fox {}
impl Speaker for Fox {
fn speak() -> Option<String> {
None
}
}
The crucial difference between traits and Java or C♯ interfaces is that the implementation of traits is not tied to the declaration of the type. You can implement your own trait for a foreign type, or a foreign trait for your own type, modifying a type’s behavior after the type was originally defined. That brings us to the first rule, though: You can’t meddle in foreign affairs. If you don’t control either the trait or the type, you can’t implement the trait for that type. If you really must, you can use the newtype idiom to define an analogous type that is under your control.
That rule doesn’t capture the first trait-related confusion you’re likely to hit when learning Rust, though. Reading documentation, you’ll often find methods that you want to use. They aren’t mentioned as being special in any way, but they don’t show up in your autocompletion and won’t compile. What gives? Well, you can’t use what you can’t see. Functionality in Rust is often implemented using traits to allow later extension, but if you don’t pull the trait into scope you can’t use its methods.
In some cases, types have almost all of their implementation defined as traits. The best example, I think, is the Rusoto ecosystem of AWS SDKs. It’s divided into crates for each service, and each service in turn is implemented as a client type and a service trait:
use rusoto_core::Region;
use rusoto_s3::{S3Client, S3};
#[tokio::main]
async fn main() {
let client = S3Client::new(Region::UsEast1);
println!("{:?}", client.list_buckets().await)
}
In this example, S3Client
is a struct and S3
is a trait. Almost all of the API
functionality (like list_buckets
here) is implemented as items on that trait. If you
don’t import S3
alongside the concrete type, you can’t use them.
Last, for people coming from Haskell, it’s important to realize that you can’t believe everything you read. Rust traits are similar to, but aren’t identical to, Haskell typeclasses. There are limitations in the Rust type system that mean that you can’t express certain types of abstraction that you can in Haskell and its children, so be careful with carrying idioms over verbatim. (The next step along the road toward more complete correspondence is called “Generic Associated Types” (GATs) in the Rust ecosystem, and is the subject of RFC #1598.)
Overall, I think that traits add an important piece of abstraction to a language, without too much additional complexity. If you want more detail, you might find the talk “Traits and You: A Deep Dive” helpful. If you’re interested in the concept’s origins, you may like the talk “Adventure with Types in Haskell” by Simon Peyton-Jones and the original paper by Philip Wadler.