A trait defines a set of methods that a type must implement — it's Rust's version of an interface or abstract class. Traits are how Rust achieves polymorphism: the same function can work on any type that satisfies a set of trait requirements, without runtime overhead.
Generics let you write code that works for any type that meets specific constraints (trait bounds). The compiler monomorphizes generic code — it generates a separate, concrete version for every type you actually use — so the runtime binary contains zero abstraction overhead. Think of it as "copy-paste by the compiler, paid once at compile time."
The two ways to use traits in function signatures:
impl Trait (static dispatch — monomorphized, zero cost, return type must be one concrete type)
vs dyn Trait (dynamic dispatch — vtable at runtime, allows heterogeneous
collections, small runtime cost). Knowing when to pick which is a key mid-level skill.
Trait Definition & impl
A trait declares what a type must be able to do. You implement it with impl MyTrait for MyType. Multiple types can implement the same trait — that's polymorphism.
impl Trait — Static Dispatch
Used in function params (fn foo(x: impl Trait)) or return position (fn foo() -> impl Trait). The compiler resolves the exact type at compile time — zero runtime cost. Return type must be a single concrete type.
dyn Trait — Dynamic Dispatch
Pointer to a vtable at runtime (Box<dyn Trait>, &dyn Trait). Allows heterogeneous collections — different concrete types behind the same pointer. Small overhead: one extra pointer indirection per call.
where Clauses
Move trait bounds out of the angle brackets into a separate where block for readability. Necessary when bounds get complex or reference associated types: where T: Clone + Debug, T::Item: Send.
Default Method Implementations
Traits can provide default implementations for methods — implementors can override them or use the default. This is how Rust avoids abstract base classes and gives you free behaviour for new types.
Trait Objects Must Be Object-Safe
Not all traits can become dyn Trait. A trait is object-safe if its methods don't use Self by value or have generic type parameters — because the vtable must have a fixed layout for any concrete type.
impl Greet works with all types that implement it.
// Define a trait — like an interface in other languages trait Greet { fn name(&self) -> &str; // Default implementation — free for any implementor fn hello(&self) { println!("Hello, my name is {}!", self.name()); } } struct Human { name: String } struct Robot { model: String } impl Greet for Human { fn name(&self) -> &str { &self.name } // `hello` — uses the default implementation above } impl Greet for Robot { fn name(&self) -> &str { &self.model } // Override default for robots fn hello(&self) { println!("BEEP. Designation: {}. BOOP.", self.name()); } } fn main() { let h = Human { name: String::from("Mishal") }; let r = Robot { model: String::from("R2-D2") }; h.hello(); // "Hello, my name is Mishal!" (default) r.hello(); // "BEEP. Designation: R2-D2. BOOP." (overridden) }
- The trait definition generates a vtable shape — a list of function signatures that any implementor must fill.
- Default method implementations are just Rust code inside the trait body — the compiler pastes them into the implementing type if it doesn't override them. There's no virtual dispatch happening here at the trait level; default methods are resolved statically if called on a concrete type.
impl Greet for Human— Rust checks at compile time that every required method (those with no default) is implemented. Missing one = compile error.- At this stage, no vtable is used — calling
h.hello()on a concreteHumanis a direct function call, fully inlined by the optimizer if it wants to.
Greet. At compile time the compiler stamps out a separate version for each concrete type used — this is monomorphization.
// Generic function — T must implement Greet fn introduce<T: Greet>(entity: &T) { println!("Introducing: {}", entity.name()); entity.hello(); } // Multiple trait bounds with + syntax use std::fmt::Debug; fn introduce_and_debug<T: Greet + Debug>(entity: &T) { entity.hello(); println!("Debug view: {:?}", entity); } // Same thing using WHERE CLAUSE — easier to read with many bounds fn introduce_where<T>(entity: &T) where T: Greet + Debug, { entity.hello(); println!("Debug: {:?}", entity); } #[derive(Debug)] struct Human { name: String } impl Greet for Human { fn name(&self) -> &str { &self.name } } fn main() { let h = Human { name: String::from("Mishal") }; introduce(&h); introduce_where(&h); }
- Monomorphization:
introduce::<Human>andintroduce::<Robot>are compiled into two separate functions in the binary. Each call site knows the exact type at compile time — no indirection, no vtable lookup. - This is why generics are called "zero-cost abstractions" — the abstraction only exists in source code; the compiled output is as efficient as if you had written separate functions by hand.
- Trade-off: monomorphization can increase binary size (code bloat) if you use many different concrete types. This is why
dyn Traitexists — one function body, multiple types. T: Greet + Debugis a compound trait bound: T must satisfy both traits simultaneously.whereis purely ergonomic — same semantics, just more readable when bounds are long.
impl Trait in both parameter position (syntactic sugar for a generic bound) and return position (tell the caller "you'll get something that implements this trait" without naming the concrete type — essential for returning closures and iterators).
use std::fmt::Display; // PARAMETER position — same as fn foo<T: Display>(val: T) // Difference: with impl Trait here, each param gets its OWN anonymous type. // fn foo(a: impl Display, b: impl Display) could be two DIFFERENT types. fn print_value(val: impl Display) { println!("Value: {}", val); } // RETURN position — "I return something that impl Display, but won't tell you what type" // Compiler knows the concrete type and optimises it — static dispatch fn make_greeting(name: &str) -> impl Display { format!("Hello, {}!", name) // returns String, but caller sees `impl Display` } // KEY USE CASE: returning closures — closures have unique unnameable types fn make_adder(x: i32) -> impl Fn(i32) -> i32 { move |y| x + y // the closure captures x — its type is unnameable } // KEY USE CASE: returning iterators — iterator types are deeply nested fn evens_up_to(n: u32) -> impl Iterator<Item = u32> { (0..=n).filter(|x| x % 2 == 0) // actual type: Filter<RangeInclusive<u32>, [closure]> — you don't want to type this } fn main() { print_value(42); print_value("hello"); println!("{}", make_greeting("Mishal")); let add5 = make_adder(5); println!("5 + 3 = {}", add5(3)); let evens: Vec<_> = evens_up_to(10).collect(); println!("{:?}", evens); // [0, 2, 4, 6, 8, 10] }
- Parameter position:
fn foo(a: impl Display, b: impl Display)—aandbcan be different concrete types. In contrast,fn foo<T: Display>(a: T, b: T)requires both to be the same type. - Return position: the compiler knows the concrete type at compile time and monomorphizes the call — zero overhead. The caller just can't name the type (useful for closures and complex iterator chains).
- Closures have unique anonymous types (like
[closure@src/main.rs:20:5]) that you cannot write in source code —impl Fn(...)is the only way to return them from a regular function. - If you need to return different concrete types depending on a runtime condition,
impl Traitdoesn't work — that's when you needBox<dyn Trait>.
dyn Trait enables runtime polymorphism — store different concrete types behind the same pointer, decided at runtime. The classic use case: a heterogeneous Vec<Box<dyn Trait>> holding mixed types.
trait Shape { fn area(&self) -> f64; fn name(&self) -> &str; } struct Circle { radius: f64 } struct Rectangle { width: f64, height: f64 } impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } fn name(&self) -> &str { "Circle" } } impl Shape for Rectangle { fn area(&self) -> f64 { self.width * self.height } fn name(&self) -> &str { "Rectangle" } } // `shapes` holds DIFFERENT types — impossible with generics alone // Box<dyn Shape> is a fat pointer: (data ptr, vtable ptr) fn print_shapes(shapes: &[Box<dyn Shape>]) { for shape in shapes { println!("{}: area = {:.2}", shape.name(), shape.area()); } } fn main() { let shapes: Vec<Box<dyn Shape>> = vec![ Box::new(Circle { radius: 3.0 }), Box::new(Rectangle { width: 4.0, height: 5.0 }), Box::new(Circle { radius: 1.5 }), ]; print_shapes(&shapes); // Circle: area = 28.27 // Rectangle: area = 20.00 // Circle: area = 7.07 }
Box<dyn Shape>is a fat pointer — two machine words: one pointing to the heap-allocated data, one pointing to a vtable (a table of function pointers for that concrete type's trait implementations).- When you call
shape.area(), Rust looks up the function pointer in the vtable at runtime — one extra pointer indirection per call. This is the "dynamic" in dynamic dispatch. - The vtable is generated once per (type, trait) combination and stored in the binary's read-only data — not per instance. Each
Box<dyn Shape>carries a pointer to the vtable, not a copy of it. - When to pick
dynoverimpl: when you need heterogeneous types in the same collection, when the concrete type is unknown until runtime (e.g. plugin systems), or when you want a single function body instead of monomorphized copies for every type.
impl Trait (static) and dyn Trait (dynamic). Shows exactly what each compiles to and when one breaks.
trait Speak { fn speak(&self) -> String; } struct Dog; struct Cat; impl Speak for Dog { fn speak(&self) -> String { "Woof!".into() } } impl Speak for Cat { fn speak(&self) -> String { "Meow!".into() } } // --- impl Trait (static dispatch) --- // Compiler generates: fn static_speak_Dog, fn static_speak_Cat // Zero runtime overhead. ONE concrete type per call. fn static_speak(animal: &impl Speak) { println!("[static] {}", animal.speak()); } // --- dyn Trait (dynamic dispatch) --- // ONE function body, handles ANY Speak type at runtime via vtable. // Slight overhead per call — but allows heterogeneous collections. fn dynamic_speak(animal: &dyn Speak) { println!("[dynamic] {}", animal.speak()); } fn main() { let dog = Dog; let cat = Cat; // static: fast, type known at compile time static_speak(&dog); // [static] Woof! static_speak(&cat); // [static] Meow! // dynamic: heterogeneous Vec possible let animals: Vec<&dyn Speak> = vec![&dog, &cat]; for a in &animals { dynamic_speak(*a); } // [dynamic] Woof! // [dynamic] Meow! // ❌ This would NOT compile with impl Trait in return position // if we want different types based on runtime condition: // fn pick_animal(is_dog: bool) -> impl Speak { // if is_dog { Dog } else { Cat } // ❌ ERROR: mismatched types // } // ✅ Use Box<dyn Speak> instead: fn pick_animal(is_dog: bool) -> Box<dyn Speak> { if is_dog { Box::new(Dog) } else { Box::new(Cat) } } println!("{}", pick_animal(true).speak()); // Woof! }
static_speak(&dog)resolves to a call toDog::speakat compile time — the optimizer can even inline it completely.dynamic_speak(&dog)at the call site: (1) the reference is widened into a fat pointer(&Dog data, &Dog's Speak vtable); (2) inside the function,animal.speak()does a vtable lookup: read function pointer fromvtable[0], call it with the data pointer as&self.- The
pick_animalexample demonstrates the key rule:impl Traitin return position can only be ONE concrete type — the compiler must know it statically. If the branch returns different types (DogvsCat), you must useBox<dyn Trait>.
where clause — a generic struct (Wrapper<T>) that pretty-prints and compares its content. Without where, the angle brackets would become unreadably long.
use std::fmt::{Debug, Display}; struct Wrapper<T> { value: T, label: String, } // WITHOUT where — messy with many bounds: // impl<T: Display + Debug + PartialOrd + Clone> Wrapper<T> { ... } // WITH where — clean, easy to read impl<T> Wrapper<T> where T: Display + Debug + PartialOrd + Clone, { fn new(value: T, label: &str) -> Self { Self { value, label: label.to_string() } } fn display(&self) { println!("[{}] Display: {} Debug: {:?}", self.label, self.value, self.value); } fn is_greater_than(&self, other: &T) -> bool { self.value > *other } } fn main() { let w1 = Wrapper::new(42_i32, "score"); let w2 = Wrapper::new("hello", "greeting"); w1.display(); // [score] Display: 42 Debug: 42 w2.display(); // [greeting] Display: hello Debug: "hello" println!("42 > 10? {}", w1.is_greater_than(&10)); // true }
- The
whereclause is purely syntactic — it compiles to identical code as putting bounds in the angle brackets. The only difference is human readability. Wrapper<T>is generic over T — the struct itself has no bound. Bounds are placed on theimplblock, meaning only the methods that need them require T to beDisplay + Debug + PartialOrd + Clone. You could have anotherimpl<T> Wrapper<T>block with fewer bounds for other methods.- This is a common production pattern: keep the struct itself unconstrained, add bounds only to the specific
implblocks that need them. It maximizes the range of types that can use your struct.
Display, Debug, From/Into, Clone, Default. These unlock operator overloads, format strings, ergonomic conversions, and framework integrations.
use std::fmt; #[derive(Debug, Clone, Default)] // Debug, Clone, Default — auto-derived struct Point { x: f64, y: f64, } // Display — what the user sees. Needed for println!("{}", point) impl fmt::Display for Point { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "({}, {})", self.x, self.y) } } // From — enables ergonomic conversions and the .into() pattern impl From<(f64, f64)> for Point { fn from((x, y): (f64, f64)) -> Self { Self { x, y } } } // PartialEq — enables == operator impl PartialEq for Point { fn eq(&self, other: &Self) -> bool { self.x == other.x && self.y == other.y } } fn main() { let p1 = Point { x: 1.0, y: 2.0 }; let p2: Point = (1.0_f64, 2.0_f64).into(); // uses From impl let p3 = Point::from((3.0, 4.0)); let p4 = Point::default(); // Point { x: 0.0, y: 0.0 } println!("Display: {}", p1); // (1, 2) println!("Debug: {:?}", p1); // Point { x: 1.0, y: 2.0 } println!("p1 == p2: {}", p1 == p2); // true println!("p1 == p3: {}", p1 == p3); // false println!("default: {}", p4); // (0, 0) let p_clone = p3.clone(); // Clone — independent copy println!("cloned: {}", p_clone); // (3, 4) }
#[derive(Debug, Clone, Default)]— the compiler auto-generates these trait implementations by inspecting each field.Debugprints all fields,Cloneclones each field,DefaultcallsDefault::default()on each field. Only works if every field's type also implements those traits.From<(f64, f64)> for Pointautomatically gives youInto<Point> for (f64, f64)— Rust has a blanket impl:impl<T, U: From<T>> Into<U> for T. ImplementFrom, getIntofree.DisplayvsDebug:Display({}) is for end users — you control the format.Debug({:?}) is for developers — auto-generated, shows internal structure. Frameworks likeaxumoften requireDisplayon error types;tracingusesDebugfor field logging.- Implementing
PartialEqmanually lets you customize equality — e.g. case-insensitive string comparison, floating-point epsilon checks.
Repository trait so your business logic depends on the abstraction, not the concrete database. This makes testing trivial — swap the real DB for a mock that also implements the trait.
use std::collections::HashMap; // The contract — business logic depends ONLY on this trait trait UserRepository { fn get_user(&self, id: u32) -> Option<&str>; fn save_user(&mut self, id: u32, name: &str); } // REAL implementation — talks to a DB (simplified) struct PostgresRepo { conn_str: String } impl UserRepository for PostgresRepo { fn get_user(&self, id: u32) -> Option<&str> { println!("[postgres] querying {} for id={}", self.conn_str, id); None // simplified — real code sends SQL } fn save_user(&mut self, id: u32, name: &str) { println!("[postgres] INSERT user id={} name={}", id, name); } } // MOCK implementation — in-memory, used in tests struct MockRepo { store: HashMap<u32, String> } impl UserRepository for MockRepo { fn get_user(&self, id: u32) -> Option<&str> { self.store.get(&id).map(|s| s.as_str()) } fn save_user(&mut self, id: u32, name: &str) { self.store.insert(id, name.to_string()); } } // Business logic — generic over ANY UserRepository impl // The logic is the same regardless of real DB or mock fn create_user<R: UserRepository>(repo: &mut R, id: u32, name: &str) { if repo.get_user(id).is_none() { repo.save_user(id, name); println!("User {} created.", name); } else { println!("User {} already exists.", id); } } fn main() { // In tests — use MockRepo, no DB needed let mut mock = MockRepo { store: HashMap::new() }; create_user(&mut mock, 1, "Mishal"); // User Mishal created. create_user(&mut mock, 1, "Mishal"); // User 1 already exists. // In production — use PostgresRepo, same logic let mut pg = PostgresRepo { conn_str: "postgres://localhost/mydb".into() }; create_user(&mut pg, 2, "Alice"); }
- This is the Dependency Inversion Principle in Rust — high-level code (
create_user) depends on an abstraction (the trait), not a concrete implementation. This pattern appears everywhere in production Rust: HTTP handlers, background workers, CLI tools. create_user<R: UserRepository>— static dispatch. The compiler generates a version forMockRepoand another forPostgresRepo. Zero overhead at runtime.- If you need to store the repo in a struct (e.g. an axum handler state), you can use either a generic struct parameter or
Box<dyn UserRepository>. The generic approach is faster; theBox<dyn>approach is more flexible (easier to store in collections, pass across threads with+ Send). - In async Rust, this pattern extends naturally:
trait UserRepository { async fn get_user(...) }— but async traits need theasync_traitcrate (Day 07 topic).
Dependency Injection via Traits
Traits are Rust's seam for testability. Business logic accepts impl Repository — swap a real DB for a fast in-memory mock in tests. No mocking framework needed.
Zero-Cost Generic Algorithms
Standard library sorts, serializers, and math ops are all generic. Your custom algorithms get the same zero-cost treatment via monomorphization — no Java-style boxing.
Plugin & Extension Systems
Box<dyn Trait + Send + Sync> powers plugin architectures — load unknown implementations at runtime (from config, reflection, or dynamic linking) through a fixed interface.
serde / axum / tokio Integration
Every framework integration in Rust works through traits: Serialize/Deserialize for serde, IntoResponse for axum, Future for tokio. Understanding traits unlocks the whole ecosystem.
IntoResponse; extractors implement FromRequest. Adding a new type to your API just means impl-ing those traits.#[derive(Serialize, Deserialize)] auto-implements the traits via proc macros. Custom types with manual impl blocks can control serialization completely.Future trait is how every async task works. impl Future for MyStateMachine is what async fn desugars to under the hood.Connection traits; your query code works against any supported database backend through the same trait-based interface.impl Trait and dyn Trait?
+
impl Trait is static dispatch — the compiler resolves the exact type at compile time and monomorphizes the function, generating a separate version per concrete type. Zero runtime overhead, but return type must be a single concrete type.dyn Trait is dynamic dispatch — the concrete type is resolved at runtime through a vtable (a table of function pointers). One function body handles all types. Slight overhead per call (one extra pointer indirection), but enables heterogeneous collections and runtime-determined types.Rule of thumb: prefer
impl Trait for performance; use dyn Trait when you need to store mixed types together or the concrete type is unknown until runtime.
fn foo<T: Display>(x: T) called with i32 and String produces two distinct functions in the binary.Advantage: zero runtime abstraction overhead — as fast as hand-written type-specific code; the optimizer can inline and specialize further.
Trade-off: binary size bloat — if a generic function is used with many different types, the binary contains many copies.
dyn Trait avoids this with one shared function body.
dyn Trait?
+
dyn Trait). The vtable for a trait object must have a fixed layout, so the compiler requires that:1. No method returns
Self by value (size of Self not known).2. No method has generic type parameters (can't monomorphize per call through a vtable).
3. No associated functions without
&self (can't dispatch through a pointer).If a trait violates these, using
Box<dyn MyTrait> gives a compile error. The fix is usually to remove the generic method from the trait and use a separate generic free function instead.
where clause instead of inline trait bounds?
+
where clauses are identical in semantics — purely a readability choice. Use them when:1. You have multiple bounds on multiple type parameters — the angle brackets become unreadably long.
2. You need to bound associated types:
where T::Item: Send + Sync.3. You want to keep the function signature clean — put the bounds separately.
A rule of thumb: one or two simple bounds → inline. Three or more, or any associated type bounds →
where.
Real example: the
Iterator trait has only one required method (next) — all 80+ other methods (map, filter, collect, etc.) are default implementations built on top of next. Implement next for your custom type and you instantly get the entire iterator API for free.
Display (from std) for Vec<i32> (from std) in your crate — both are foreign.The workaround is the newtype pattern: wrap the foreign type in a local struct (
struct MyVec(Vec<i32>)), then implement the foreign trait on your local newtype. The newtype is yours, so the orphan rule is satisfied.
| Pattern | What It Solves | Used In |
|---|---|---|
Associated Typestype Item |
Bind an output type to a trait impl — cleaner than a type parameter when there's only one natural "output" type (e.g. Iterator::Item, Add::Output). |
Iterator, Add/Sub operators, async streams |
Blanket Implsimpl<T: A> B for T |
Implement a trait for ALL types satisfying some bound — the standard library does this for Into via From, and for ToString via Display. |
std conversions, derive-less trait propagation |
| Newtype Pattern | Wrap a foreign type in a local struct to implement a foreign trait (orphan rule bypass). Also used to add invariants, restrict API surface, or add semantic meaning to primitives. | Error wrapping, unit types (Metres(f64)), API design |
| Trait Specialization (nightly) |
Provide a more specific impl that overrides the blanket impl when a type satisfies additional constraints — not yet stable, but used in std internals. | std string conversions, performance-optimized fast paths |
HRTB on Trait BoundsF: for<'a> Fn(&'a T) |
Require a trait be implemented for all possible lifetimes — necessary when a callback is stored and called multiple times with different-lifetime inputs. | Visitor patterns, iterator adapters, callback APIs |
// Associated type — cleaner than a type parameter when there's one natural output trait Transformer { type Output; fn transform(&self) -> Self::Output; } struct Doubler(i32); impl Transformer for Doubler { type Output = i32; fn transform(&self) -> i32 { self.0 * 2 } } struct Stringifier(i32); impl Transformer for Stringifier { type Output = String; fn transform(&self) -> String { self.0.to_string() } } // Blanket impl — auto-implements Describe for ANYTHING that is Display use std::fmt::Display; trait Describe { fn describe(&self) -> String; } impl<T: Display> Describe for T { fn describe(&self) -> String { format!("I am: {}", self) } } fn main() { println!("{}", Doubler(5).transform()); // 10 (i32) println!("{}", Stringifier(5).transform()); // "5" (String) println!("{}", 42_i32.describe()); // "I am: 42" — blanket impl! println!("{}", "hello".describe()); // "I am: hello" println!("{}", 3.14_f64.describe()); // "I am: 3.14" }