Rust Basics - Module 4: Structs, Enums and Pattern Matching
Part 4 of 5 in Rust BasicsIn the previous blog post, we discussed about "Borrowing and References" and how they make ownership practical. Without borrowing, every function call would consume its arguments, and that makes ownership extremely awkward to work with. In this blog post, we will cover Structs, Enums and Pattern Matching in the Rust.
Structs are the custom data types which allow us to group related data types into one. And Enums are algebraic data types i.e., each variant can carry its own data of different types unlike 'C' enums - a user-defined data type used to assign names to integer constants.
Let's start with structs, then move to enums and bring it all together with pattern matching.
Part 1: Structs
A struct or structure, is a custom data type that lets us group and name multiple related values that make up a meaningful group under a named type.
Definition
Using struct keyword we can define our own structure in Rust. For example:
struct User {
username: String, // Calls as Field
email: String,
age: u32,
active: bool,
}Creating Instance and Access
To use a struct after we defined it, we have to create an instance by giving a concrete value to the each field in the struct. We can do that by stating the name of the struct and then add the curly brackets containing the key:value pairs, where the keys are the names of the fields and values are the data we wanted to store.
fn main() {
let user = User {
username: String::from("siva"),
email: String::from("siva@example.com"),
age: 25,
active: true,
};
println!("{}", user.username);
}Note: Since
structacts as a template for a group, we don't need to create/assign the fields in the same order as we defined in the struct.
Now, if we want to access a value from the instance we just created, we can use the . notation to access the particular filed and get the value.
let username = user.username;Structs are immutable by default - the same rule as any variable. To mutate the fields, the entire binding must be mut , as Rust does not allow marking each individual fields as mut . It is all or nothing.
let mut user = User {
username: String::from("siva"),
email: String::from("siva@example.com"),
age: 25,
active: true,
};
user.age = 24; // Allowed nowAnd also, we can create a new instance reusing fields from an existing one, or what we call Struct update syntax.
let user2 = User {
email: String::from("other@example.com"),
..user // fills remaining fields from user defined above
} Note:
..usermoves fields that are notCopy. If username is aString, it moves out ofuserand no long fully usable, so use when it is absolute necessary.
Part 2: Types of Struct
In Rust, we can define or use a struct in three ways, say:
Named field struct
Tuple struct
Unit struct
Named Field Struct
Standard way of defining the structs as we discussed above.
struct User {
username: String,
email: String,
age: u32,
active: bool,
}Tuple Struct
Tuple structs is named tuples, useful when the field's position carries meaning:
struct Point(f64, f64);
struct Color(u8, u8, u8);
let p = Point(3.0, 4.0);
let c = Color(255, 128, 0);
println!("{}", p.0); // Access fields by position numberUnit Structs
Unit-like structs can be useful when you need to implement a trait (we'll discuss more in upcoming modules) on some type but don't have any data that you want to store in the type itself.
struct AlwaysEqual;
fn main() {
let subject = AlwaysEqual;
}Part 3: Behaviour with impl - Methods & Associated Functions
Methods are similar to functions. We declare them with the fn keyword followed by a name, parameters and a return value. Unlike functions, methods are defined within the context of a struct (or enum/trait), and their first parameter is always self, which represents the instance of the struct the method is being called on. The only exception for self is, if the function is an associated function to struct like the function create/initialize a struct.
Rust separates data definition from behaviour. We can define the set of methods in an impl block, not in the struct itself.
Example
// Struct (data definition)
struct Rectangle {
width: f64,
height: f64,
}
// Methods (behaviour)
impl Rectangle {
// associated
fn new(width: f64, height: f64) -> Rectangle {
Rectangle { width, height } // field init shortand when name matches
}
// method - takes &self, borrows the instance
fn area(&self) -> f64 {
self.width * self.height
}
// mutable method - takes &mut self
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
// consuming method - takes self, moves the instance
fn into_square(self) -> Rectangle {
let side = self.width.min(self.height);
Rectangle { width: side, height: side }
}
}
fn main() {
let mut r = Rectangle::new(10.0, 5.0);
println!("area: {}", r.area());
r.scale(2.0);
println!("after scale: {}x{}", r.width, r.height);
}The three method receives types - &self , &mut self , self - map directly to the borrowing rules. &self borrows immutably. &mut self borrows mutably. self takes ownership - the caller loses the instance after calling this method.
Rectangle::new is an associated function (no self ). It is the Rust convention for constructors. There is no new keyword - it is just a naming convention.
Part 4: Enums
Also called Enumerations. Enums allow you to define a type by enumerating its possible variants. And this is where Rust diverges sharply from C. Rust enums are algebraic data types - each variant can carry its own data of different types.
Defining the Enums
Using enum keyword, we can define an enum in the Rust. For example:
enum Shape {
Circle(f64),
Rectangle(f64, f64),
Triangle(f64, f64, f64),
}This is a single type Shape that can be any one of three variants, each carrying different data. Compare this to C where we would need a union plus a tag field plus discipline to not access the wrong union member. Rust eliminates that entire pattern: the compiler knows which variant is active, and pattern matching forces you to handle each one explicitly.
Another example:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}This enum has four variants with different types:
Quit: Has no data associated with it at allMove: Has named fields, like astructdoesWrite: Includes a singleStringChangeColor: Includes threei32values.
Enum Values
We can create instances of each of the three variants of Shape like this:
let c = Shape::Circle(10.0);
let r = Shape::Rectangle(10.0, 15.0);
let t = Shape::Triangle(5.0, 6.0, 10.0);Standard Rust's Enums
The most important enums in all of Rust are built into the standard library:
Option<T>Result<T, E>
Option<T>
Represents a value that may or may not exists. Rust has no null . Instead:
enum Option<T> {
Some(T), // there is a value
None, // there is no value
}
let maybe: Option<i32> = Some(42);
let nothing: Option<i32> = None;You cannot use an Option<T> as if it were a T directly - the compiler forces you to handle the None case. This eliminates an entire class of null pointer bugs.
Result<T, E>
Represents either success or failure.
enum Result<T, E> {
Ok(T), // Success, carries the value
Err(E), // Failure, carries the error
}
fn divide(a: f64, b: f64) -> Result<f64, String> {
if b == 0.0 {
Err(String::from("division by zero"))
} else {
Ok(a/b)
}
}Part 5: Pattern Matching - match
match is the primary way to work with enums. It is exhaustive - the compiler forces you to handle every variant. No case can be silently ignored. An example using Shape enum defined above:
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rectangle(w, h) => w * h,
Shape::Triangle(a, b, c) => {
let s = (a + b + c) / 2.0;
(s * (s-a) * (s-b) * (s-c)).sqrt()
}
}
}Each arm is pattern -> expression . The pattern de-structure the variant and bind its inner values to names. If you miss a variant, the compiler tells you - no runtime surprises.
For Option :
let value: Option<i32> = Some(42);
match value {
Some(n) => println!("got: {}", n),
None => println!("nothing"),
};Catch-all patterns
match number {
1 => println!("one"),
2 => println!("two"),
_ => println!("something else"), // _ matches anything, binds nothing
}if let - shorthand when you only care about one variant:
if let Some(n) = value {
println!("got: {}", n);
} // equivalent to matching Some and ignoring Nonewhile let - loop while a pattern matches:
while let Some(top) = stack.pop() {
println!("{}", top);
}Part 6: Exercises
Define a struct
Transactionwith fields:id: u64,amount: f64,status: String. Add animplblock with a constructornewand a methodis_large(&self) -> boolthat returns true ifamount > 1000.0.
struct Transaction {
id: u64,
amount: f64,
status: String,
}
impl Transaction {
fn new(id: u64, amount: f64, status: String) -> Transaction {
Transaction { id, amount, status }
}
fn is_large(&self) -> bool {
self.amount > 1000.0
}
}
fn main() {
let txn1 = Transaction::new(1, 900.0, String::from("success"));
let txn2 = Transaction::new(2, 1001.0, String::from("success"));
println!("Txn1 is large = {}", txn1.is_large());
println!("Txn2 is large = {}", txn2.is_large());
}Define an enum
Commandwith variants:Quit,Move { x: i32, y: i32 },Print(String). Write amatchthat handles all three and prints something meaningful for each.
enum Command {
Quit,
Move { x: i32, y: i32 },
Print(String)
}
fn execute_command(cmd: &Command) {
match cmd {
Command::Quit => println!("Quitting"),
Command::Move{x, y} => println!("Moving x:{} and y:{}", x, y),
Command::Print(s) => println!("{}", s)
}
}
fn main() {
let cmd1 = Command::Quit;
let cmd2 = Command::Move {
x: 10,
y: 10,
};
let cmd3 = Command::Print("Hello".to_string());
execute_command(&cmd1);
execute_command(&cmd2);
execute_command(&cmd3);
}Conclusion
Structs let you group related data into meaningful types. Enums let a single type represent multiple distinct variants, each carrying its own payload. match ties it all together - exhaustive, compiler-enforced, no silent ignores. Together, they replace the scattered nulls, union hacks, and runtime surprises you'd find in C. This is Rust making correctness a compile-time guarantee, not a runtime hope.
What's Next: Traits & Generics
So far, every type we've written is fixed to one kind of data. Traits and Generics change that letting you write code that works across many types while keeping the compiler's full safety guarantees. That's the next module.
Enjoyed this post?
1 reaction