Chapter 06 10 min read

Structs, enums, traits, generics

The primitives from chapter 2 (i32, string, bool...) get you started. To model your own data — a user, an HTTP request, a tree node — you reach for structs and enums. To share behaviour across types you reach for traits. To write code once that works on many types, generics. This chapter is the whole modelling toolkit.

Structs: bundles of fields

A struct is a fixed record of named fields, each with its own type.

struct User {
    id:    i64,
    name:  string,
    admin: bool,
}

Build a value with the struct literal syntax — every field has to be set:

let u: User = User {
    id:    42,
    name:  "alice",
    admin: false,
};

Read fields with .field:

println!(u.name);
if u.admin { println!("is admin"); }

Methods with impl

Want a function that takes a User? You could write a free function — but it's nicer to attach it directly to the type:

impl User {
    fn display_name(self: User) -> string {
        if self.admin {
            return "@".concat(self.name).concat(" (admin)");
        }
        return "@".concat(self.name);
    }
}

fn main() -> i32 {
    let u: User = User { id: 1, name: "alice", admin: true };
    println!(u.display_name());   // @alice (admin)
    return 0;
}

A few things:

  • impl User { ... } opens a block of methods on User.
  • The first parameter is self — the receiver. Its type tells the compiler whether the method takes ownership (self: User), borrows shared (self: &User), or borrows mutably (self: &mut User).
  • At the call site, u.display_name() looks like an attribute access but actually invokes the method, passing u as self.

Static / associated functions

Functions that belong to a type but don't take a receiver (no self) — useful for constructors:

impl User {
    fn new(id: i64, name: string) -> User {
        return User { id: id, name: name, admin: false };
    }
}

let u: User = User::new(1, "alice");

Call them with Type::function(...). By convention, every struct that allocates on the heap has a Type::new(...) constructor.

Enums: one-of-several

A struct packs all of its fields. An enum says "the value is exactly one of these named variants":

enum Shape {
    Circle,
    Square,
    Triangle,
}

let s: Shape = Shape::Square;

Use match to do something different for each variant — and the compiler will yell if you forget one:

fn sides(s: Shape) -> i32 {
    return match s {
        Shape::Circle    => { return 0; },
        Shape::Square    => { return 4; },
        Shape::Triangle  => { return 3; },
    };
}

Variants with data

The real power of enums is that each variant can carry different data:

enum Event {
    Login(string),                    // a username
    Logout,
    Message(string, string),          // from, body
}

fn describe(e: Event) -> string {
    return match e {
        Event::Login(u)        => { return "login: ".concat(u); },
        Event::Logout          => { return "logout"; },
        Event::Message(f, b)   => { return f.concat(": ").concat(b); },
    };
}

Each arm of the match destructures the variant's payload into local bindings. Login(u) binds u to the username when that arm fires.

?T and !T are enums

The types from chapter 4 (?T and !T) are basically enums under the hood. ?T has two variants: "has value" and "none." !T has two: "ok" and "err." They get special syntax because they're so common, but the mental model is the same as a normal enum.

Traits: shared behaviour

A trait is a contract: "any type that has these methods can be used here." You declare it like a class with no implementation, then say which types satisfy it.

trait Greetable {
    fn greet(self: &Self) -> string;
}

Self is the placeholder for "whatever type implements this trait." When User implements Greetable, Self becomes User everywhere in the trait.

impl Greetable for User {
    fn greet(self: &User) -> string {
        return "hello, ".concat(self.name);
    }
}

You can now call .greet() on any User:

let u: User = User::new(1, "alice");
println!(u.greet());     // hello, alice

The same trait can be implemented for multiple types independently:

struct Dog { name: string, }

impl Greetable for Dog {
    fn greet(self: &Dog) -> string {
        return "woof, ".concat(self.name);
    }
}

Now both User and Dog are Greetable.

Default methods

A trait can provide a default implementation that types inherit unless they override:

trait Greetable {
    fn name(self: &Self) -> string;

    fn greet(self: &Self) -> string {
        return "hello, ".concat(self.name());
    }
}

Now any type that implements name() automatically gets greet() for free.

Trait bounds on functions

