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.