Rust Traits: Shared Behavior Without Inheritance
Understand how Rust traits define shared behavior, with default methods, trait bounds, derive, and a clear take on dyn Trait versus impl Trait.
What you'll learn
- ✓Define a trait and implement it for your types
- ✓Add default methods that implementers can override
- ✓Constrain generics with trait bounds and where clauses
- ✓Use derive to auto-implement common traits
- ✓Choose between impl Trait and dyn Trait
Prerequisites
- •A working Rust toolchain — see /blog/rust-install-and-first-program
- •Familiarity with types — see /blog/rust-variables-and-types
- •Comfort writing functions — see /blog/rust-functions
Traits are how Rust expresses shared behavior. A trait describes a contract: a set of methods a type must provide. Any type — yours or someone else’s — can implement a trait, and generic code can demand “any T that implements this contract.” Unlike classical inheritance, traits compose without hierarchies and resolve almost entirely at compile time.
Defining a Trait
A trait is a named bundle of method signatures.
trait Summary {
fn summarize(&self) -> String;
}
Implement it for a concrete type with impl Trait for Type.
struct Article {
title: String,
body: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{} ({} chars)", self.title, self.body.len())
}
}
let a = Article { title: "Hello".into(), body: "World".into() };
println!("{}", a.summarize());
The same trait can be implemented for many types, giving them a common method without sharing storage or layout. This is the central design move: behavior in one place, data in another.
Default Methods
A trait can supply default bodies. Implementers may override them or accept the default.
trait Summary {
fn title(&self) -> String;
fn summarize(&self) -> String {
format!("Read more about {}...", self.title())
}
}
struct Tweet { handle: String, text: String }
impl Summary for Tweet {
fn title(&self) -> String { self.handle.clone() }
// summarize uses the default
}
Defaults are excellent for ergonomics: required methods stay small, while convenience helpers come for free.
Trait Bounds on Generics
Traits become powerful when paired with generics. A bound on a type parameter says “this generic only accepts types that implement this trait.”
fn announce<T: Summary>(item: &T) {
println!("Breaking: {}", item.summarize());
}
For longer bounds, use a where clause for readability.
fn pair<T, U>(a: T, b: U) -> (T, U)
where
T: Summary + Clone,
U: Summary,
{
(a.clone(), b)
}
Multiple bounds combine with +. You can also bound a trait itself with a supertrait, requiring implementers to also implement another trait.
trait Detailed: Summary {
fn detail(&self) -> String;
}
Deriving Common Traits
Many traits in std have well-known, mechanical implementations. Use #[derive(...)] to ask the compiler for them.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Tag(String);
Useful candidates include Debug, Clone, Copy, PartialEq, Eq, Hash, Default, and PartialOrd. Derive only works when every field also implements the requested trait.
Trait Objects with dyn Trait
Sometimes you need a heterogeneous collection of values that share a trait. Use a trait object via dyn Trait behind a pointer such as &dyn, Box<dyn>, or Arc<dyn>.
fn print_all(items: &[Box<dyn Summary>]) {
for item in items {
println!("{}", item.summarize());
}
}
Trait objects use dynamic dispatch: the call site looks up the method through a vtable at runtime. This trades a small amount of speed for the ability to store different concrete types together. Not every trait is “object safe” — those with generic methods or Self by value usually are not.
Static Dispatch with impl Trait
For single-type cases, impl Trait keeps things zero-cost. As an argument it is shorthand for a generic.
fn announce(item: impl Summary) {
println!("{}", item.summarize());
}
As a return type, impl Trait says “I return some specific type that satisfies this trait, but I am not telling you which.”
fn make_summary() -> impl Summary {
Article { title: "T".into(), body: "B".into() }
}
The body must return exactly one concrete type. If you need to return different concrete types from different branches, reach for Box<dyn Trait> instead.
Quick Comparison
impl Traitargument: ergonomic generic, monomorphized, fast.impl Traitreturn: hides the type, single concrete type per function.dyn Trait: dynamic dispatch, allows mixed concrete types, requires a pointer.
A practical rule: start with impl Trait. Move to generics with bounds when you need multiple type parameters. Reach for dyn Trait when you need a heterogeneous container or a stable boxed return type.
Implementing on Foreign Types
Rust’s orphan rule says you can implement a trait for a type only if either the trait or the type is defined in your crate. This prevents two crates from silently providing conflicting impls. If you need to extend a foreign type with a foreign trait, wrap it in a newtype.
struct WrappedVec(Vec<u32>);
impl Summary for WrappedVec {
fn summarize(&self) -> String {
format!("{} items", self.0.len())
}
}
This newtype pattern is also how you attach behavior without leaking implementation details.
Working With Ownership
Trait methods take the same self, &self, or &mut self choices as inherent methods, and they participate in the same borrow checks. If you need a refresher on what each form means, revisit /blog/rust-ownership-basics. Most reader methods take &self; methods that mutate take &mut self; consuming transformations take self.
A Worked Example
A small calculator that prints results through a trait.
trait Op {
fn apply(&self, a: i32, b: i32) -> i32;
fn name(&self) -> &'static str;
fn describe(&self, a: i32, b: i32) -> String {
format!("{}: {}", self.name(), self.apply(a, b))
}
}
struct Add;
struct Mul;
impl Op for Add {
fn apply(&self, a: i32, b: i32) -> i32 { a + b }
fn name(&self) -> &'static str { "add" }
}
impl Op for Mul {
fn apply(&self, a: i32, b: i32) -> i32 { a * b }
fn name(&self) -> &'static str { "mul" }
}
fn run(ops: &[Box<dyn Op>], a: i32, b: i32) {
for op in ops {
println!("{}", op.describe(a, b));
}
}
Add and Mul share no data and no inheritance chain, yet they participate uniformly in run thanks to the trait object.
Wrap up
Traits give Rust polymorphism without inheritance: define a contract once, implement it for many types, and let generics enforce it. Defaults, bounds, and derive make traits ergonomic, while impl Trait and dyn Trait cover the static and dynamic dispatch cases. With this foundation, the standard library starts to feel like a coherent toolkit rather than a pile of types.