scipio avatar

Learn Rust Series (#8) - Traits & Generics

scipio

Published: 26 Jul 2026 › Updated: 26 Jul 2026Learn Rust Series (#8) - Traits & Generics

Learn Rust Series (#8) - Traits & Generics

Learn Rust Series (#8) - Traits & Generics

rust-banner.png

What will I learn

  • You will learn how traits define shared behavior across unrelated types, without a single line of inheritance;
  • how to write generic functions and structs that work for many types at once, instead of copy-pasting the same logic per type;
  • how trait bounds let a generic function actually call methods on its type parameter, rather than just carry it around;
  • how default methods let a trait ship behavior for free, and how #[derive(...)] hands you common traits without writing them;
  • why Rust's generics compile down to specialized machine code with zero runtime cost -- the famous "abstraction without a tax".

Requirements

  • A working Rust toolchain (via rustup) from episode 1;
  • A terminal and an editor (VS Code with rust-analyzer is a fine pick);
  • Episodes 1 to 7 read, because today builds directly on structs, enums, impl blocks, and last time's slices.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#8) - Traits & Generics

Welcome back! At the very end of last episode I left you with a small promise. Our largest function worked beautifully -- but only for i32. If you wanted the largest f64, or the largest char, or the largest anything-else, you were stuck copy-pasting the whole function and changing one word in the signature. I called that out as a job for generics, and today is the day we make good on it. By the end of this post largest will accept a slice of any type you can compare, in a single definition, with zero runtime cost. That is the promise of generics, and it is one of the reasons Rust feels so different once it clicks.

But generics on their own are only half the story, and honestly the less interesting half. The real star today is traits -- Rust's answer to "how do unrelated types share behavior". If you come from Python or Java, you are used to reaching for inheritance and class hierarchies to share code. Rust does not have inheritance at all (a design choice that shocks newcomers), and traits are what it gives you in stead. They are closer to Java's interfaces or Go's interfaces, but with a couple of superpowers those languages lack. Once traits and generics come together -- and they are made for each other -- you get abstractions that are both flexible and blisteringly fast.

Nota bene: keep the Python mental model in your back pocket as always, but expect it to strain a little today. Python leans on "duck typing" -- if it walks like a duck, call .quack() and hope for the best at runtime. Rust wants that same flexibility but checked at compile time, and traits are how it squares that circle. Right, let us clear last episode's homework and then meet the machinery.

Solutions to Episode 7's exercises

Three exercises last time, all about collections and slices. They make a perfect warm-up, because two of them are begging to be made generic once we have the tools.

Exercise 1 wanted evens, taking a slice and returning a fresh Vec of only the even numbers:

fn evens(nums: &[i32]) -> Vec<i32> {
    let mut result = Vec::new();
    for &n in nums {
        if n % 2 == 0 {
            result.push(n);
        }
    }
    result
}

fn main() {
    let out = evens(&[1, 2, 3, 4, 5, 6]);
    println!("{:?}", out); // [2, 4, 6]
}

Notice the shape from last time paying off: we take a &[i32] slice (so a Vec or an array both work) but we return an owned Vec<i32>, because the result is brand-new data this function created and hands ownership of to the caller. The for &n in nums pattern destructures each &i32 reference back into a plain i32 copy, so n is an owned integer we can freely compare and push. Small function, but it quietly reinforces the borrow-in / own-out habit that runs through all of Rust.

Exercise 2 was the character-frequency counter using the entry API:

use std::collections::HashMap;

fn char_frequency(text: &str) -> HashMap<char, u32> {
    let mut counts: HashMap<char, u32> = HashMap::new();
    for c in text.chars() {
        *counts.entry(c).or_insert(0) += 1;
    }
    counts
}

fn main() {
    let freq = char_frequency("hello");
    println!("l -> {:?}", freq.get(&'l')); // l -> Some(2)
    println!("h -> {:?}", freq.get(&'h')); // h -> Some(1)
}

Same *map.entry(k).or_insert(0) += 1 incantation we dissected last episode, now walking text.chars() so we count proper Unicode characters rather than raw bytes (that distinction mattered a lot in episode 7, remember). One thing to note on the way out: freq.get(&'l') takes a reference to the key, because get borrows the key to look it up rather than consuming it.

Exercise 3 was first_word, returning a slice that borrows from its input:

fn first_word(s: &str) -> &str {
    match s.find(' ') {
        Some(index) => &s[0..index],
        None => s,
    }
}

fn main() {
    println!("{:?}", first_word("hello world")); // "hello"
    println!("{:?}", first_word("single"));      // "single"
}

This is the one I asked you to sit with, and here is why. The return type is &str -- a borrowed window into the input string, not a fresh allocation. s.find(' ') hands back an Option<usize> (the space might not be there), and we match on it: if there is a space at index, we return the slice up to it; if there is not, the whole string is the first word. No copying, no String, just a cheap view. The subtle part is that the returned slice is tied to the input's lifetime -- the borrow checker guarantees s outlives the slice we handed back. That connection between an input borrow and an output borrow is exactly what lifetimes make explicit, and we get to them soon. For now, just appreciate that it Just Works. Right, homework cleared -- let us make functions that stop caring what type they operate on.

The problem generics solve

Let me start where we ended, with the pain. Here is largest locked to i32, and a near-identical twin locked to f64:

fn largest_i32(items: &[i32]) -> i32 {
    let mut biggest = items[0];
    for &n in items {
        if n > biggest {
            biggest = n;
        }
    }
    biggest
}

fn largest_f64(items: &[f64]) -> f64 {
    let mut biggest = items[0];
    for &n in items {
        if n > biggest {
            biggest = n;
        }
    }
    biggest
}

Look at those two. The bodies are byte-for-byte identical -- the only difference is i32 versus f64 in the signature. That is a code smell in any language, but especially in a language that prides itself on catching mistakes: two copies means two places a bug can hide, two places to fix when the logic changes. What we really want is to write the algorithm once and let it work for any type where "is greater than" makes sense. That is precisely what a generic is: a placeholder for a type, filled in later.

Generics: one definition, many types

We introduce a type parameter -- conventionally a single capital letter, T for "type" -- in angle brackets after the function name. Inside the function, T stands in for whatever concrete type the caller uses:

fn first<T>(items: &[T]) -> &T {
    &items[0]
}

fn main() {
    let nums = [10, 20, 30];
    let words = ["alpha", "beta"];
    println!("{}", first(&nums));  // 10
    println!("{}", first(&words)); // alpha
}

fn first<T> reads as "a function first that is generic over some type T". The parameter is &[T] (a slice of T) and it returns &T (a reference to one of them). When we call first(&nums), Rust infers T = i32; when we call first(&words), it infers T = &str. One definition, both calls, no copy-paste. This works because first never actually does anything to the elements -- it just hands one back. It does not add them, compare them, or print them. And that is the catch we are about to hit.

Trait bounds: giving a generic the right to do something

Watch what happens the moment we try to write our generic largest naively:

fn largest<T>(items: &[T]) -> T {
    let mut biggest = items[0];   // ERROR: cannot move out of a slice
    for &n in items {
        if n > biggest {          // ERROR: `T` might not support `>`
            biggest = n;
        }
    }
    biggest
}

That does not compile, and the compiler is completely right to reject it. Think about what we are asking. We wrote if n > biggest, but T is any type -- and not every type can be compared with >. What would "greater than" even mean for a struct Dog? Rust will not let you call an operation on a generic type unless you have promised that the type supports it. That promise is a trait bound: a constraint that says "T is not just any type, it is any type that implements this particular trait".

The > operator comes from a standard-library trait called PartialOrd (partial ordering). Copying the value out of the slice needs Copy. We add both as bounds, and the function compiles and works for every type that satisfies them:

fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut biggest = items[0];
    for &n in items {
        if n > biggest {
            biggest = n;
        }
    }
    biggest
}

fn main() {
    let ints = [3, 7, 2, 9, 4];
    let floats = [1.5, 9.2, 0.7];
    let chars = ['r', 'u', 's', 't'];
    println!("{}", largest(&ints));   // 9
    println!("{}", largest(&floats)); // 9.2
    println!("{}", largest(&chars));  // u  (chars compare by code point)
}

There it is -- the promise I made last episode, delivered. Read <T: PartialOrd + Copy> as "for any type T that implements both PartialOrd (so > works) and Copy (so biggest = items[0] duplicates the value rather than moving it)". The + combines multiple bounds. Now largest works for i32, f64, and char -- and it will work for any future type you define, so long as that type also implements those two traits. This is the heart of the whole system: generics give you the placeholder, trait bounds give the placeholder its abilities. A generic with no bounds can barely do anything; a generic with the right bounds can do exactly what those traits allow and nothing more. That "nothing more" is a feature -- the compiler stops you the instant you rely on a capability you did not ask for.

When the bounds list gets long, there is a tidier where clause form that reads better:

fn largest<T: PartialOrd + Copy>(items: &[T]) -> T {
    let mut biggest = items[0];
    for &n in items {
        if n > biggest {
            biggest = n;
        }
    }
    biggest
}

fn describe_max<T>(items: &[T]) -> String
where
    T: PartialOrd + Copy + std::fmt::Display,
{
    let top = largest(items);
    format!("the largest of {} items is {top}", items.len())
}

fn main() {
    println!("{}", describe_max(&[4, 8, 15, 16, 23, 42])); // the largest of 6 items is 42
}

Functionally where T: ... is identical to putting the bounds inline; it just moves them below the signature so the parameter list stays readable when you pile on three or four traits. I reach for where the moment the inline version starts wrapping. Note the extra Display bound here, needed because we interpolate top into a string with {top} -- printing is itself a trait capability, which is a lovely segue into defining our own.

Defining a trait: shared behavior, no inheritance

So far we have only used standard-library traits. The real power arrives when you define your own. A trait is a named set of method signatures that a type can promise to fulfil. Here is a Summary trait -- a promise that "this type can summarize itself in one line" -- and two totally unrelated types implementing it:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article {
    headline: String,
    author: String,
}

struct Tweet {
    handle: String,
    body: String,
}

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} -- by {}", self.headline, self.author)
    }
}

