Nugget 5
Home
You have a Dog and a Cat. Both need to
make_sound(). Instead of writing two separate functions,
define a trait — the Rust version of an interface.
Without traits, you'd write the same logic twice — once for each type:
struct Dog { name: String }
struct Cat { name: String }
fn dog_sound(d: &Dog) -> String {
format!("{} says Woof!", d.name)
}
fn cat_sound(c: &Cat) -> String {
format!("{} says Meow!", c.name)
}
fn main() {
let dog = Dog { name: "Rex".into() };
let cat = Cat { name: "Whiskers".into() };
println!("{}", dog_sound(&dog));
println!("{}", cat_sound(&cat));
}
This works but doesn't scale. Every new animal requires a new function. Traits let you write it once.
A trait declares method signatures without implementations:
trait Speak {
fn make_sound(&self) -> String;
}
This says: "Any type implementing Speak must provide a
make_sound method that takes a reference to self and returns a
String."
Now Dog and Cat both implement Speak,
each with their own behavior:
struct Dog { name: String }
struct Cat { name: String }
trait Speak {
fn make_sound(&self) -> String;
}
impl Speak for Dog {
fn make_sound(&self) -> String {
format!("{} says Woof!", self.name)
}
}
impl Speak for Cat {
fn make_sound(&self) -> String {
format!("{} says Meow!", self.name)
}
}
fn main() {
let dog = Dog { name: "Rex".into() };
let cat = Cat { name: "Whiskers".into() };
// Same method name, different behavior
println!("{}", dog.make_sound()); // Rex says Woof!
println!("{}", cat.make_sound()); // Whiskers says Meow!
}
📐 This is polymorphism
The same method name (make_sound) triggers different code
depending on the type. That's runtime polymorphism without inheritance —
traits are Rust's version of interfaces (Java, Go) or type classes (Haskell).
A trait can provide default method bodies. Implementors can override them or use the default:
trait Speak {
fn make_sound(&self) -> String;
// Default implementation — uses make_sound() internally
fn introduce(&self) -> String {
format!("I am a pet! {}", self.make_sound())
}
}
impl Speak for Dog {
fn make_sound(&self) -> String {
format!("{} says Woof!", self.name)
}
// introduce() uses the default
}
impl Speak for Cat {
fn make_sound(&self) -> String {
format!("{} says Meow!", self.name)
}
// Cat can override the default if it wants:
// fn introduce(&self) -> String {
// "I'm a cat and I do what I want.".to_string()
// }
}
fn main() {
let dog = Dog { name: "Rex".into() };
let cat = Cat { name: "Whiskers".into() };
println!("{}", dog.introduce()); // I am a pet! Rex says Woof!
println!("{}", cat.introduce()); // I am a pet! Whiskers says Meow!
}
💡 Why default implementations matter
You define the trait once with sensible defaults. New types only implement
the minimum required methods and get the rest for free. This is how
Display provides .to_string() without you writing it.
Traits let you: