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.

DimensionGenerics (static dispatch)Trait objects (dynamic dispatch)
When resolution happensCompile timeRuntime
PerformanceZero-cost abstraction; often inlinedIndirect call via vtable
Binary sizeSeparate code per concrete typeSingle function body
CollectionsHomogeneous: every element must be the same typeHeterogeneous: can mix types
Additional requirementsNoneTrait 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 Temperature and a CriticalTemperature both implement Diagnostic, 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 Widget or Drawable. Each widget struct implements the trait, and the framework stores them as Vec<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 the Error trait, wrapping the concrete error in a box. Libraries such as anyhow build 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 with dyn 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.