7.6 Custom Types and Pattern Matching

Introduction: Building Custom Data Structures
Section titled “Introduction: Building Custom Data Structures”In systems programming, the ability to define custom data types is fundamental for modelling real-world entities and organising complex information. Just as C/C++ provides struct and class for this purpose, Rust offers struct and enum as tools for creating custom, type-safe data structures. For C/C++ engineers, understanding Rust’s approach to these constructs is crucial, as they integrate seamlessly with Rust’s ownership and borrowing system, contributing to its memory safety. This section introduces Rust’s structs and enums, with a focus on practical examples and comparisons to familiar C/C++ concepts that we covered earlier in the module.
Structs: Grouping Related Data
Section titled “Structs: Grouping Related Data”Structs in Rust are custom data types that allow you to group together related pieces of data. They are similar to structs in C and C++ in that they are composite types, but Rust’s structs come with additional features and integrate deeply with the language’s safety guarantees.
Defining Structs
Section titled “Defining Structs”Rust provides three kinds of structs: classic structs (with named fields), tuple structs (named tuples), and unit structs (field-less). All are defined using the struct keyword.
Classic Structs (Named Fields)
These are the most common type of struct, analogous to C/C++ structs or classes with public members. Each field within the struct has a name and a type.
For example, in Rust:
// Define a classic struct named Userstruct User { active: bool, username: String, // String is Rust's growable, owned string type email: String, sign_in_count: u64,}Has the equivalence in C/C++:
// C++ equivalent using a struct (or use a class with public members)struct User { bool active; std::string username; // std::string is C++'s growable string type std::string email; unsigned long long sign_in_count;};In C++, struct members are public by default, while class members are private by default. In Rust, struct fields are private by default within their module unless explicitly marked pub.
Tuple Structs (Named Tuples)
Tuple structs are essentially named tuples. They are useful when you want to give a name to a tuple and make it a distinct type, but the individual fields don’t need their own names.
Rust Example:
// Define a tuple struct named Colorstruct Color(i32, i32, i32); // Represents RGB values
// Define another tuple struct named Pointstruct Point3D(i32, i32, i32); // Represents 3D coordinatesC/C++ Comparison: These are somewhat similar to type defining a std::tuple or a simple struct with unnamed members, but Rust’s tuple structs create distinct types.
// C++ equivalent using std::tuple (C++11 onwards)using Color = std::tuple<int, int, int>;using Point3D = std::tuple<int, int, int>;
// Or a C-style struct with unnamed fields (less common/idiomatic)struct Color_C { int r, g, b; }; // Still usually named fieldsUnit Structs (Field-less)
Unit structs are structs that have no fields at all. They are useful in situations where you need to implement a trait (we will cover traits in the next section) on some type but don’t need to store any data within the type itself. They are often used as marker types, i.e., when you want to distinguish different states or capabilities at the type level without storing any actual data within the struct itself. For example, you could have a Document that can be Pending or Published. You could use a generic Document<S> where S is the marker type.
struct Pending;struct Published;struct Document<State> { … }There isn’t a direct, idiomatic C++ equivalent. They might be loosely compared to empty classes or structs used as tags.
Instantiating Structs
Section titled “Instantiating Structs”To use a struct, you create an instance of it by providing concrete values for its fields. The following is a full code example for a Rust struct User (note that each separate instance of User is highlighted in the following example for clarity):
// Define a classic struct named User -- from previous examplestruct User { active: bool, username: String, // String is Rust's growable, owned string type email: String, sign_in_count: u64,}
fn main() { // 3 different ways to create the struct // Create an instance of the User struct let user1 = User { active: true, username: String::from("derek123"), email: String::from("derek@dcu.ie"), sign_in_count: 1, }; println!("User1 email: {}", user1.email);
// Field init shorthand (if variable name matches field name) let email = String::from("joe@example.com"); let username = String::from("joe456"); let user2 = User { email, // This is the field init shorthand: equivalent to email: email username, // Equivalent to username: username active: false, sign_in_count: 5, }; println!("User2 email: {}", user2.email);
// Struct update syntax (like C++ copy constructor with modifications) let user3 = User { email: String::from("charlie@example.com"), ..user1 // Copy fields from user1. user1 is moved if its fields are not Copy }; println!("User3 email: {}", user3.email); // Error if user1 was moved println!("User3 username: {}", user3.username);}Note that for user2, email and username are used as shorthand because the local variable names match the struct’s field names. The active and sign_in_count fields are initialized explicitly as their corresponding local variables (if they existed) do not match their field names, or simply because the values are literals.
The code gives the output:
User1 email: derek@dcu.ieUser2 email: joe@example.comUser3 email: charlie@example.comUser3 username: derek123In Rust, the .. (double-dot) syntax within a struct literal allows you to copy or move the remaining fields from another struct instance to create a new one, without having to explicitly list every field. This syntax is a convenient shorthand for creating slightly modified copies of existing struct instances.
When using struct update syntax (..user1), if user1 contains fields that are not Copy types (like String), user1 will be moved, and you won’t be able to use it afterward. If all fields are Copy types, user1 will be copied instead of moved.
C/C++ Equivalent (Classic Struct):
User user1 = {true, "derek123", "derek@dcu.ie", 1}; // Aggregate initialisationUser user2; // Default constructionuser2.active = false;user2.username = "joe456";user2.email = "joe@example.com";user2.sign_in_count = 5;
// C++ copy constructor with modifications (requires custom constructor)User user3 = user1; // Copy constructuser3.email = "charlie@example.com";Accessing and Modifying Fields
Fields of a struct instance are accessed using dot notation (.). To modify a field, the struct instance itself must be declared as mutable (let mut).
Rust Example:
Using the same User and Color structs from the previous examples:
struct User { active: bool, username: String, // String is Rust's growable, owned string type email: String, sign_in_count: u64,}
struct Color(i32, i32, i32); // Represents RGB values
fn main() { let mut user1 = User { active: true, username: String::from("derek123"), email: String::from("derek@dcu.ie"), sign_in_count: 1, };
// Access fields println!("User email: {}", user1.email); // Output: User email: alice@example.com
// Modify a field (user1 must be mutable) user1.email = String::from("new_derek@dcu.ie"); println!("New user email: {}", user1.email); // Output: New user email
// Accessing fields of a tuple struct let my_color = Color(255, 128, 0); println!("Red: {}, Green: {}, Blue: {}", my_color.0, my_color.1, my_color.2);}Gives the output:
User email: derek@dcu.ieNew user email: new_derek@dcu.ieRed: 255, Green: 128, Blue: 0C/C++ Equivalent:
User user1 = {true, "derek123", "derek@dcu.ie", 1};std::cout << "User email: " << user1.email << std::endl;user1.email = "new_derek@dcu.ie";std::cout << "New user email: " << user1.email << std::endl;🧩Knowledge Check
Section titled “🧩Knowledge Check”Match Struct Types and Features
Struct Shorthand and Update Syntax
Associated Functions and Methods (impl)
Section titled “Associated Functions and Methods (impl)”In Rust, functions associated with a struct (or enum or trait) are defined within an impl (implementation) block. These can be either associated functions (like static methods or constructors in C++) or methods (instance methods in C++).
Rust Example (Full Code)
// Define a Point structstruct Point { x: f64, y: f64,}
// Implement methods and associated functions for Pointimpl Point { // Associated function (like a static method or constructor) // Often used as a constructor, by convention named new fn new(x: f64, y: f64) -> Self { //Self is an alias for the type Point Point { x, y } }
// Associated function to create an origin point fn origin() -> Self { Point { x: 0.0, y: 0.0 } }
// Method (takes an immutable reference to self) // &self is shorthand for self: &Self fn distance_from_origin(&self) -> f64 { ((self.x * self.x) + (self.y * self.y)).sqrt() }
// Method (takes a mutable reference to self) // &mut self is shorthand for self: &mut Self fn translate(&mut self, dx: f64, dy: f64) { self.x += dx; self.y += dy; }
// Method (take of self) // self is shorthand for self: Self fn consume_and_print(self) { println!("Consuming Point at ({}, {})", self.x, self.y); // self is moved here, so the original variable cannot be used afterwards }}
fn main() { let p1 = Point::new(3.0, 4.0); // Call associated function let p_origin = Point::origin();
println!("Distance from origin for p1: {}", p1.distance_from_origin()); // Call method with immutable borrow
let mut p2 = Point::new(1.0, 2.0); p2.translate(5.0, -1.0); // Call method with mutable borrow println!("Translated p2: ({}, {})", p2.x, p2.y); // Output: Translated p2: (6, 1)
let p3 = Point::new(10.0, 20.0); p3.consume_and_print(); // Call method that takes ownership // println!("p3: ({}, {})", p3.x, p3.y); // ERROR: use of moved value: `p3`}This code gives the output:
Distance from origin for p1: 5Translated p2: (6, 1)Consuming Point at (10, 20)This Rust code program demonstrates the powerful language features related to structs that make such object-oriented-like patterns not only possible but safe and efficient. Rust employs the struct keyword to define custom data types, bundling related data into a single unit. Here, our Point struct groups two f64 values, x and y, representing coordinates. This provides clear data encapsulation.
The flexibility emerges from the impl block, which is Rust’s mechanism for implementing associated functions and methods on a type. Think of an impl block as where you define the behaviour that the struct possesses, much like we did when writing classes in C++. There are two types of functions:
- Firstly, we see Associated Functions. These are functions defined within the
implblock that do not takeselfas their first parameter. They are invoked directly on the type itself, much like static methods or constructors in C++. - Secondly, we have Methods. These are functions defined within an
implblock that do takeselfas their first parameter. Rust’s strict ownership system dictates howselfis passed, and this is where safety and control is managed.
This is very similar to how we wrote this code in C++.
- Associated Functions (
Point::new(),Point::origin()) are directly analogous to static methods in C++ classes. - Methods (
&self): These are equivalent toconstmember functions in C++. They receive an immutable reference to the instance (thispointer in C++ is implicitly constPoint_Cpp* const). - Methods (
&mut self): These are equivalent to non-const member functions in C++. They receive a mutable reference to the instance (thispointer is implicitlyPoint_Cpp* const). - Methods (
self): These methods take ownership of the instance, meaning the original variable cannot be used after the call. This is similar to passing an object by value in C++ where the object is copied, or explicitly moving it (std::move) if a move constructor is defined.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Methods and self
Order an impl Block
Enums: Representing Choices with Data
Section titled “Enums: Representing Choices with Data”Enums (enumerations) in Rust are powerful custom data types that allow you to define a type by enumerating its possible variants. Unlike simple C/C++ enums, Rust’s enums can have data associated with each variant, making them highly versatile for representing complex, structured choices. This makes them akin to “tagged unions” or “sum types” in other languages.
Defining Enums
Section titled “Defining Enums”Enums are defined using the enum keyword, followed by the enum’s name and its variants.
Rust Example (Simple Enum - Unit-like Variants):
// Define a simple enum for traffic light colorsenum TrafficLight { Red, Yellow, Green,}This is directly analogous to enum class in C++ (scoped enums).
enum class TrafficLight_Cpp { Red, Yellow, Green};Enum Variants with Data
Section titled “Enum Variants with Data”A key feature of Rust enums is that each variant can optionally hold data. This data can be structured like a tuple or like a struct.
Tuple-like Variants
These variants hold unnamed data, similar to tuple structs.
// Enum for messages in a game, with tuple-like dataenum Message { Quit, // No data Move(i32, i32), // Holds x and y coordinates Write(String), // Holds a String message ChangeColor(i32, i32, i32), // Holds RGB values}Struct-like Variants
These variants hold named data, similar to classic structs.
// Enum for different shapes, with struct-like dataenum Shape { Circle { radius: f64 }, Rectangle { width: f64, height: f64 }, Triangle { side1: f64, side2: f64, side3: f64 },}C++‘s std::variant (C++17 onwards) combined with std::visit provides similar functionality to Rust’s enums with associated data, acting as a type-safe tagged union. Before std::variant, a common pattern was to use a union combined with an enum discriminant, which was less type-safe.
🧩Knowledge Check
Section titled “🧩Knowledge Check”Enum Variants and Data
Defining Enums with Data
Pattern Matching with match
Section titled “Pattern Matching with match”The match expression is Rust’s primary way to handle enums with associated data. It allows you to execute different code blocks based on the variant of the enum, and it can also destructure the data contained within the variants. A crucial aspect of match is its exhaustiveness: the compiler ensures that all possible variants are handled, preventing unhandled cases that can lead to typical bugs in C++ switch statements.
The full code example is as follows:
enum Message { Quit, // No data Move(i32, i32), // Holds x and y coordinates Write(String), // Holds a String message ChangeColor(i32, i32, i32), // Holds RGB values}
enum Shape { Circle { radius: f64 }, Rectangle { width: f64, height: f64 }, Triangle { side1: f64, side2: f64, side3: f64 },}
fn process_message(msg: Message) { match msg { Message::Quit => { println!("Quitting the application."); } Message::Move(x, y) => { // Destructuring tuple-like variant println!("Moving to coordinates ({}, {}).", x, y); } Message::Write(text) => { // Destructuring String variant println!("Writing message: \"{}\".", text); } Message::ChangeColor(r, g, b) => { // Destructuring tuple-like variant println!("Changing color to RGB({}, {}, {}).", r, g, b); } }}
fn calculate_shape_area(shape: Shape) -> f64 { match shape { Shape::Circle { radius } => { // Destructuring struct-like variant std::f64::consts::PI * radius * radius } Shape::Rectangle { width, height } => { // Destructuring struct-like variant width * height } Shape::Triangle { side1, side2, side3 } => { // Destructuring struct-like variant // Heron's formula for triangle area let s = (side1 + side2 + side3) / 2.0; (s * (s - side1) * (s - side2) * (s - side3)).sqrt() } }}
fn main() { process_message(Message::Move(10, 20)); process_message(Message::Write(String::from("Hello Rust!"))); process_message(Message::Quit);
let circle = Shape::Circle { radius: 5.0 }; println!("Circle area: {}", calculate_shape_area(circle));
let rect = Shape::Rectangle { width: 4.0, height: 6.0 }; println!("Rectangle area: {}", calculate_shape_area(rect));}This gives the output:
Moving to coordinates (10, 20).Writing message: "Hello Rust!".Quitting the application.Circle area: 78.53981633974483Rectangle area: 24Rust’s structs and enums provide powerful and flexible ways to define custom data types, essential for organising complex programs in systems and edge computing. Structs allow for clear grouping of related data, while enums offer a robust mechanism for representing distinct choices, with the added benefit of carrying associated data.
For C/C++ engineers, the key takeaways are:
- Rust’s structs are similar to C/C++ structs, emphasising data aggregation. The
implblocks provide a structured way to define associated functions (like static methods) and methods (instance methods), with explicit&self,&mut self, andselfto manage borrowing and ownership. - Rust’s enums are far more powerful than traditional C/C++ enums, acting as type-safe tagged unions that can hold diverse data for each variant.
- The
matchexpression, especially when used with enums, is critical to Rust’s safety. Its compile-time exhaustiveness check prevents unhandled cases, a common source of bugs in C++ switch statements.
These custom types are fully integrated with Rust’s ownership and borrowing system, enabling developers to write expressive, memory-safe, and performant code, which is particularly beneficial for resource-constrained and critical applications in edge programming and computer architectures.
Mobile Robot Example: Try this yourself!
Section titled “Mobile Robot Example: Try this yourself!”Here is a short example for you to test your knowledge of enumerations. There is a small simulation below and underneath that there is a solution. Try not to look at the solution until you have at least attempted the question.
Write a Mobile Robot example that demonstrates an enum for robot movement and pattern matching. The code should define a Command enum with variants for Move, TurnLeft, and TurnRight. The Move variant should include a f64 value representing the distance to move. The Robot struct should store the robot’s position and direction in radians. There should also be an execute_command method that uses match for pattern matching on the Command enum, allowing the robot to respond appropriately to each command.
Give some example code to demonstrate it moving: For example, with the origin on the bottom left of the screen, start at the origin facing right (0.0, 0.0, 0.0), move forward by 5.0, turn left, move forward by 3.0, turn right three times, and move forward by 2.0. Don’t plot the output — it is here for illustrative purposes only.
Robot Movement
Watch a mobile robot execute a sequence of Move / TurnLeft / TurnRight commands — its position and heading update live on the grid.
Show Solution
Here is one possible solution that demonstrates the use of enums
use std::f64::consts::FRAC_PI_2; // Represents PI / 2
/// Command enum defines the possible actions for the robot.pub enum Command { Move(f64), // Move forward by a given distance TurnLeft, TurnRight,}
/// Robot struct stores the state of our mobile robot.#[derive(Debug)] // This allows us to print the struct for debugging.pub struct Robot { x: f64, y: f64, direction: f64, // Direction in radians}
impl Robot { /// Creates a new robot at a given position and direction. pub fn new(x: f64, y: f64, direction: f64) -> Self { Robot { x, y, direction } }
/// Executes a command using pattern matching to update the robot's state. pub fn execute_command(&mut self, command: Command) { match command { // If the command is Move, extract the distance and update x and y. Command::Move(distance) => { self.x += distance * self.direction.cos(); self.y += distance * self.direction.sin(); } // If the command is TurnLeft, add 90 degrees (PI/2 radians) to the direction. Command::TurnLeft => { self.direction += FRAC_PI_2; } // Command is TurnRight, subtract 90 degrees (PI/2 radians) from direction. Command::TurnRight => { self.direction -= FRAC_PI_2; } } }}
fn main() { // 1. Initialize the robot at the origin (0,0) facing right (0 radians). let mut robot = Robot::new(0.0, 0.0, 0.0); println!("Starting position: {:?}", robot);
// 2. Define a sequence of commands for the robot to execute. // This is a nice way to do it. Calling robot.execute_command() directly is fine. let commands = vec![ Command::Move(5.0), Command::TurnLeft, Command::Move(3.0), Command::TurnRight, Command::TurnRight, Command::TurnRight, Command::Move(2.0), ];
// 3. Execute each command and print the robot's state. for (i, command) in commands.into_iter().enumerate() { robot.execute_command(command); println!("After command {}: {:?}", i + 1, robot); }}This code gives the following output:
Starting position: Robot { x: 0.0, y: 0.0, direction: 0.0 }After command 1: Robot { x: 5.0, y: 0.0, direction: 0.0 }After command 2: Robot { x: 5.0, y: 0.0, direction: 1.5707963267948966 }After command 3: Robot { x: 5.0, y: 3.0, direction: 1.5707963267948966 }After command 4: Robot { x: 5.0, y: 3.0, direction: 0.0 }After command 5: Robot { x: 5.0, y: 3.0, direction: -1.5707963267948966 }After command 6: Robot { x: 5.0, y: 3.0, direction: -3.141592653589793 }After command 7: Robot { x: 3.0, y: 2.9999999999999996, direction: -3.141592653589793 }🧩Final Knowledge Check
Section titled “🧩Final Knowledge Check”Exhaustive Pattern Matching
Method Call Syntax
Build a Complex Enum Match
© 2026 Derek Molloy, Dublin City University. All rights reserved.