impl Summary for Tweet {
    fn summarize(&self) -> String {
        format!("@{}: {}", self.handle, self.body)
    }
}

fn main() {
    let a = Article {
        headline: String::from("Rust hits a new release"),
        author: String::from("the core team"),
    };
    let t = Tweet {
        handle: String::from("rustlang"),
        body: String::from("we shipped it"),
    };
    println!("{}", a.summarize()); // Rust hits a new release -- by the core team
    println!("{}", t.summarize()); // @rustlang: we shipped it
}

The trait Summary { fn summarize(&self) -> String; } block declares the contract -- any type claiming to be a Summary must provide a summarize method with that exact signature. Then each impl Summary for TypeName block fulfils the contract for a specific type. Crucially, Article and Tweet share nothing -- no common base class, no hierarchy -- yet both can be treated as "things that summarize". That is the anti-inheritance philosophy in action: behavior is bolted on per type, precisely where you want it, rather than inherited from some ancestor you may not even control. Go programmers will find this deeply familiar; Java folks can think "interface, but you can implement it for types you did not write".

Traits plus generics: the combination that matters

Here is where the two ideas fuse into something greater than either alone. A trait makes a fantastic bound. Watch a generic function that accepts anything summarizable:

trait Summary {
    fn summarize(&self) -> String;
}

