Structs and enums
Ownership tells you who holds a String. This lesson names groups of data, then the one-of-many values that replace boolean flags. Patterns and method receivers come after those two shapes, because both of them use the shapes. Typestate is last: it needs a consuming method.
Structs
A struct groups related data under a name. That is the domain model.
| Form | Shape | Use it for |
|---|---|---|
| Named fields | struct User { id: u64, email: String } | The usual domain model |
| Tuple struct | struct Color(u8, u8, u8) | A small ordered group, or a newtype with one field |
| Unit struct | struct LoggedIn | A marker with no data |
id sits in the stack frame beside the String’s pointer, length, and capacity. You already know that String layout.
impl User attaches functions. new is an associated function: no self, and it returns Self. email(&self) borrows to read. set_email(&mut self, e: String) borrows to change. into_email(self) takes ownership. &self reads, &mut self changes, self consumes. The section on receivers is the full decision, after enums, because you need both shapes first.
let u2 = User { email: new_email, ..u1 } copies or moves the fields you did not name. Fields are private outside their module. Keep them private and offer a constructor that protects the invariant, such as an email that must contain @. A newtype, struct Metres(f64) beside struct Seconds(f64), stops you passing a duration where a length belongs. It costs nothing at runtime. The last section of this lesson uses that idea for identities and for stages of a document.
struct Metres(f64);
struct User {
id: u64,
email: String,
}
impl User {
fn new(email: String) -> Result<Self, &'static str> {
if !email.contains('@') {
return Err("email must contain @");
}
Ok(Self { id: 0, email })
}
fn email(&self) -> &str {
&self.email
}
}
fn main() {
let user = User::new(String::from("a@b.com")).expect("email");
let metres = Metres(3.0);
println!("{} {}", user.email(), metres.0);
}Enums and Option
An enum is one of several variants. It replaces null, a pile of booleans, and states that cannot happen together. enum IpAddr { V4(u8, u8, u8, u8), V6(String) } holds either four bytes or a string, never both.
The value carries a tag. V4 stores 127, 0, 0, 1. The V6 string is not there.
Option is the enum that removes most null crashes: None or Some(T). Absence is in the type. Reach for these in order. unwrap and expect panic on None. Libraries avoid them.
| Form | What it does |
|---|---|
| match | Names every case, including None |
| if let Some(v) = opt | Handles the one variant you care about |
unwrap_or | Supplies a default value |
unwrap_or_else | Computes the default only when the value is None |
| map | Transforms the inner value and leaves None alone |
| and_then | Chains a step that itself returns Option |
| ok_or | Turns an Option into a Result. That conversion is the next lesson. |
struct Order { paid: bool, shipped: bool } allows shipped without paid. enum Order { Draft, Paid { at: Timestamp }, Shipped { tracking: String } } does not. A match on Order forces every business state. Methods on an enum are an impl that matches self.
enum Order {
Draft,
Paid { at_unix: u64 },
Shipped { tracking: String },
}
fn label(order: &Order) -> &str {
match order {
Order::Draft => "draft",
Order::Paid { .. } => "paid",
Order::Shipped { tracking } => tracking.as_str(),
}
}
fn main() {
let order = Order::Shipped { tracking: String::from("1Z") };
let maybe = Some(7);
println!("{} {}", label(&order), maybe.unwrap_or(0));
}Patterns
A match arm is a pattern, an optional guard, and an expression. Learn the toolkit in this order, because each one nests inside the previous.
| Pattern | Example |
|---|---|
| Literal | 1 | 2 | 3 |
| Range | 1..=9 |
| Binding | x names the value |
| Struct | Point { x, y } |
| Enum | Some(n), Err(e) |
| Ignore | _ throws the rest away. rest @ ... names it. |
| Nested | Some(User { id, .. }) |
| Guard | n if n % 2 == 0 |
| At-binding | n @ 1..=5 keeps the name and the range |
The first pattern that fits runs its expression. A guard can still reject that arm.
if let Some(x) = opt handles the one variant you care about. while let Some(n) = stack.pop() loops while that variant keeps appearing. let Some(user) = find(id) else { return Err(...) } is the guard-clause form: either user is bound, or the function returns. Matching a reference usually lets you write the pattern as if you had the value. Prefer match on enums over a soup of booleans. While you are still designing, name an ignored piece _reason instead of throwing it away.
struct Point {
x: i32,
y: i32,
}
fn classify(n: i32) -> &'static str {
match n {
n @ 1..=5 if n % 2 == 0 => "small even",
1..=9 => "small",
_ => "other",
}
}
fn main() {
let p = Point { x: 1, y: 2 };
let Point { x, y } = p;
println!("{x},{y} {}", classify(4));
let mut next = Some(2);
while let Some(n) = next {
println!("{n}");
next = if n == 0 { None } else { Some(n - 1) };
}
}Method receivers
The first parameter decides what the call does to the caller’s value.
| Receiver | Caller still owns it | Use it for |
|---|---|---|
&self | Yes | The default. Queries and formatting. |
&mut self | Yes. The binding must be mut. | An update in place |
| self | No | A transform that invalidates the old value: into_string, into_iter |
mut self | No. You return the changed value. | A builder chain. Rarer than the other three. |
&self is the default: queries and formatting. &mut self updates in place, and the binding must be mut. The caller still owns v.
A builder often looks like Client::builder().timeout(...).build(), chaining &mut self or self. A getter should return &str or a slice, not a clone. From and Into are the standard library’s consuming conversions. You will see the trait names when traits are introduced. The method shape is the part that matters now: into_* takes self.
struct Buffer {
data: String,
}
impl Buffer {
fn as_str(&self) -> &str {
&self.data
}
fn push(&mut self, extra: &str) {
self.data.push_str(extra);
}
fn with_suffix(mut self, suffix: &str) -> Self {
self.data.push_str(suffix);
self
}
fn into_string(self) -> String {
self.data
}
}
fn main() {
let mut buf = Buffer { data: String::from("hi") };
buf.push("!");
let owned = buf.with_suffix(".").into_string();
println!("{owned}");
}Newtypes and typestate
struct UserId(u64) and struct Email(String) stop the wrong plain value reaching an API. There is no extra runtime cost. You saw Metres and Seconds for the same reason.
Typestate puts the lifecycle in the type, so an illegal step does not compile. Post
edit can exist on a draft. The published methods are not on this type, so you cannot call them by mistake.
The same split shows up as a connection that is closed or open, and as a request builder that is not sealed yet. The illegal state is not a boolean you have to remember to check.
struct Draft;
struct Published;
struct Post<State> {
body: String,
state: std::marker::PhantomData<State>,
}
impl Post<Draft> {
fn new(body: String) -> Self {
Self { body, state: std::marker::PhantomData }
}
fn publish(self) -> Post<Published> {
Post { body: self.body, state: std::marker::PhantomData }
}
}
fn main() {
let draft = Post::<Draft>::new(String::from("hello"));
let _published = draft.publish();
}