Learn Rust Series (#22) - Deriving Common Traits

avatar

Learn Rust Series (#22) - Deriving Common Traits

rust-banner.png

What will I learn

  • You will learn what the #[derive(...)] attribute does and how it auto-implements common traits;
  • what each of Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord and Default gives you;
  • why Copy needs Clone, and why a type with a String field cannot be Copy;
  • which traits you need to use a type as a HashMap key or to sort a Vec;
  • the difference between PartialEq and Eq, and why floats have one but not the other;
  • how a derive works under the hood, and when to hand-write an impl in stead.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Rust toolchain (via rustup, from rustup.rs);
  • The previous twenty-one episodes, especially traits from episode 8, collections from episode 7, and conversions from episode 21;
  • The ambition to learn systems programming from the ground up.

Difficulty

  • Beginner

Curriculum (of the Learn Rust Series):

Learn Rust Series (#22) - Deriving Common Traits

You have been writing #[derive(Debug)] at the top of your structs since early in this series, probably without ever stopping to ask what that little line actually does. Well, today we stop and ask, because it turns out that one attribute is doing quite a lot of work on your behalf. It is a macro that, at compile time, reads the shape of your type and writes out a complete, correct trait implementation for you -- the exact same code you could have typed by hand, only you did not have to, and it stays in sync automatically as your type grows. A handful of the most important standard traits can be summoned this way, and knowing precisely what each one gives you (and, just as importantly, what each one demands of your fields) will save you a mountain of boilerplate and clear up a couple of the more confusing beginner errors in one go.

At the end of episode 21 I promised that conversions sit right next to the deriving machinery, and here we are. This is a shorter, intensely practical episode -- the kind you will find yourself scrolling back to months from now to remember "wait, do I need Eq or just PartialEq for a map key again?". Let's clear last episode's homework first, then get into it ;-)

Solutions to Episode 21 Exercises

Episode 21 was all about conversions: From as the one trait you actually implement, Into coming along for free through a blanket impl, TryFrom/TryInto for the fallible cases, and the lovely detail that the ? operator secretly calls From::from on the error path. Three exercises, and here is each one with full, runnable code.

Exercise 1 asked you to implement From<(f64, f64)> for a Point { x: f64, y: f64 } struct, so that (1.0, 2.0).into() builds a Point with the tuple's two fields landing in the right places:

struct Point { x: f64, y: f64 }

impl From<(f64, f64)> for Point {
    fn from((x, y): (f64, f64)) -> Point {
        Point { x, y }
    }
}

fn main() {
    let p: Point = (1.0, 2.0).into();
    println!("x={} y={}", p.x, p.y); // x=1 y=2
}

The key insight is that I destructured the tuple right there in the parameter list -- fn from((x, y): (f64, f64)) -- so the two components already have names by the time I build the struct. And because I implemented From, the matching Into is handed to me automatically, which is why let p: Point = (1.0, 2.0).into() just works. Note the explicit : Point annotation: .into() infers its target from context, so it needs the type spelled out somewhere.

Exercise 2 wanted a constructor fn new(title: impl Into<String>) -> Article for an Article struct with a single title: String field, callable with both a &str literal and an owned String:

struct Article { title: String }

impl Article {
    fn new(title: impl Into<String>) -> Article {
        Article { title: title.into() }
    }
}

fn main() {
    let a = Article::new("Hello");             // &str
    let b = Article::new(String::from("Hi"));  // owned String
    println!("{} / {}", a.title, b.title);     // Hello / Hi
}

This is the "flexible parameter" idiom from last episode in miniature. By asking for impl Into<String> the constructor accepts anything convertible into a String, and the single title.into() inside the body does the conversion. The caller never writes .to_string(), and passing an already-owned String costs nothing because that conversion compiles down to a move.

Exercise 3 asked for a TryFrom<i32> impl on a Percentage that returns Ok only for values in 0..=100 and an Err(String) otherwise, tested with 50, -5, and 150 through both Percentage::try_from(...) and the free .try_into() form:

struct Percentage(u8);

impl TryFrom<i32> for Percentage {
    type Error = String;
    fn try_from(v: i32) -> Result<Percentage, Self::Error> {
        if (0..=100).contains(&v) {
            Ok(Percentage(v as u8))
        } else {
            Err(format!("{v} is out of range 0..=100"))
        }
    }
}

fn main() {
    println!("{}", Percentage::try_from(50).is_ok());   // true
    println!("{}", Percentage::try_from(-5).is_err());  // true

    let p: Result<Percentage, _> = 150i32.try_into();   // free TryInto
    println!("{}", p.is_err());                         // true
}

Two things to notice. First, type Error = String is the associated type we studied in episode 17 -- each TryFrom impl names the error it produces. Second, 150i32.try_into() works even though I never wrote a line of TryInto, because implementing TryFrom gives you TryInto for free through the very same blanket-impl trick that From uses for Into. Right, homework cleared -- on to derives ;-)

What a derive actually is

Before the individual traits, the one-sentence mental model: a derive is a compiler-generated trait implementation, written for you from the fields of your type. When you write #[derive(Debug)] on a struct, the compiler walks over every field, checks that each field's type also implements Debug, and stitches together an impl Debug for YourType that formats each field in turn. You could write that impl yourself -- and later in the series we will see how to write these generators ourselves -- but for the common traits the standard library already ships the generator, so you get a correct implementation for the price of one word.

Only a specific, blessed set of traits can be derived: Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, and Default are the big nine you will use constantly. That is not an arbitrary list -- these are precisely the traits whose "obvious" field-by-field implementation is almost always the one you want. Let's take them in the groups they naturally travel in.

Debug, Clone, Copy, PartialEq: the common four

On a small value type you will very often see all four of these derived together on one line, and each earns its place:

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

fn main() {
    let a = Point { x: 1, y: 2 };
    let b = a;                       // Copy: this COPIES, so `a` is still usable
    println!("{a:?} and {b:?}");     // Point { x: 1, y: 2 } and Point { x: 1, y: 2 }
    println!("equal? {}", a == b);   // true
}

Walk through what each one bought us. Debug is what lets us write {a:?} and {b:?} in that println! -- without it, the program would not compile, because Rust refuses to guess how to print your type. Clone gives you an explicit .clone() method for an on-demand duplicate. Copy changes the meaning of let b = a from a move (which we studied back in episode 3, where a would become unusable) into a copy, so both a and b remain valid afterwards. And PartialEq is what makes a == b legal, comparing the two points field by field.

The derived implementations here are exactly the obvious ones: Debug prints the type name and each field, Clone clones each field, PartialEq compares each field with == and requires all of them to match. That mechanical, field-by-field behaviour is right the overwhelming majority of the time, which is the whole reason deriving is safe to reach for by default.

Copy requires Clone, and forbids owned data

There is a subtle relationship hiding in that derive list: Copy cannot exist without Clone. You will always see them together as #[derive(Copy, Clone)], never Copy alone. The reason is that Copy is defined as a special case of Clone -- a type is Copy when duplicating it is nothing more than a bitwise copy of its bytes, with no extra work, no allocation, nothing to clean up. Since that is a strictly simpler thing than a general Clone, the language requires that any Copy type also be Clone.

And that "just copy the bytes" definition is exactly why some types cannot be Copy at all:

// #[derive(Copy, Clone)]  // <-- would NOT compile: String is not Copy
#[derive(Debug, Clone, PartialEq)]
struct Record {
    name: String,
    values: Vec<i32>,
}

fn main() {
    let a = Record { name: String::from("alpha"), values: vec![1, 2, 3] };
    let b = a.clone();                  // explicit deep copy
    println!("equal? {}", a == b);      // true
    println!("{a:?}");                  // a is still usable after clone
}

A String owns a heap allocation, and so does a Vec. If Record were Copy, then let b = a would blindly duplicate the struct's bytes -- including the pointer into that heap allocation -- and you would suddenly have two Record values both believing they own the same buffer. When they went out of scope, both would try to free it (that is the Drop behaviour from episode 20), and you would have a classic double-free. Rust simply forbids the whole situation: a type containing any non-Copy field cannot itself be Copy, and the compiler will not derive it. You get Clone instead, which for a String performs a proper deep copy of the buffer, so the two records own separate allocations.

The rule of thumb that falls out of this: derive Copy for small, all-scalar value types (coordinates, RGB colours, small enums), where copying is genuinely free and you want assignment to feel like it does for an i32. For anything that owns heap data, derive only Clone, so that duplicating it is an explicit, visible act you can see in the code. Copy incidentally belongs to a small family of "marker" traits that describe deep properties of a type rather than adding methods, and there is more to say about that family, but one thing at a time.

Eq and Hash: the ticket to being a HashMap key

Back in episode 7 we used HashMap and HashSet with string and integer keys and never thought about why those worked. The answer is that a type can only be a key if it implements both Eq and Hash, and the standard integer and string types already do. To use your own type as a key, you derive them:

use std::collections::HashMap;

#[derive(Debug, PartialEq, Eq, Hash)]
struct Coord { x: i32, y: i32 }

fn main() {
    let mut grid = HashMap::new();
    grid.insert(Coord { x: 0, y: 0 }, "origin");
    grid.insert(Coord { x: 1, y: 2 }, "somewhere");
    println!("{:?}", grid.get(&Coord { x: 0, y: 0 })); // Some("origin")
}

Why two traits? A hash map works in two stages: it hashes the key to pick a bucket, then compares keys for equality to find the exact entry within that bucket. Hash provides the first step, Eq the second. And there is a hard invariant tying them together: two values that are equal must produce the same hash. If you ever broke that -- two "equal" keys hashing to different buckets -- the map would store duplicates and lose entries, one of the nastiest bugs there is. The beautiful thing about deriving both is that the generated implementations are guaranteed to agree by construction, so you get that invariant for free. This is a case where hand-writing one of the two and deriving the other is genuinely dangerous, so please, derive them as a pair.

PartialEq versus Eq: what is the difference?

You have now seen PartialEq on its own and Eq alongside it, so the obvious question is: why are there two equality traits, and when do you need each? The short version is that Eq is a promise on top of PartialEq. PartialEq gives you == and !=. Eq adds no new methods at all -- it is a marker that says "equality on this type is total and well-behaved": every value equals itself, and there are no surprises.

The classic example of a type that is PartialEq but deliberately not Eq is the floating-point numbers, because of NaN:

fn main() {
    let nan = f64::NAN;
    println!("{}", nan == nan); // false! NaN is not equal to itself
    // f64 implements PartialEq (so == compiles) but NOT Eq,
    // because Eq would promise nan == nan, which is false.
}

NaN is the "not a number" result you get from things like 0.0 / 0.0, and by the IEEE 754 standard it is not equal to anything, including itself. That single misbehaving value means f64 cannot honestly claim the "every value equals itself" promise of Eq, so the standard library gives floats PartialEq but withholds Eq. The practical consequence you will actually hit: you cannot use an f64 (or any struct containing one) as a HashMap key, because Hash and the map's machinery lean on Eq. For a struct made of integers, derive both and you are golden; the moment a float sneaks in, Eq becomes underivable, and that is Rust protecting you from a subtly broken key type.

PartialOrd and Ord: sorting and comparison

The ordering traits mirror the equality ones exactly. PartialOrd gives you the comparison operators <, >, <=, >=; Ord promises a total ordering (any two values can be compared and land in a definite order) and is what Vec::sort and BinaryHeap require. Derived ordering is lexicographic over the fields in declaration order, which turns out to be exactly what you want for things like version numbers:

#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Version { major: u32, minor: u32 }

fn main() {
    let mut versions = vec![
        Version { major: 1, minor: 2 },
        Version { major: 1, minor: 0 },
        Version { major: 0, minor: 9 },
    ];
    versions.sort(); // compares major first, then minor on a tie
    println!("{versions:?}");
    // [Version { major: 0, minor: 9 }, Version { major: 1, minor: 0 }, Version { major: 1, minor: 2 }]
}

Because the derive compares major first and only looks at minor when the majors tie, sorting produces proper semantic version order: 0.9 before 1.0 before 1.2. This "declaration order is the ordering" behaviour is enormously handy -- it means you can control the sort priority of a struct just by choosing the order you write its fields in. And exactly as with floats, note that Ord requires Eq: a total ordering implies total equality, so you always derive the ordering traits together with the equality ones. If you ever needed a different order (say, sort by minor first) you would hand-write Ord in stead, but for the common case, the free lexicographic order is the one you were reaching for anyway.

Default: sensible zero values

The last of the everyday derives is Default, which generates a ::default() constructor that fills every field with its type's default: 0 for numbers, false for bool, an empty String, an empty Vec, and so on, all the way down recursively. Paired with the struct update syntax we met in episode 5, it gives you a clean "override just what you care about" pattern:

#[derive(Debug, Default)]
struct Config {
    verbose: bool,
    retries: u32,
    name: String,
}

fn main() {
    let c = Config::default();
    println!("{c:?}"); // Config { verbose: false, retries: 0, name: "" }

    let c2 = Config { retries: 3, ..Default::default() };
    println!("{c2:?}"); // Config { verbose: false, retries: 3, name: "" }
}

That ..Default::default() line is the idiom to remember: "set retries to 3, and give me the default for everything else". It scales beautifully as a config struct grows, because adding a new field never breaks existing construction sites -- they just pick up the new field's default. Default is also what functions like std::mem::take lean on under the hood, and it pairs naturally with the builder-style APIs you will write later. Derive it whenever "all zeros / all empty" is a meaningful starting point for your type.

How this looks from Python and Go

Since many of you arrived here from Python (as I did, having taught it for years), it is worth seeing how other languages handle this same "give me the obvious equality/printing/ordering for my type" problem, because the contrast is instructive.

Python does it at runtime with dunder methods and a bit of help from the standard library. You either hand-write __repr__, __eq__, __hash__ and friends, or you slap @dataclass on the class and let it generate them:

from dataclasses import dataclass

@dataclass(frozen=True, order=True)
class Coord:
    x: int
    y: int

c = Coord(0, 0)
print(c)               # Coord(x=0, y=0)   <- like Debug
print(c == Coord(0, 0)) # True             <- like PartialEq
d = {c: "origin"}       # hashable because frozen=True  <- like Hash + Eq

The @dataclass decorator is genuinely close in spirit to #[derive(...)] -- it inspects the fields and writes the boilerplate methods for you. The difference is that Python generates real Python methods that run and cost time on every call, checks nothing until that runtime, and happily lets you build a broken type (an unhashable field in a frozen dataclass only explodes when you try to hash it). Rust's derive runs at compile time, produces zero-overhead code, and refuses to compile the moment a field does not support what you asked for -- the String-cannot-be-Copy error we saw is caught before your program ever runs.

Go sits at the other extreme: it has almost no derivation at all. Struct comparability with == is built into the language (if all fields are comparable), fmt uses reflection at runtime to print anything, and for ordering you write a comparison function by hand and pass it to sort.Slice. There is no @dataclass, no #[derive]; you either get the built-in behaviour or you write it out longhand. Three philosophies again -- Python generates methods at runtime, Go bakes a little in and leaves the rest to you, and Rust generates zero-cost implementations at compile time while type-checking every one of them. I know which trade-off I prefer for a systems language ;-)

How a derive works, and when to write it by hand

Under the hood, a derive is a procedural macro: a small program that runs during compilation, receives the parsed structure of your type as input, and emits Rust source code (the trait impl) as output. That is why #[derive(PartialEq)] fails with a clear error if one of your fields is a type that is not itself PartialEq -- the generator tries to compare that field, cannot, and the compiler tells you exactly which field is the problem. Every derive works this way: it requires the corresponding trait on all fields, and builds the whole from the parts.

Ninety-something percent of the time, the generated impl is exactly correct and you should prefer it, for three concrete reasons. It stays in sync automatically when you add or remove fields. It upholds cross-trait invariants like the Eq/Hash agreement that are easy to get wrong by hand. And it communicates intent instantly to any reader: #[derive(Clone, PartialEq)] tells them "nothing surprising happens here" at a glance.

So when should you hand-write an impl in stead of deriving? Only when the derived, field-by-field behaviour is genuinely wrong for your type. The canonical example is a struct with a cached or derived field that should not participate in equality:

struct User {
    id: u64,
    cached_display: String, // derived from id; must NOT affect equality
}

impl PartialEq for User {
    fn eq(&self, other: &Self) -> bool {
        self.id == other.id // compare identity only, ignore the cache
    }
}

fn main() {
    let a = User { id: 7, cached_display: String::from("stale") };
    let b = User { id: 7, cached_display: String::from("fresh") };
    println!("{}", a == b); // true: same id, cache ignored
}

Here a derived PartialEq would compare cached_display too and wrongly report these two users as different. Because equality means "same identity" for this type, we write the impl by hand and compare only id. That is the whole decision rule: derive by default, and reach for a hand-written impl only when your type has semantics the mechanical version cannot know about. One caveat to keep in the back of your mind -- there are rules about where you are even allowed to write these impls, tied to which crate owns the trait and which owns the type, and that is a coherence question we will pick apart very soon.

What did we actually learn?

  • #[derive(...)] is a compile-time macro that writes a correct, field-by-field trait implementation for you, requiring that every field also implements the trait in question.
  • The common four, Debug, Clone, Copy, PartialEq, are usually derived together on small value types; Copy always needs Clone, and a type owning heap data (a String, a Vec) cannot be Copy at all.
  • To use a type as a HashMap/HashSet key you need Eq and Hash together, and deriving both guarantees the critical "equal values hash the same" invariant automatically.
  • Eq is PartialEq plus a promise of total equality, which is why floats are PartialEq but not Eq (NaN != NaN); Ord is PartialOrd plus a total ordering, derived lexicographically in field-declaration order.
  • Default gives you ::default() and the lovely ..Default::default() override pattern; and you hand-write an impl only when the derived behaviour is wrong, such as equality that must ignore a cached field.

The pattern running through this whole stretch of the series is Rust handing you small, sharp, single-purpose traits -- associated types, operators, deref, drop, conversions, and now the derivable staples -- and showing how the compiler can generate the mechanical ones for you. But that raises a question I hinted at just now: when you write impl SomeTrait for YourType, are you always allowed to? It turns out there is a firm rule governing exactly that, and it is where we head next. One thing at a time ;-)

Exercises

Three exercises as always, gentle to chewier. Full solutions open the next episode, so genuinely have a go first -- typing this yourself is where it sticks.

  1. Define an enum Direction with variants North, East, South, West, derive Debug and PartialEq on it, and in main compare two Direction values with ==, printing the result of one equal pair and one unequal pair.
  2. Take the Coord { x: i32, y: i32 } struct with #[derive(PartialEq, Eq, Hash)], build a HashSet<Coord>, insert the same coordinate twice plus one different coordinate, and print the set's .len() to confirm it kept only the two distinct entries.
  3. Add a third field patch: u32 to the Version struct, keep all the derives, build a Vec of three versions that differ only in patch (for example 1.0.2, 1.0.0, 1.0.1), sort it, and print the result to confirm the tie on major and minor is broken by the third component.

Bedankt voor het lezen, en tot de volgende keer! ;-)

@scipio



0
0
0.000
0 comments