struct Article { headline: String, author: String }

impl Summary for Article {
    fn summarize(&self) -> String {
        format!("{} -- by {}", self.headline, self.author)
    }
}

fn print_summary<T: Summary>(item: &T) {
    println!("SUMMARY: {}", item.summarize());
}

fn main() {
    let a = Article {
        headline: String::from("Generics explained"),
        author: String::from("scipio"),
    };
    print_summary(&a); // SUMMARY: Generics explained -- by scipio
}

fn print_summary<T: Summary>(item: &T) says "give me a reference to any type T, as long as T implements Summary". Inside, we are allowed to call item.summarize() precisely because the bound guarantees the method exists. Try to pass a type that does not implement Summary and the compiler refuses at the call site -- no runtime surprise, no AttributeError three hundred lines into a batch job like Python would hand you. This is duck typing's flexibility with a compiler's certainty, which is the single sentence I would use to sell traits to a sceptic.

There is a shorthand worth knowing: item: &impl Summary means exactly the same as <T: Summary>(item: &T) for a simple single-argument case, and reads a touch lighter:

# trait Summary { fn summarize(&self) -> String; }
fn print_summary(item: &impl Summary) {
    println!("SUMMARY: {}", item.summarize());
}

I tend to use impl Trait for one-off arguments and the explicit <T: Trait> form when the same type shows up in several spots and I need to name it. Both compile to the identical thing.