Once Greetable exists, you can write a function that works on any type that implements it:

fn say_hi<T: Greetable>(thing: &T) {
    println!(thing.greet());
}

fn main() -> i32 {
    let u: User = User::new(1, "alice");
    let d: Dog  = Dog  { name: "rex" };
    say_hi(&u);    // hello, alice
    say_hi(&d);    // woof, rex
    return 0;
}

Read <T: Greetable> as "T is any type, with the constraint that it implements Greetable." The compiler generates a specialized version for each concrete type at compile time — same machinery as Rust's monomorphization, or C++ templates.

Dynamic dispatch with *dyn Trait

Sometimes you want a runtime collection of mixed types — say, a Vector of "any Greetable, doesn't matter which." For that, use *dyn Trait. You cast a heap pointer (*User, *Dog) — not a borrow — to the trait type:

let things: *Vector<*dyn Greetable> = Vector::new();
let u: *User = new User { id: 1, name: "alice", admin: false };
let d: *Dog  = new Dog  { name: "rex" };
things.push(u as *dyn Greetable);
things.push(d as *dyn Greetable);

for t in things {
    println!(t.greet());
}

*dyn Greetable is a pair of pointers under the hood (one to the data, one to the type's method table). The cost: a method call goes through a lookup at runtime. The win: a single collection can hold heterogeneous types. (new T { ... } allocates a raw heap pointer — see the memory model — which is what you store in a long-lived collection.)

Generics: code that works on many types

You already saw generics on Vector<T> and HashMap<V> — those are pre-built. You can write your own:

struct Pair<A, B> {
    first:  A,
    second: B,
}

impl<A, B> Pair<A, B> {
    fn new(a: A, b: B) -> Pair<A, B> {
        return Pair { first: a, second: b };
    }
}

fn main() -> i32 {
    let p: Pair<i32, string> = Pair::new(42, "answer");
    println!(p.second);
    return 0;
}

<A, B> are type parameters — placeholders that get filled in when someone uses the struct. The compiler generates a specialized version for each concrete pairing (Pair<i32, string>, Pair<bool, f64>, etc.).

Bounds on generics

You'll often want to constrain the type parameter — "any T, as long as it implements some trait." The bound is what lets you call that trait's methods inside the generic function:

trait Measured {
    fn size(self: &Self) -> i32;
}

fn largest<T: Measured>(items: &*Vector<*T>) -> *T {
    let mut best: *T = items.get(0);
    for x in items {
        if x.size() > best.size() { best = x; }   // .size() is allowed by the bound
    }
    return best;
}

<T: Measured> says "T is anything, as long as it implements Measured." Without that bound the compiler wouldn't let us call x.size() — it has no way to know the method exists. Bounds are what make generic code both flexible and type-safe.

You can require multiple traits with +:

trait Named { fn label(self: &Self) -> string; }

fn announce<T: Measured + Named>(item: *T) {
    println!(item.label(), "has size", item.size());   // both bounds in play
}

Putting it all together

A worked example — a tiny "shape" library tying every concept together:

shapes.glide
trait Area {
    fn area(self: &Self) -> f64;
}

struct Circle { radius: f64, }
struct Square { side: f64, }

impl Area for Circle {
    fn area(self: &Circle) -> f64 {
        return 3.14159 * self.radius * self.radius;
    }
}

impl Area for Square {
    fn area(self: &Square) -> f64 {
        return self.side * self.side;
    }
}

fn total_area<T: Area>(shapes: &*Vector<*T>) -> f64 {
    let mut sum: f64 = 0.0;
    for s in shapes { sum = sum + s.area(); }
    return sum;
}

fn main() -> i32 {
    let circles: *Vector<*Circle> = Vector::new();
    circles.push(&Circle { radius: 1.0 });
    circles.push(&Circle { radius: 2.0 });
    println!("circles total:", total_area(&circles));
    return 0;
}

Six concepts: trait, struct, impl, for, generic function with a trait bound, generic over a heap pointer. Under ~30 lines.

Where to next

You can model data with structs and enums, share behaviour with traits, and write reusable functions with generics. The next chapter — Concurrency — covers how to make multiple things happen at once.