Inheritance and Polymorphism via Traits
How Rust achieves inheritance-like code reuse and polymorphism using traits, default method implementations, generics, and trait objects
What Rust Does Differently
Most object-oriented languages bundle three ideas under the label “inheritance”: a way to reuse code from a parent class, a way to express that one type is a more general type, and a runtime mechanism that lets you treat different types uniformly. Rust has no class inheritance — no extends keyword, no parent structs, no implicit field copying. Yet the practical needs that drive developers to inheritance are handled by traits, just through a different mechanism. Traits let you share behaviour across unrelated types, write functions that work with many types at once, and decide at compile time (or, if you need it, at runtime) which concrete implementation to call.
The result is a system that feels different if you are arriving from C# or Java, but once you understand how default methods, generic bounds, and trait objects map onto the two big reasons people use inheritance — code reuse and polymorphism — the pieces fit together.
Code Reuse Without Inheritance: Default Method Implementations
Classical inheritance lets a child class pick up method implementations from a parent. You override what you need and inherit the rest. Rust traits support the same pattern with default methods. When you define a trait, you can provide a body for any method right in the trait definition. Any type that implements that trait automatically gets that method, without writing a single line of code for it.
Consider a Diagnostic trait that gives a standard way to report a value’s type name. Most types can just print their Rust type via std::any::type_name, so we supply a default.
use std::any::type_name;
trait Diagnostic {
// Required: each type must tell us its "category".
fn category(&self) -> &str;
// Default: a full report built from the category and the concrete type name.
fn report(&self) -> String {
format!(
"[{}] value of type `{}`",
self.category(),
type_name::<Self>()
)
}
}
The only thing a type must implement is category. It gets report for free. This is exactly the same relationship as a parent class providing a method that the child inherits.
struct Temperature(f64);
impl Diagnostic for Temperature {
fn category(&self) -> &str {
if self.0 < -273.15 {
"invalid"
} else if self.0 < 0.0 {
"freezing"
} else {
"normal"
}
}
}
let t = Temperature(-5.0);
println!("{}", t.report());
// Output: [freezing] value of type `playground::Temperature`
The report method calls self.category(), which dispatches to the specific implementation provided by Temperature. The type didn’t have to write the formatting logic — it inherited it.
Overriding Default Methods
If a type wants a different behaviour for a default method, it can override it inside its impl block. That mirrors how a child class overrides a parent’s method. In the next example, a CriticalTemperature changes how the report is formatted but reuses the same category logic.
struct CriticalTemperature(f64);
impl Diagnostic for CriticalTemperature {
fn category(&self) -> &str {
// deliberately reuse the same logic as Temperature
if self.0 < -273.15 {
"invalid"
} else if self.0 < 0.0 {
"freezing"
} else {
"normal"
}
}
fn report(&self) -> String {
format!("CRITICAL: {}", self.category())
}
}
A type can even call the default version inside its override, though the syntax is a little indirect: you call the trait’s method explicitly on self, like <CriticalTemperature as Diagnostic>::report(self), which Rust can sometimes infer.
No Field Inheritance:
Default methods share behaviour, not data. A trait cannot contain fields. If you need shared data, you use composition: store the shared data in a separate struct and implement the trait by delegating to that field. This keeps data ownership explicit and avoids the fragile base class problem — no parent class can silently corrupt a child’s state.
Polymorphism Without Inheritance: Generics as Bounded Parametric Polymorphism
In classical OOP, the main way to write code that works with multiple types is to accept a reference to a parent class and call virtual methods. Rust achieves the same goal — a single piece of code that operates on many types — through generics with trait bounds. This is known as bounded parametric polymorphism.
Write a function that works with anything Diagnostic, and the compiler will generate a separate, concrete version of that function for every type you actually call it with. There is no runtime lookup, no vtable, and no performance cost for the abstraction.
fn print_diagnostic(d: &impl Diagnostic) {
println!("{}", d.report());
}
fn main() {
print_diagnostic(&Temperature(22.0));
print_diagnostic(&CriticalTemperature(-300.0));
}
Here, &impl Diagnostic is syntactic sugar for a generic parameter: fn print_diagnostic<T: Diagnostic>(d: &T). Both are the same once compiled. If you call print_diagnostic with a Temperature, the compiler stamps out a version where T is Temperature. For CriticalTemperature, it stamps out another. The report call is resolved to the concrete method at compile time — no indirection.
This approach is the Rust analogue of “a function that accepts any subclass of Parent” but completely resolved before the binary runs.
Tight Coupling at Compile Time:
Generics are monomorphized: the compiler produces a unique copy of the function for each concrete type. This gives excellent runtime performance, but it can increase compile times and binary size if a function is used with many different types. For most programs, this trade-off is invisible. When you genuinely need a single function body that handles different types at runtime without duplicating code, trait objects are the alternative.
Runtime Polymorphism with Trait Objects
Sometimes you cannot know every type at compile time. A plugin system, an event queue, or a GUI widget tree all need to store a collection of items that share a common interface but whose concrete types are determined later. Rust provides trait objects for exactly this situation.
A trait object is a fat pointer: it bundles a pointer to the data and a pointer to a vtable — a table of function pointers for each method in the trait. When you call a method on a dyn Diagnostic, the compiler inserts an indirect call through that vtable. The behaviour is very close to Java’s interface dispatch or C++ virtual methods.
fn log_all(items: &[&dyn Diagnostic]) {
for item in items {
println!("{}", item.report());
}
}
fn main() {
let t = Temperature(100.0);
let c = CriticalTemperature(50.0);
let list: Vec<&dyn Diagnostic> = vec![&t, &c];
log_all(&list);
}
The vector holds references to two different concrete types (Temperature and CriticalTemperature), yet item.report() works because both implement Diagnostic. The compiler emits one log_all function, and the vtable figures out which report to call at runtime.
Ownership with Boxed Trait Objects
A &dyn Diagnostic borrows data that lives somewhere else. If you need the collection to own its elements, you use Box<dyn Diagnostic>. A box is a heap-allocated owning pointer, and Box<dyn Diagnostic> is a trait object that also manages the memory.
let mut plugins: Vec<Box<dyn Diagnostic>> = Vec::new();
plugins.push(Box::new(Temperature(15.0)));
plugins.push(Box::new(CriticalTemperature(-5.0)));
for p in &plugins {
println!("{}", p.report());
}
When the vector is dropped, each box is dropped, and the heap memory is freed. This pattern appears in many Rust applications: event handlers, error type erasure with Box<dyn Error>, and dependency injection.
Trait Objects Are Unsized:
A trait object, written as dyn Trait, does not have a known size at compile time. You can never put a bare dyn Diagnostic on the stack or store it as a value. It must always sit behind a pointer — &dyn Diagnostic, Box<dyn Diagnostic>, Rc<dyn Diagnostic>, and so on. The compiler will reject any attempt to use it otherwise.
Static vs Dynamic Dispatch — Choosing the Right Tool
The two approaches solve the same problem — writing code against an interface rather than a concrete type — but at different points in the program’s life.
| Dimension | Generics (static dispatch) | Trait objects (dynamic dispatch) |
|---|---|---|
| When resolution happens | Compile time | Runtime |
| Performance | Zero-cost abstraction; often inlined | Indirect call via vtable |
| Binary size | Separate code per concrete type | Single function body |
| Collections | Homogeneous: every element must be the same type | Heterogeneous: can mix types |
| Additional requirements | None | Trait must be object-safe |
Use generics when you can express the type set at compile time and care about every last bit of performance. Use trait objects when you genuinely need a heterogeneous collection or when you want to erase the concrete type to reduce code bloat and compilation time.
No Need to Guess Early:
Rust lets you start with generics and later refactor to trait objects if you discover that the caller genuinely needs runtime polymorphism. Because both expose behaviour through the same trait, the surrounding code changes minimally.
Why Rust Avoids Classical Inheritance
If you’ve debugged a deep class hierarchy in C++ or Java, you’ve likely encountered at least one of these: a parent class that does too much, a method that doesn’t make sense for a particular subclass, or a diamond dependency where two parents clash. Rust’s design sidesteps these problems by separating data from behaviour.
- No implicit field inheritance. A struct does not inherit fields from a parent. If you need shared data, you put it inside a separate struct and use composition. This makes ownership and borrowing crystal clear — there is no “protected” field that someone four levels down might mutate.
- Traits define capabilities, not taxonomies. A type implements a trait because it can do something, not because it is something. A
Temperatureand aCriticalTemperatureboth implementDiagnostic, but there is no language-enforced hierarchy tying them together. You can add a trait to a type from any crate that owns the trait or the type, without modifying the original struct definition. - Single-implementation inheritance of methods. A trait can provide default methods, giving you the same “write once, use everywhere” benefit without the pitfalls of deep class chains. If a type needs a different behaviour, it overrides — and nothing else is affected.
This design is not a missing feature; it’s a deliberate choice that makes large codebases easier to refactor. The Gang of Four’s principle of “program to an interface, not an implementation” is embedded in the language itself.
Object Safety Limits Trait Objects:
Not every trait can be turned into a trait object. If a trait has a method that returns Self (like a builder pattern), uses generic type parameters, or has associated constants that depend on Self, it is not object-safe. The compiler will give a clear error when you try to write dyn Trait for such a trait, often suggesting you use generics instead.
Common Misconceptions and Mistakes
“Traits are just interfaces, so they can replace classes”
Traits are more flexible than interfaces in many languages because you can implement a trait for a type after the type is defined. But a trait does not carry state. Beginners sometimes try to define fields inside a trait, which is not allowed. If you need state, use a struct and implement the trait.
“I can always use dyn Trait anywhere I can use impl Trait”
impl Trait in argument position is syntactic sugar for a generic, resolved at compile time. dyn Trait is a runtime construct with different trade-offs and the object safety restriction. They are not interchangeable, and the compiler will stop you if you try.
“Box<dyn Trait> is like a base class pointer — I can downcast it”
Rust does not have a built-in downcasting mechanism for arbitrary trait objects. If you need to recover the concrete type, you can use std::any::Any or an enum, but downcasting is rarely the idiomatic solution. Usually, the trait should be enough.
“Default methods are inherited automatically — I can call them on any type that implements the trait”
Yes, but only when the type is accessed through a path that knows it implements the trait. If you have a bare struct value, you can call value.method() only if the method is in scope (i.e., the trait is in scope). Without a use statement importing the trait, the method call won’t compile.
“Generics cause code bloat, so I should avoid them”
In practice, Rust’s monomorphization of generics rarely causes noticeable bloat unless you are compiling with many distinct concrete types. The compiler is good at deduplication, and the performance benefit of static dispatch is often worth it. Start with generics and only switch to trait objects when you have a specific reason.
Real-World Usage Patterns
Trait-based polymorphism appears throughout the Rust ecosystem, often where OOP languages would use inheritance.
- GUI frameworks define traits like
WidgetorDrawable. Each widget struct implements the trait, and the framework stores them asVec<Box<dyn Widget>>to lay out and render a dynamic widget tree. - Error handling uses
Box<dyn std::error::Error>to let functions return any error type that implements theErrortrait, wrapping the concrete error in a box. Libraries such asanyhowbuild on this. - Logging and tracing libraries define traits like
Log; applications implement the trait for different backends (console, file, remote server), and the core system works withdyn Log. - Plugin systems load dynamic libraries and produce boxed trait objects that the host application treats uniformly.
The pattern is the same: define what a type can do in a trait, implement it for each concrete type, and decide at the boundary whether to use generics or trait objects based on the need for compile-time resolution or runtime heterogeneity.
Moving Beyond Inheritance
If you think of inheritance as a single tool for two separate jobs — code reuse and subtyping polymorphism — then Rust’s trait system is a case of supplying two better tools for those jobs. Default methods give you code reuse without coupling data. Generics give you zero-cost polymorphic functions. Trait objects give you runtime dispatch when you need it.
The absence of class inheritance is not a gap; it’s a design decision that removes a class of bugs while preserving the expressive power that made inheritance popular. Once you’ve built a few systems in Rust, the idiom of “compose structs, share behaviour with traits” becomes as natural as class hierarchies ever were — and often easier to maintain.