Default methods: behavior for free

Traits are not limited to bare signatures -- they can supply default implementations. A default method has a body right there in the trait, and any implementor gets it automatically without writing a line, though it can still override it. This is how a trait ships genuinely useful behavior, not just a contract:

trait Greet {
    fn name(&self) -> String;

    // default method: built from name(), free to every implementor
    fn hello(&self) -> String {
        format!("Hello, my name is {}!", self.name())
    }
}

struct Person { first: String }
struct Robot { id: u32 }

impl Greet for Person {
    fn name(&self) -> String {
        self.first.clone()
    }
    // Person uses the DEFAULT hello()
}

impl Greet for Robot {
    fn name(&self) -> String {
        format!("unit-{}", self.id)
    }
    // Robot OVERRIDES hello() with its own version
    fn hello(&self) -> String {
        format!("BEEP. DESIGNATION {}.", self.name())
    }
}

fn main() {
    let p = Person { first: String::from("Alice") };
    let r = Robot { id: 7 };
    println!("{}", p.hello()); // Hello, my name is Alice!
    println!("{}", r.hello()); // BEEP. DESIGNATION unit-7.
}

Look at the leverage. Person implements only name() and gets hello() thrown in for free, built on top of whatever name() returns. Robot implements name() and chooses to override hello() with something more robotic. A default method can call the trait's other (still-abstract) methods -- hello calls self.name() without knowing or caring how each type spells it. That is a genuinely powerful pattern: the trait author writes the general behavior once, in terms of a small set of required methods, and every implementor fills in only the small bits that differ. The standard library uses this everywhere -- the Iterator trait requires you to write a single next method and then showers you with dozens of default methods (map, filter, sum, and friends) built on top of it. But that is a whole episode of its own, coming a little later. ;-)

#[derive]: common traits without the boilerplate

Some traits are so routine that writing them by hand every time would be daft. You have already seen #[derive(Debug)] sprinkled through earlier episodes -- that is the compiler generating a trait implementation for you. derive works for a handful of standard traits, and it saves quit some typing:

#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: i32,
    y: i32,
}

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = a.clone();              // Clone: deep copy
    println!("{:?}", a);           // Debug: Point { x: 1, y: 2 }
    println!("equal? {}", a == b); // PartialEq: true
}

That one #[derive(Debug, Clone, PartialEq)] line generated three full trait implementations: Debug (so {:?} can print the struct), Clone (so .clone() makes a copy), and PartialEq (so == compares two Points field by field). Writing those by hand would be twenty-odd lines of utterly mechanical code, and the derived versions are exactly what you would have written anyway. The rule of thumb: derive when the obvious field-by-field behavior is what you want, and hand-write an impl only when you need something custom (say, a Display with a specific format -- Display is deliberately not derivable, because there is no one obvious way to show a type to a human). Derive is a small convenience that adds up enormously across a real codebase.

Generic structs: types that hold any type

Generics are not just for functions -- structs can be generic too, which is how the standard library builds Vec<T> and Option<T> and HashMap<K, V> from episode 7. When you need a container or wrapper that should not care what it holds, you reach for the same tool:

#[derive(Debug)]
struct Pair<T> {
    first: T,
    second: T,
}

impl<T: std::fmt::Display> Pair<T> {
    fn new(first: T, second: T) -> Self {
        Pair { first, second }
    }

    fn announce(&self) {
        println!("pair holds {} and {}", self.first, self.second);
    }
}

fn main() {
    let ints = Pair::new(3, 8);
    let words = Pair::new("left", "right");
    ints.announce();  // pair holds 3 and 8
    words.announce(); // pair holds left and right
    println!("{:?}", ints); // Pair { first: 3, second: 8 }
}

Two pieces of syntax to unpack. struct Pair<T> declares a struct generic over T, with both fields being that same T. Then impl<T: std::fmt::Display> Pair<T> is how you write methods on a generic struct: you announce the type parameter <T: ...> right after impl, and you can attach a bound there too (here Display, because announce prints the fields). Now Pair works for integers, strings, or anything printable, all from one definition. This is exactly how Vec<T> is built under the hood -- there is no special compiler magic reserved for the standard library, just generics and traits, the very tools you now hold.

The payoff: zero-cost abstraction

