Skip to main content
  1. Rust/

Traits in Rust: From Fundamentals to Monomorphic Zero-Cost Code

2112 words·10 mins· loading · loading · · ·Rust-middle
About Rust - This article is part of a series.
Articles read 0/4
0%
🔵 Intermediate level (Rust-middle)

Introduction: Why Do We Need Traits?
#

Imagine we are building a fitness application with different exercise types: Pushups and Plank. Both need to calculate calories burned, but do so using different logic:

struct Pushups {
    reps: u32,
}

struct Plank {
    duration_sec: u32,
}

impl Pushups {
    fn calories_burned(&self) -> u32 {
        self.reps * 1
    }
}

impl Plank {
    fn calories_burned(&self) -> u32 {
        self.duration_sec * 2
    }
}

Now we face a software engineering problem—writing a single function to calculate total calories burned across any workout exercises. Without abstractions, we would be forced to duplicate the function implementation for every distinct type.


How Rust Solves This Problem
#

In many programming languages, this problem is solved using interfaces or base classes. However, without explicit type bounds, error messages often pop up deep inside template instantiation code, accompanied by massive compiler tracebacks.

Rust solves this problem using Traits—explicit behavioral contracts.

First, we declare the Workout behavioral contract:

pub trait Workout {
    fn calories_burned(&self) -> u32;
}

Next, we declare that our structs implement this trait:

impl Workout for Pushups {
    fn calories_burned(&self) -> u32 {
        self.reps * 1
    }
}

impl Workout for Plank {
    fn calories_burned(&self) -> u32 {
        self.duration_sec * 2
    }
}

Now we can write a single unified function using a trait bound (T: Workout):

fn total_calories<T: Workout>(exercises: &[T]) -> u32 {
    exercises.iter().map(|e| e.calories_burned()).sum()
}

What Happens Under the Hood?
#

The compiler does not invoke a single dynamic virtual method at runtime. Instead, it generates a specialized function copy for every concrete type at compile time (monomorphization). As a result, you gain clean architectural flexibility with the raw speed of pure C (Zero-cost abstractions).

Try our step-by-step interactive example below: in step 1, you will observe a compiler error when attempting to call a method on an unbounded type T, and in step 2—its resolution via the Workout trait:

Example: Traits in Practice (Fitness Workouts)
Step 1/2
 1// ?compile_fail
 2struct Pushups {
 3    reps: u32,
 4}
 5
 6impl Pushups {
 7    fn calories_burned(&self) -> u32 {
 8        self.reps * 1
 9    }
10}
11
12// Attempting to write a generic function WITHOUT a trait bound T
13fn total_calories<T>(exercises: &[T]) -> u32 {
14    exercises.iter().map(|e| e.calories_burned()).sum()
15}
16
17fn main() {
18    let pushups = vec![Pushups { reps: 20 }, Pushups { reps: 30 }];
19    let total = total_calories(&pushups);
20    println!("Total calories: {}", total);
21}
22

Step 1: Compile error without trait bound

  • Attempting to invoke method calories_burned() on generic type T without contract declaration.
  • The compiler does not know if type T possesses such a method and blocks compilation.

1. What Are Traits & Basic Syntax
#

A Trait is a specification of a set of methods that a type must be capable of executing. A trait itself stores no data payload; it strictly declares behavior.

Let’s declare a simple trait Say and implement it for a Dog struct. By Rust convention, trait and struct names are written in CamelCase.

// Declare the contract: anything implementing Say can "say" something
pub trait Say {
    fn say(&self);
}

struct Dog;

// Implement the contract for Dog
impl Say for Dog {
    fn say(&self) {
        println!("Woof!");
    }
}

Now, when we instantiate a Dog, we can invoke the method using dot syntax:

let dog = Dog;
dog.say(); // Outputs: Woof!
Important

Scope Requirement: Don’t forget to import the trait! To call a trait method on a type, the trait itself must be brought into scope using the use keyword (e.g., use crate::Say;). Otherwise, the compiler will emit an error even if the type implements the trait.


2. Anatomy of Trait Members
#

Inside a trait, you can declare more than just instance methods.

Instance Methods and Associated Functions
#

  • Instance Methods: Receive self, &self, or &mut self as their first argument. Called on an instance (dog.say()).
  • Associated Functions: Do not take self as the first argument. These act as “static methods” called via namespace syntax (Dog::species()).
pub trait Animal {
    fn name(&self) -> &str;        // Instance method
    fn species() -> &'static str; // Associated function without self
}

struct Dog;

impl Animal for Dog {
    fn name(&self) -> &str { "Rex" }
    fn species() -> &'static str { "Canis lupus" }
}

// Associated function call:
println!("Species: {}", Dog::species());

Default Implementations
#

We can provide a default function body directly inside the trait declaration. Implementing types can either inherit this default behavior or override it when needed:

pub trait Greet {
    fn hello(&self) {
        println!("Hello!"); // Default implementation
    }
}

struct Student;
impl Greet for Student {} // Empty impl body — uses default behavior

Associated Constants
#

Traits can declare constants whose values are specified during implementation for concrete types. Their names are written in UPPER_SNAKE_CASE:

pub trait HasId {
    const ID: usize;
}

struct User;
impl HasId for User {
    const ID: usize = 42;
}

3. Marker Traits: Copy vs. Clone
#

Rust features two fundamental traits that dictate how data payloads are copied in memory:

TraitCategoryMechanismUsage Code
CloneExplicit TraitCreates a deep copy of an object (can allocate heap memory and run arbitrary logic)Requires explicit .clone() call
CopyMarker Auto-TraitSignals bitwise memcpy capability. Changes move semantics into implicit copy semanticsOccurs automatically during assignment or function passing
let a = String::from("abc");
let b = a; // Move (ownership moves to b, 'a' is no longer accessible)

let x = 5;
let y = x; // Copy (value of x is implicitly copied, both variables accessible)

The Copy trait is a subtrait of Clone (trait Copy: Clone {}). You cannot make a type Copy without also implementing Clone.

  • Primitive numeric types (i32, f64), bool, char implement Copy natively.
  • For custom structs, we can derive them automatically: #[derive(Copy, Clone)].

4. Generics, Trait Bounds, and impl Trait
#

Generics (generic types) allow writing structs and functions that operate across arbitrary data types.

struct Point<T> {
    x: T,
    y: T,
}

Trait Bounds
#

When we want to perform operations on generic type T, we apply Trait Bounds:

impl<T> Point<T>
where 
    T: std::ops::Add<Output=T> // Restricting type T to addition contract
{
    fn add(self, other: Self) -> Self {
        Self {
            x: self.x + other.x,
            y: self.y + other.y,
        }
    }
}

Ergonomic impl Trait in Arguments
#

For most standard functions in Rust, instead of verbose fn foo<T: Trait>(x: T), a more concise syntax is preferred: impl Trait:

fn print_workout(w: impl Workout) {
    println!("Calories: {}", w.calories_burned());
}

Both forms are fundamentally equivalent: the compiler transforms impl Trait into a generic function at compile time.


5. Monomorphization Mechanics
#

Unlike languages like Java or C# where generics are erased at runtime, or Python where everything is checked dynamically, Rust utilizes monomorphization.

When the compiler encounters generic code, it inspects all concrete types passed into the function calls and generates physical function copies for every distinct type during compilation.

// Generic source code
fn foo<T>(x: T) { ... }

foo(5);       // Called with i32
foo("Hello"); // Called with &str

Under the hood, the compiler conceptually transforms this into two concrete functions:

fn foo_i32(x: i32) { ... }
fn foo_str(x: &str) { ... }

Advantages of Monomorphization:
#

  • Zero-Cost Abstractions: Execution runs at raw machine speed because types are fixed upfront—no runtime type checks or dynamic dispatch overhead.
  • Aggressive Inlining: The compiler can easily inline short function bodies directly into call sites.

Disadvantages of Monomorphization:
#

  • Slower Compile Times: The compiler must compile and optimize multiple generated function copies.
  • Binary Code Bloat: Machine code size expands as duplicate function copies accumulate.

Explore traits and static polymorphism in action in our interactive code slides below:

Traits and Static Polymorphism
Step 1/5
 1pub trait Say {
 2    fn say(&self) -> String;
 3}
 4
 5struct Dog;
 6
 7impl Say for Dog {
 8    fn say(&self) -> String {
 9        "Woof!".to_string()
10    }
11}
12
13fn main() {
14    let dog = Dog;
15    println!("Dog says: {}", dog.say());
16}
17

Trait Declaration and Implementation

  • Trait Say describes contract: any type implementing it must be able to speak.
  • Struct Dog implements method say from trait Say.
  • After implementation, method becomes available on the struct using dot notation.

Test Your Knowledge!
#

Take a short quiz to consolidate your understanding of traits and static polymorphism in Rust.

Article read
Please rate how helpful and clear this article was to you
Article series
About Rust - This article is part of a series.
Articles read 0/4
0%
🔵 Intermediate level (Rust-middle)

Related