rust · level 2

Types & Traits

Structs, enums, traits, generics, derive macros.

150 XP

Types & Traits

Rust's type system has three pillars: structs for product types, enums for sum types, and traits for behaviour. Together with generics, these compose into the most expressive type system in mainstream production languages — capable of catching at compile time what other languages defer to runtime tests.

Structs — product types

A struct is a record:

struct Point {
    x: f64,
    y: f64,
}

struct Color(u8, u8, u8);   // tuple struct — positional fields
struct Marker;              // unit struct — no fields, used as a type-level tag

let p = Point { x: 1.0, y: 2.0 };
let c = Color(255, 0, 128);
let _m = Marker;

Construction is by name (Point { x, y }) or position (Color(r, g, b)). Field access is .name or .0/.1 for tuple structs. Structs are passed by value (move or copy depending on field types).

Enums — sum types

An enum is a tagged union: a value is exactly one of several variants, each carrying its own data:

enum Shape {
    Circle { radius: f64 },              // struct-like variant
    Rectangle(f64, f64),                 // tuple variant
    Point,                               // unit variant
}

let s = Shape::Circle { radius: 1.5 };

The two enums you'll use constantly — both in the standard library, both critical:

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

Option replaces null. Result replaces exceptions. We'll cover both in depth in lessons 03 and 05.

Methods & associated functions

Both structs and enums get methods via impl blocks:

impl Point {
    // Associated function (no `self`) — called as Point::origin()
    fn origin() -> Self {
        Point { x: 0.0, y: 0.0 }
    }

    // Method (takes `&self`) — called as p.distance(&other)
    fn distance(&self, other: &Point) -> f64 {
        ((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
    }

    // Mutating method — takes `&mut self`
    fn translate(&mut self, dx: f64, dy: f64) {
        self.x += dx;
        self.y += dy;
    }
}

Self (capital S) is the type itself. self (lowercase) is the receiver. Three flavours: self (consume), &self (read-only borrow), &mut self (mutable borrow).

Traits — capability declarations

A trait is a contract: "any type implementing this trait provides these methods." Roughly Java's interface, but more flexible:

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

    // Default implementation — implementers can override or accept it.
    fn shout(&self) -> String {
        format!("{}!!!", self.hello())
    }
}

struct Cat;
impl Greet for Cat {
    fn hello(&self) -> String {
        String::from("meow")
    }
}

let c = Cat;
println!("{}", c.shout());   // "meow!!!"

You can implement traits on types you don't own (within limits — the orphan rule requires either the trait or the type to be defined in your crate).

Generics + trait bounds

Generics let one definition work for many types. Trait bounds say which types qualify:

use std::fmt::Display;

fn announce<T: Display>(thing: T) {
    println!("Behold: {}", thing);
}

// Equivalent with `where` clause — easier to read with multiple bounds:
fn announce_two<T, U>(t: T, u: U)
where
    T: Display,
    U: Display + Clone,
{
    println!("{} and {}", t, u.clone());
}

Generics in Rust are monomorphised — the compiler generates a separate copy per concrete type used. There is zero runtime cost, no boxing, no virtual dispatch. The downside is binary size grows with the number of instantiations.

The big four derive traits

Most of your structs will start with this incantation:

#[derive(Debug, Clone, PartialEq, Eq)]
struct User {
    id: u64,
    name: String,
}
Trait What it gives you
Debug println!("{:?}", x) — developer-facing format
Clone x.clone() — explicit deep copy
Copy implicit copy on assignment (only if all fields are Copy)
PartialEq / Eq == and !=
PartialOrd / Ord <, >, .cmp()
Hash usable as a HashMap key
Default User::default() returns a zero-valued instance

Each #[derive(X)] expands at compile time into a full impl X for User { ... }. Use cargo expand to see the generated code.

Display vs Debug

Two formatting traits, distinct purposes:

use std::fmt;

struct Money { cents: u64 }

// Debug — auto-derivable; for developers / logs.
#[derive(Debug)]
struct DebugMe { x: i32, y: i32 }

// Display — write it by hand; for end users.
impl fmt::Display for Money {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "${}.{:02}", self.cents / 100, self.cents % 100)
    }
}

println!("{:?}", DebugMe { x: 1, y: 2 });   // DebugMe { x: 1, y: 2 }
println!("{}", Money { cents: 12345 });     // $123.45

Display being non-derivable is intentional: every type's user-facing representation is a design decision, not a mechanical translation.

Trait objects (dyn) — runtime polymorphism

When you genuinely need a heterogeneous collection of trait implementers, use a trait object:

let shapes: Vec<Box<dyn Greet>> = vec![
    Box::new(Cat),
    Box::new(Dog),
];
for s in &shapes {
    println!("{}", s.hello());   // virtual dispatch via vtable
}

dyn Greet is "some unspecified type implementing Greet". This is dynamic dispatch — slightly slower than monomorphisation, but enables runtime heterogeneity. The trait must be object-safe (no generic methods, no Self returns).

When to reach for what

You want Use
A record with named fields struct
A value that's one-of-several shapes enum
"These types support this operation" trait + generics
Heterogeneous collection of implementers Vec<Box<dyn Trait>>
Compile-time polymorphism, zero cost <T: Trait>
A field that's optional Option<T>
A function that can fail Result<T, E>

Internalise this table and 80% of Rust API design becomes mechanical.

Tools in the wild

4 tools
  • rustupfree tier

    Install and switch toolchains; ships rustc, cargo, and stdlib documentation.

    cli
  • cargofree tier

    `cargo doc --open` renders the full standard-library trait reference locally.

    cli
  • clippyfree tier

    Suggests when to derive a trait, when to use `Default`, when to prefer `From` over `Into`.

    cli
  • cargo-expandfree tier

    See what `#[derive(...)]` macros expand to — invaluable when learning the trait machinery.

    cli