I have said "zero runtime cost" a few times, and I owe you the why, because it is the part that makes Rust programmers smug. In many languages, generic or interface-based code pays a price at runtime -- boxing values, chasing pointers, looking methods up in tables. Rust, by default, does none of that. It uses a technique called monomorphization: at compile time, for every concrete type you actually call a generic function with, the compiler stamps out a separate, specialized copy with the type filled in.

So when you call largest(&ints) and largest(&floats), the compiler quietly generates two functions -- one hard-coded for i32, one for f64 -- exactly as if you had written the copy-pasted largest_i32 and largest_f64 from the start of this episode yourself. You wrote one generic definition; the machine code contains the specialized versions. The abstraction exists entirely at compile time and evaporates before the program runs:

fn double<T: std::ops::Add<Output = T> + Copy>(x: T) -> T {
    x + x
}

fn main() {
    let a = double(21);    // compiler generates a version for i32
    let b = double(1.5);   // and a separate version for f64
    println!("{a} and {b}"); // 42 and 3
}

double is generic, but after monomorphization the binary holds one i32-specialized copy and one f64-specialized copy, each as tight as if you had written it by hand. No lookup, no indirection, no tax. That is what "zero-cost abstraction" means -- you get the expressiveness of writing code once against a placeholder, and the performance of code written specifically for each type. You genuinely do not choose between clean and fast here, which is rarer than it sounds. (There is a second flavor of trait use, where the type is decided at runtime rather than compile time -- it trades a sliver of speed for flexibility, and it earns its own episode down the line. Today's monomorphized generics are the fast default.)

Try it yourself

Three exercises, gentle to chewier, as always. Full solutions open the next episode.

  1. Write a generic function smallest<T: PartialOrd + Copy>(items: &[T]) -> T that mirrors this episode's largest but returns the minimum element. Test it on &[5, 2, 9, 1, 7] (expect 1) and on &[3.3, 0.5, 8.1] (expect 0.5). The only change from largest is one comparison operator -- proof that the generic machinery carries over unchanged.

  2. Define a trait Area with a single method fn area(&self) -> f64. Create two structs, Circle { radius: f64 } and Rectangle { width: f64, height: f64 }, and implement Area for both (circle area is 3.14159 * radius * radius, rectangle area is width * height). Then write a generic function print_area<T: Area>(shape: &T) that prints the area, and call it on one of each.

  3. Extend exercise 2's Area trait with a default method fn describe(&self) -> String that returns format!("this shape has area {:.2}", self.area()) (the {:.2} prints two decimal places). Do not implement describe for either struct -- let them inherit the default -- then call .describe() on a Circle and a Rectangle and confirm both work from the single default definition.

Exercise 3 is the one to linger on: it shows a default method building real behavior on top of a required method, which is the exact pattern the standard library's biggest traits are made of.

So what did we actually cover?

  • Generics let you write a function or struct once against a placeholder type T, instead of copy-pasting per concrete type. fn first<T>(items: &[T]) -> &T works for slices of anything.
  • A bare generic can barely do anything; trait bounds (<T: PartialOrd + Copy>, or a where clause) grant it exactly the abilities the named traits provide -- and nothing more, checked at compile time.
  • A trait is a named contract of method signatures. Implement it per type with impl Trait for Type; unrelated types share behavior with no inheritance, closer to Go or Java interfaces but implementable on types you did not write.
  • Default methods ship behavior inside the trait itself, built on the required methods -- implementors get them free and may override. #[derive(Debug, Clone, PartialEq)] generates whole trait impls for you.
  • Rust generics are zero-cost: monomorphization stamps out a specialized copy per concrete type at compile time, so the abstraction disappears before runtime and you pay no speed penalty at all.

Traits and generics are, for my money, the point where Rust stops being "a safer C" and becomes its own thing entirely. You can now write one algorithm that serves every type it sensibly should, define shared behavior across types that know nothing about each other, and hand the compiler enough information to keep all of it both correct and fast. Almost everything interesting in the standard library -- iterators, formatting, comparison, conversion -- is built from exactly these two ideas, so you have just been handed the key that unlocks the rest of the language. Next time we start organizing growing programs into tidy, reusable pieces so this power does not turn into a single thousand-line mess. With ownership, errors, collections, and now traits in hand, you are more than ready for it.

De groeten, en tot de volgende keer! ;-)

scipio@scipio

Leave Learn Rust Series (#8) - Traits & Generics to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio