Structs, Enums, and Error Handling
Learning Objectives
- You can define and use structs to create custom data types.
- You understand how to implement methods and associated functions.
- You can work with enums to represent different variants of data.
- You understand the
Option<T>enum and how to handle absent values. - You can use the
Result<T, E>type for error handling. - You know when and how to use the
?operator for error propagation. - You know when and how to use
Box<T>for recursive types.
Warming up
Here is a set of tiny programming tasks to get you familiar with syntax that relates to structs, enums, and error handling. You can solve these exercises directly in the embedded code editor.
Defining Structs
A struct (short for structure) is a custom data type that lets you package together related values. If you’re familiar with objects in other languages, structs are similar - they group data that belongs together.
Let’s create a struct to represent a menu item in our coffee shop:
The struct definition declares the fields (name, price, calories) and their types. To create an instance of the struct, we provide values for each field.
We access fields using dot notation: espresso.name, espresso.price, etc.
Mutable Structs
Like all variables in Rust, struct instances are immutable by default. To modify fields, the entire instance must be mutable:
Note: Rust doesn’t allow you to mark individual fields as mutable - either the entire struct instance is mutable or none of it is. This keeps things simple and predictable.
Field Init Shorthand
When the variable name matches the field name, you can use a shorthand:
Struct Update Syntax
You can create a new instance based on an existing one:
Note: Be careful with the struct update syntax and ownership. If the original struct contains non-Copy types (like String), those fields will be moved. In the example above, we specified
nameexplicitly, so onlycaloriesis copied fromespresso.
Methods and Associated Functions
Structs are useful, but we can make them even more powerful by adding methods - functions that operate on instances of the struct.
Implementing Methods
Methods are defined in an impl (implementation) block:
Let’s break down the three method signatures:
&self- Borrows the instance immutably. The method can read but not modify.&mut self- Borrows the instance mutably. The method can modify the instance.self- Takes ownership of the instance. The instance is consumed and can’t be used after.
Most methods use &self or &mut self. Using self (taking ownership) is less common and typically used when transforming an instance into something else.
Associated Functions
Associated functions are like methods, but they don’t take self as a parameter. They’re called on the type itself, not on an instance:
Note: By convention, many structs have a
new()associated function that creates instances. Unlike some languages,newisn’t a keyword in Rust - it’s just a naming convention.
Notice how we call associated functions: MenuItem::new() - using the struct name and ::. We call methods: latte.display() - using an instance and ..
Multiple impl Blocks
You can have multiple impl blocks for the same struct. This is usually not necessary, but it’s allowed:
Enums
While structs let you group related data together, enums let you define a type that can be one of several variants. Enums are perfect for representing data that can be one thing or another.
Defining Enums
Let’s create an enum for drink sizes:
Each variant (Small, Medium, Large) is accessed through the enum name with ::.
doesn’t directly support creating enums, but as we covered in 7 Objects and Compatibility one could emulate them using sum types.
As a reminder, to define an enum of “three variants”, we can write Unit + Unit + Unit.
Let’s recreate the above DrinkSize example in :
Enums with Data
Enums become really powerful when variants can hold data:
Notice how each variant can have different types and amounts of data:
Cashhas a singlef64valueCardhas named fields like a structMobileApphas a singleStringvalue
Methods on Enums
Just like structs, enums can have methods:
Box and Recursive Types
Now that we understand enums, let’s explore a powerful feature that’s essential for building complex data structures: recursive types.
The Problem: Infinite Size
Sometimes you want to create a type that contains itself. For example, imagine we want to create an order that is composed of a list of menu items. Each item in the order could point to the next item:
enum OrderList {
Node(MenuItem, OrderList),
Empty,
}
Note: You might notice that the above is a linked list. In reality, we’d build this with a vector or similar. We’ll learn about collections in the next chapter. The
Nodeis a name we give to each element in the list, containing aMenuItemand a pointer to the nextOrderList— it could, e.g., as well be calledElementorItem.
When you try to compile this, Rust will complain. Why? Because it can’t determine the size of OrderList. Each Node contains another OrderList, which contains another OrderList, and so on - leading to infinite size! Rust gives us this error:
error[E0072]: recursive type `OrderList` has infinite size
Rust needs to know how much memory to allocate for each type at compile time. But with our OrderList definition above, how big is a OrderList?
- It contains a
MenuItemand anotherOrderList - And another
OrderList(which contains aMenuItemand anotherOrderList) - Which contains a
MenuItemand anotherOrderList - Forever!
The size is infinite, so knowing the size at compile time is impossible.
Enter Box<T>
Box<T> is a smart pointer that allocates values on the heap instead of the stack. The Box itself has a known, fixed size (it’s just a pointer), even though it can point to any amount of data.
Here’s how we fix our list:
enum OrderList {
Node(MenuItem, Box<OrderList>),
Empty,
}
As a whole, a program using Box<OrderList> looks like this:
Now the OrderList has a known size:
- The
MenuItemhas a fixed size - The
Box<OrderList>is 8 bytes (just a pointer on a 64-bit system) - Total: fixed size (plus some alignment)
The actual list data lives on the heap, and we just hold pointers to it.
A smart pointer is a data structure that behaves like a pointer but also has additional capabilities, such as automatic memory management. In Rust, Box<T> is a smart pointer that allocates data on the heap and automatically deallocates it when it goes out of scope.
Smart pointers enable polymorphism by allowing you to work with different types through a common interface. Polymorphism broadly means here that Box<OrderList> can point to any variant of the OrderList enum, allowing the recursive structure to handle lists of any length with a fixed-size pointer.
Working with Recursive Types
Let’s make our list more useful by adding methods:
Notice how we can pattern match on Box<OrderList> just like we would on OrderList - Rust automatically dereferences it for us!
Note: A cool thing in the above example is also how the sum and len methods are implemented recursively. Each method calls itself on the next item in the list until it reaches the
Emptyvariant.
Example: Binary Tree
As another example, let’s look into a binary tree, which is typically discussed in introductory data structures and algorithms courses. The example uses Box<T> to handle recursion. The #[derive(Debug)] attribute automatically implements the Debug trait, which lets us print the enum with {:#?}.
Use Box<T> when you (1) have a recursive type or need heap allocation, (2) need a type with unknown size at compile time, (3) want to transfer ownership of large data without copying, or (4) need trait object (we’ll look into traits in Chapter 7).
The Option Enum
Rust doesn’t have null like many other languages. Instead, it has the Option<T> enum to represent a value that might be absent:
enum Option<T> {
Some(T),
None,
}
Note:
Optionis automatically imported in every Rust program. You don’t need to writeOption::SomeorOption::None- justSomeandNonework.
Using Option
Let’s use Option to represent items that might be in stock:
The Option<f64> return type tells us: “This function might return a price, or it might not.” The caller must handle both cases.
Unwrapping Options
There are several ways to get the value out of an Option:
Note: Avoid
unwrap()in production code! It will crash your program if the value isNone. Use it only when you’re absolutely certain the value exists, or in examples and quick prototypes.
Option Methods
Option has many useful methods:
The Result Type
While Option represents a value that might be absent, Result<T, E> represents an operation that might fail:
enum Result<T, E> {
Ok(T),
Err(E),
}
Ok(T)contains the success valueErr(E)contains the error information
Using Result
Let’s create a function that might fail:
The Result<i32, String> tells us:
- Success: returns an
i32 - Error: returns a
Stringdescribing what went wrong
Handling Results
Like Option, there are several ways to handle Result:
The ? Operator
The ? operator is a shorthand for error propagation. If the Result is Ok, it unwraps the value. If it’s Err, it returns the error from the current function:
Note: The
?operator can only be used in functions that returnResultorOption. You can’t use it inmain()unless you changemain’s signature to return aResult.
Here’s an example of using ? multiple times:
Each ? checks if the operation succeeded. If any operation fails, the function immediately returns with that error.
Creating Custom Error Types
Using String for errors works but isn’t ideal. It’s better to create custom error types that clearly describe what can go wrong:
Custom error types make your code more self-documenting and allow callers to handle different errors differently:
Example: Coffee Shop System
Let’s bring everything together in a coffee shop order management system using recursive types:
This example demonstrates:
1. Using Option<OrderList>
struct Order {
items: Option<OrderList>,
}
Wrapping the list in Option allows us to temporarily take ownership — this allows us to modify the list without cloning it.
2. Option’s take() Method
if let Some(old_items) = self.items.take() {
self.items = Some(old_items.add(order_item));
}
take() removes the value from the Option, leaving None behind. This gives us ownership of old_items so we can pass it to add().
3. Working with Option<T>
fn total(&self) -> f64 {
self.items.as_ref()
.map(|list| list.total())
.unwrap_or(0.0)
}
We use as_ref() to borrow the value inside the Option, then map() to transform it, and unwrap_or() to provide a default.
4. Pattern Matching on Option
match &self.items {
Some(list) if !list.is_empty() => { /* ... */ }
_ => Err(OrderError::EmptyOrder),
}
We can match on the Option and add guard clauses for additional checks.
Summary
In this chapter, we covered essential Rust types and patterns:
- Structs group related data together into custom types
- Methods are defined in
implblocks with&self,&mut self, orself - Associated functions don’t take
selfand are called with:: - Enums represent data that can be one of several variants
- Enums can hold data making them very flexible
Box<T>enables recursive types by storing data on the heap- Recursive types like trees and lists require
Boxbecause they contain themselves Option<T>replaces null and forces you to handle absent valuesResult<T, E>represents operations that can fail- The
?operator simplifies error propagation in Result-returning functions - Custom error types make code more maintainable and self-documenting