05 · Structs & Enums¶
Structs group related data together under one type. Enums represent a value that can be one of several distinct variants — Rust's enums are far more powerful than in most languages, since each variant can carry its own data.
Defining and using structs¶
struct Person {
name: String,
age: u32,
}
fn main() {
let ada = Person {
name: String::from("Ada"),
age: 30,
};
println!("{} is {} years old", ada.name, ada.age);
}
Structs are immutable by default¶
struct Person {
name: String,
age: u32,
}
fn main() {
let mut ada = Person {
name: String::from("Ada"),
age: 30,
};
ada.age += 1; // requires `mut` on the binding
println!("{}", ada.age); // 31
}
Methods with impl¶
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// Associated function (no `self`) -- acts like a constructor
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height }
}
// Method (takes `&self`) -- operates on an existing instance
fn area(&self) -> f64 {
self.width * self.height
}
}
fn main() {
let rect = Rectangle::new(3.0, 4.0);
println!("{}", rect.area()); // 12
}
Enums — a value that's one of several variants¶
enum Direction {
North,
South,
East,
West,
}
fn describe(dir: &Direction) -> &str {
match dir {
Direction::North => "heading north",
Direction::South => "heading south",
Direction::East => "heading east",
Direction::West => "heading west",
}
}
fn main() {
let d = Direction::North;
println!("{}", describe(&d)); // heading north
}
Enums that carry data (this is what makes Rust enums special)¶
enum Shape {
Circle(f64), // radius
Rectangle(f64, f64), // width, height
Triangle(f64, f64), // base, height
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
Shape::Rectangle(width, height) => width * height,
Shape::Triangle(base, height) => 0.5 * base * height,
}
}
fn main() {
let shapes = vec![
Shape::Circle(2.0),
Shape::Rectangle(3.0, 4.0),
Shape::Triangle(6.0, 2.0),
];
for shape in &shapes {
println!("{:.2}", area(shape));
}
}
Each variant of Shape carries exactly the data it needs — a Circle only
stores a radius, a Rectangle stores two dimensions. match forces you to
handle every variant, so adding a new shape later means the compiler flags
every match that needs updating.
Deriving common traits¶
#[derive(Debug, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
fn main() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1.clone();
println!("{:?}", p1); // Point { x: 1, y: 2 }
println!("{}", p1 == p2); // true
}
#[derive(...)] auto-generates common trait implementations: Debug enables
{:?} printing, Clone enables .clone(), PartialEq enables ==. Adding
these attributes is far more common than hand-writing the trait
implementations yourself.
How It Actually Works¶
An enum like Shape is laid out in memory as a tagged union: a small
integer discriminant (which variant is active) followed by enough bytes to
hold the largest variant's payload, sized to fit whichever variant needs the
most space — a Circle(f64) and Rectangle(f64, f64) share one memory
layout sized for the bigger of the two. match compiles to a jump table or
chain of discriminant comparisons, and because the compiler knows the
exhaustive set of variants at compile time, it can check exhaustiveness
statically — that's the mechanism behind "adding a variant breaks every
non-exhaustive match," not a lint but a hard compile error rooted in the
enum's closed, known-at-compile-time shape. Rust also applies "niche
optimization" for common cases: Option<&T> is the same size as &T alone,
because a null pointer value is impossible for a reference and gets reused
as the None tag for free.
#[derive(Debug, Clone, PartialEq)] is a compiler-plugin-like mechanism
called a procedural macro that runs at compile time, inspects the
struct's field list via a syntax tree, and generates ordinary trait impl
blocks as if you'd hand-written them — Clone becomes a method that clones
each field in turn, PartialEq becomes a field-by-field == chain. There is
no runtime reflection involved: by the time your program runs, derive has
already disappeared, replaced by plain generated code that the rest of the
compiler optimizes exactly like anything else.
Cheat sheet¶
| Concept | Syntax |
|---|---|
| Define a struct | struct Name { field: Type, ... } |
| Instantiate | Name { field: value, ... } |
| Method | impl Name { fn method(&self) -> T { ... } } |
| Associated function | impl Name { fn new(...) -> Name { ... } } |
| Define an enum | enum Name { VariantA, VariantB(Type), ... } |
| Match on an enum | match value { Name::VariantA => ..., ... } |
| Auto-derive traits | #[derive(Debug, Clone, PartialEq)] |
🔀 See this in another language¶
Exercise¶
Define an enum Command with variants Add(i32, i32), Subtract(i32, i32),
and Quit. Write a function execute(cmd: &Command) -> Option<i32> that
returns Some(result) for Add/Subtract, and None for Quit. Then
define a struct Calculator with a history: Vec<i32> field and a method
run(&mut self, cmd: Command) that calls execute, and if it returns
Some(value), pushes value onto history.