Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

6. Structs and Enums

A struct groups values that belong together. An enum names a value that is one of several alternatives. Between them they describe nearly all the data a program handles, and Rust’s version of the second one is more useful than what most languages offer, because the compiler makes you deal with every alternative.

6.1 Structs

struct Rect {
    width: f64,
    height: f64,
}

fn main() {
    let a = Rect { width: 3.0, height: 4.0 };
    println!("{}", a.width * a.height);
}

Fields are named at construction, in any order, and reached with a dot. A struct is mutable only if the variable holding it is declared mut; there is no per-field mutability.

Two shorthands come up constantly. When a variable has the same name as the field, write it once:

fn new(width: f64, height: f64) -> Rect {
    Rect { width, height }
}

And when most of a new value comes from an existing one, take the rest with ..:

let wide = Rect { width: 10.0, ..a };

There are two other shapes. A tuple struct has fields without names, reached by position, and is how you wrap a single value in a meaningful type:

struct Celsius(f64);
struct Meters(f64);

fn main() {
    let t = Celsius(21.5);
    println!("{}", t.0);
}

Those two are different types, so a function taking Meters cannot be handed a Celsius, and a whole family of unit-confusion bugs stops compiling. The wrapper costs nothing at runtime.

A unit struct has no fields at all, and exists to have behavior attached to it:

#![allow(unused)]
fn main() {
struct Logger;
}

6.2 Methods

Functions belonging to a type go in an impl block:

#[derive(Debug, Clone, Copy, PartialEq)]
struct Rect {
    width: f64,
    height: f64,
}

impl Rect {
    fn new(width: f64, height: f64) -> Rect {
        Rect { width, height }
    }

    fn square(side: f64) -> Rect {
        Rect::new(side, side)
    }

    fn area(&self) -> f64 {
        self.width * self.height
    }

    fn scale(&mut self, factor: f64) {
        self.width *= factor;
        self.height *= factor;
    }
}

fn main() {
    let a = Rect::new(3.0, 4.0);
    println!("{a:?} area {}", a.area());

    let mut c = a;
    c.scale(2.0);
    println!("scaled {c:?}, original {a:?}");
}
Rect { width: 3.0, height: 4.0 } area 12
scaled Rect { width: 6.0, height: 8.0 }, original Rect { width: 3.0, height: 4.0 }

The first parameter decides everything. &self borrows, and is what a method that only reads should take. &mut self borrows mutably, for a method that modifies. Plain self consumes the value, which is right for a conversion that should leave the original unusable. A function with no self at all is an associated function, called as Rect::new(...) rather than on a value; new is a convention rather than a keyword, and it is how Rust spells a constructor.

Methods are called with a dot and Rust adds the borrow for you: a.area() means Rect::area(&a). This automatic referencing is why methods read cleanly whether you hold a value or a reference to one.

You may write as many impl blocks for a type as you like.

6.3 Derive

The line above the struct asks the compiler to write some implementations:

#[derive(Debug, Clone, Copy, PartialEq)]

Debug gives the type the programmer-facing printed form {:?} uses, and {:#?} gives the same thing across several lines:

Rect {
    width: 3.0,
    height: 4.0,
}

Clone gives it .clone(). Copy makes assignment duplicate rather than move, allowed only when every field is Copy and suited to small arithmetic types like this one. PartialEq gives it ==. Others you will reach for are Default, Eq, Hash and PartialOrd. Chapter 9 explains what a derive actually is; for now, take it as the compiler writing the obvious code so you do not have to.

Derive Debug on everything. The cost is nothing and the alternative is being unable to print your own data while debugging.

6.4 Enums

An enum lists alternatives:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Copy, PartialEq)]
enum Direction {
    North,
    South,
    East,
    West,
}
}

A value of type Direction is one of those four and nothing else. There is no integer hiding underneath that someone can set to 9.

Enums get impl blocks like structs, and a method on an enum is usually a match:

impl Direction {
    fn parse(word: &str) -> Option<Direction> {
        match word {
            "n" | "north" => Some(Direction::North),
            "s" | "south" => Some(Direction::South),
            "e" | "east" => Some(Direction::East),
            "w" | "west" => Some(Direction::West),
            _ => None,
        }
    }

    fn opposite(self) -> Direction {
        match self {
            Direction::North => Direction::South,
            Direction::South => Direction::North,
            Direction::East => Direction::West,
            Direction::West => Direction::East,
        }
    }
}

opposite takes self by value rather than by reference, which is right here because Direction is Copy and one machine word. Add a fifth direction and the compiler will reject opposite until you extend it. That is the property worth paying for: the type system knows the set of cases, and every place that handles them is checked against the list.

Variants carrying data

A variant may carry values, and different variants may carry different things:

enum Shape {
    Circle(f64),
    Rect { width: f64, height: f64 },
    Triangle(f64, f64, f64),
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rect { width, height } => width * height,
        Shape::Triangle(a, b, c) => {
            let s = (a + b + c) / 2.0;
            (s * (s - a) * (s - b) * (s - c)).sqrt()
        }
    }
}

fn main() {
    let shapes = [
        Shape::Circle(1.0),
        Shape::Rect { width: 3.0, height: 4.0 },
        Shape::Triangle(3.0, 4.0, 5.0),
    ];
    for s in &shapes {
        println!("{:.4}", area(s));
    }
}

This is a tagged union, and it is the feature that people coming from C miss most when they go back. The value carries its own tag, the tag cannot disagree with the contents, and match binds the contents while it tests the tag.

The patterns take the variants apart: Shape::Circle(r) binds the radius, Shape::Rect { width, height } binds both fields by name. Because s is a reference, the bindings are references too, and the arithmetic works on them without a * because the operators know what to do with a &f64.

Note that the match arms hold the whole calculation. There is no partially constructed object to guard against and no “this field is only meaningful when the kind is Circle” comment.

6.5 Option

Rust has no null. A value that might be missing is wrapped:

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

That is the entire definition, and it is in the standard library rather than the language. Some and None are usable without a prefix in every program.

The consequence is that a missing value cannot be mistaken for a present one, because the two have different shapes and you have to open the wrapper to get at the contents. There is no equivalent of reading a field off null and finding out at runtime. Tony Hoare called the null reference his billion-dollar mistake; this is what not making it looks like.

The direct way to open an Option is match, or if let when only one case is interesting. There are also methods for the common shapes, and using them keeps code flat:

#![allow(unused)]
fn main() {
let name: Option<String> = None;

println!("{}", name.clone().unwrap_or_else(|| "anonymous".to_string()));
println!("{}", name.is_some());

let n: Option<i32> = Some(4);
println!("{:?}", n.map(|v| v * 10));
println!("{}", n.unwrap_or(0));
println!("{:?}", n.filter(|v| *v > 10));
}

unwrap_or supplies a default, unwrap_or_else supplies one lazily, map applies a function to the value if there is one, and_then does the same when the function itself returns an Option, and ok_or turns a None into an error, which Chapter 8 needs. unwrap and expect take the value and panic if it is not there; they are for cases you have already ruled out, and for the first draft.

Here is Option doing real work. A little text adventure where the exits are optional:

struct Room {
    name: String,
    north: Option<usize>,
    south: Option<usize>,
    east: Option<usize>,
    west: Option<usize>,
}

impl Room {
    fn exit(&self, d: Direction) -> Option<usize> {
        match d {
            Direction::North => self.north,
            Direction::South => self.south,
            Direction::East => self.east,
            Direction::West => self.west,
        }
    }
}

with the walking loop as a match inside a match:

match Direction::parse(word) {
    None => println!("{word}: I do not know that direction."),
    Some(d) => match world[here].exit(d) {
        Some(next) => {
            here = next;
            println!("{word}: you are in the {}.", world[here].name);
        }
        None => println!("{word}: no exit that way; try {:?}.", d.opposite()),
    },
}
north: you are in the library.
south: you are in the hall.
east: you are in the kitchen.
up: I do not know that direction.
west: you are in the hall.

Two different absences, an unknown word and a missing exit, are handled in two different places, and neither can be skipped by accident. The rooms refer to each other by index into a vector rather than by reference, which sidesteps the ownership question entirely; it is the usual way to build a graph in Rust and it is worth learning before reaching for Rc.

6.6 Result

Result is Option with a reason attached:

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

Every fallible operation in the standard library returns one, so parse and read_to_string have needed unwrapping since Chapter 1. Chapter 8 is about handling them properly.

6.7 Recursive Types

An enum that contains itself would have no fixed size, so the recursive part goes behind a Box, a pointer to the heap and one word wide:

enum Expr {
    Number(f64),
    Neg(Box<Expr>),
    Add(Box<Expr>, Box<Expr>),
    Mul(Box<Expr>, Box<Expr>),
    Div(Box<Expr>, Box<Expr>),
}

use Expr::{Add, Div, Mul, Neg, Number};

fn eval(e: &Expr) -> Option<f64> {
    match e {
        Number(n) => Some(*n),
        Neg(a) => Some(-eval(a)?),
        Add(a, b) => Some(eval(a)? + eval(b)?),
        Mul(a, b) => Some(eval(a)? * eval(b)?),
        Div(a, b) => {
            let divisor = eval(b)?;
            if divisor == 0.0 {
                None
            } else {
                Some(eval(a)? / divisor)
            }
        }
    }
}

fn show(e: &Expr) -> String {
    match e {
        Number(n) => n.to_string(),
        Neg(a) => format!("-{}", show(a)),
        Add(a, b) => format!("({} + {})", show(a), show(b)),
        Mul(a, b) => format!("({} * {})", show(a), show(b)),
        Div(a, b) => format!("({} / {})", show(a), show(b)),
    }
}

fn main() {
    // (3 + 4) * -2
    let e = Mul(
        Box::new(Add(Box::new(Number(3.0)), Box::new(Number(4.0)))),
        Box::new(Neg(Box::new(Number(2.0)))),
    );
    println!("{} = {:?}", show(&e), eval(&e));

    let bad = Div(Box::new(Number(1.0)), Box::new(Number(0.0)));
    println!("{} = {:?}", show(&bad), eval(&bad));
}
((3 + 4) * -2) = Some(-14.0)
(1 / 0) = None

Box::new(x) puts x on the heap and owns it; when the Box is dropped the contents go too. It is the simplest of Rust’s pointer types and the one to reach for when a type needs to contain itself.

The use Expr::{...} line brings the variant names into scope so the tree can be written without repeating the type name.

The question mark in eval(a)? is a preview of Chapter 8. On an Option, it means: if this is None, return None from the enclosing function right now; otherwise unwrap it and carry on. Three characters replace a match whose failure arm does nothing interesting, and the division case shows the long form alongside it for contrast.

Exercise 6-1. Add a Sub variant to Expr and let the compiler find every place that needs updating. Note how many places that is, and how it found them.

Exercise 6-2. eval returns Option<f64>, so a division by zero and an unparseable input are indistinguishable. Change it to return Result<f64, String> with a message naming the problem.

Exercise 6-3. Give Rect a Default implementation by deriving it, and find out what the default f64 is.

Exercise 6-4. The Room struct has four nearly identical fields. Replace them with a single exits: Vec<(Direction, usize)> and rewrite exit. Which version would you rather extend with “up” and “down”?

Exercise 6-5. Write an enum Token for a small calculator language, with variants for numbers, the four operators, and parentheses. Write a function that turns a &str into a Vec<Token>, returning None on anything unrecognized.