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.
Struct Mutability
0 / 3 points
Which statements about struct mutability in Rust are true?
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:
Adding Category and Caffeine to MenuItem
0 / 15 points
Extend the MenuItem struct to include the following additional fields:
category: A string representing the category of the menu item (e.g., “Beverage”, “Food”, “Dessert”).contains_caffeine: A boolean indicating whether the item contains caffeine.
Update the constructor method new to initialize these new fields (use name, category, contains_caffeine, and price as the parameter order). Additionally, implement the following method:
to_string(&self) -> String: Returns a formatted string representation of the menu item, including its name, category, caffeine content, and price.
Here is an example of how the formatted output should look:
let item = MenuItem::new(String::from("Espresso"), String::from("Beverage"), true, 2.50);println!("{}", item.to_string());// Output: "Espresso (Beverage, contains caffeine): 2.50 euros"let item = MenuItem::new(String::from("Herbal Tea"), String::from("Beverage"), false, 1.75);println!("{}", item.to_string());// Output: "Herbal Tea (Beverage, caffeine-free): 1.75 euros"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
Discount Enum
0 / 15 points
Create a Discount enum with three variants:
Percentage(f64)- represents a percentage discount (e.g., 15.0 for 15%).FixedAmount(f64)- represents a fixed amount discount (e.g., 5.0 for 5 euros).NoDiscount- represents no discount.
Then, implement a function apply_discount(price: f64, discount: Discount) -> f64 that takes a price and a discount as parameters and returns the final price after applying the discount.
Function call examples:
let discount1 = Discount::Percentage(10.0);let final_price1 = apply_discount(100.0, discount1); // returns 90.0let discount2 = Discount::FixedAmount(15.0);let final_price2 = apply_discount(100.0, discount2); // returns 85.0let discount3 = Discount::NoDiscount;let final_price3 = apply_discount(100.0, discount3); // returns 100.0Methods on Enums
Just like structs, enums can have methods:
MenuItem Category as Enum
0 / 10 points
Modify the MenuItem struct so that the category field is represented as an enum instead of a string. Define an enum Category with the following variants:
BeverageFoodDessert
Update the constructor method new to accept a Category parameter for the category field. Additionally, adjust the program in a suitable manner so that the functionality remains the same; e.g. the to_string method should still return a formatted string representation of the menu item, including its name, category, caffeine content, and price.
Here is an example of how the formatted output should look:
let item = MenuItem::new(String::from("Espresso"), Category::Beverage, true, 2.50);println!("{}", item.to_string());// Output: "Espresso (Beverage, contains caffeine): 2.50 euros"let item = MenuItem::new(String::from("Herbal Tea"), Category::Beverage, false, 1.75);println!("{}", item.to_string());// Output: "Herbal Tea (Beverage, caffeine-free): 1.75 euros"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 sizeRust 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.
Box Characteristics
0 / 3 points
Which statements about Box<T> are correct?
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).
Binary Tree Sum
0 / 15 points
Add a method fn sum(&self) -> i32 to the binary tree that calculates the sum of all values in the tree nodes. For a starting point, look at how the sum method is implemented for the OrderList in the materials.
Example usage:
let mut tree = BinaryTree::new();println!("Sum of empty tree: {}", tree.sum()); // prints Sum of empty tree: 0tree.insert(5);tree.insert(3);println!("Sum of tree with 2 nodes: {}", tree.sum()); // prints Sum of tree with 2 nodes: 8The 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.
Average with Option
0 / 15 points
Add a method fn average(&self) -> Option<f64> to the Entries struct. The method should return the average of the values in the values vector as an Option<f64>. If the vector is empty, the method should return None. Otherwise, it should return Some(average) where average is the calculated average.
Example usage:
let mut data: Entries = Entries::new();
if let Some(avg) = data.average() { println!("Average: {:.2}", avg);} else { println!("No entries to calculate average.");}
data.add(10);data.add(20);
if let Some(avg) = data.average() { println!("Average: {:.2}", avg);} else { println!("No entries to calculate average.");}Option Methods
Option has many useful methods:
Option Methods
0 / 3 points
What does the .map() method do on an Option?
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
Result Type Components
0 / 3 points
What do the generic parameters T and E represent in Result<T, E>?
Handling Results
Like Option, there are several ways to handle Result:
Pattern Matching on Results
0 / 3 points
Which code correctly handles both success and error cases of a 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.
Question Operator Behavior
0 / 3 points
Given this code, what happens if parse::<i32>() returns an error?
fn process(input: &str) -> Result<i32, String> { let num = input.parse::<i32>() .map_err(|_| String::from("Parse error"))?; Ok(num * 2)}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:
Custom Error Types Benefits
0 / 3 points
Why are custom error types (enums) better than using String for errors?
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.
Order Processing
0 / 15 points
The assignment template comes with necessary structs for adding and processing orders in an order management system. There is a separate struct for orders, and a separate struct for the storage.
The method fn process_order(&mut self, order: &Order) is currently unimplemented. Implement it so
that it first checks that all the items are present in the inventory. If any item is not present, return an ItemNotFound error. If any item is out of stock or has insufficient quantity, return an InsufficientStock error.
If all items are in stock with sufficient quantity, deduct the ordered quantities from the inventory and return a success message. The main function has sample calls to demonstrate the functionality.
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