Rust Intermediate

Intermediate Rust

Rust Intermediate

Advanced Methods, Enums, and Pattern Matching

Advanced Methods, Enums, and Pattern Matching

We have seen how methods work: when there is a struct, a method must be implemented for that struct to be able to use the data in the struct. Methods are also defined for enums and traits — concepts that we will explore after now. For now, let’s revise some of the constructs of method implementations in general.

We will build a simple user session management program using everything we have learned. Create a new project called session_mgt using Cargo. Create a module called method.rs and export it to the main.rs file. If you don’t know how to do this, read the Beginner Rust book. Next, type this code in the method.rs file by yourself, trying to understand what is happening:

use rand::Rng;

// Create the underlying struct for the User.
#[derive(Debug, Clone)]
pub struct User {
    pub id: u32,
    pub username: String,
    pub email: String,
}

// Implement the User struct.
impl User {
    // new is a constructor function for User that creates a new user.
    pub fn new(id: u32, username: &str, email: &str) -> Self {
        User { 
            id, 
            username: username.to_string(), 
            email: email.to_string() }
    }
}

// Create the underlying struct for the Session.
#[derive(Debug, Clone)]
pub struct Session {
    pub user_id: u32,
    pub token: String,
}

// Implement the Session struct {
impl Session {
    // new is a constructor function for Session that creates a new user session.
    pub fn new(user_id: u32) -> Self {
        let token = rand::thread_rng()
        .sample_iter(rand::distributions::Alphanumeric)
        .take(16)
        .map(char::from)
        .collect();

    Session { user_id, token }
    }
}

Great! We successfully created two structs: User and Session. Notice how we used the pub keyword to judiciously export the struct's properties, the struct itself, and the functions that implement it.

If you come from a functional programming language that uses structs, you will not be new to method implementations for structs. When creating a new struct, it is ideal to always have a constructor function using the new function name. This name is not compulsory but has become conventional practice.

Note: When writing code, keep in mind that the code is not being read by the user of the software, but by another programmer like you. Hence, optimize the code for good developer experience, and the software for good user experience.

Another observation from the code above is the use of the Self keyword. This is common with other functional languages. By rule of thumb, the new constructor function always returns an instance of the struct (called self). Other functions look somewhat like this below. Inside the impl User block, add this:

// Function to get a user by ID from the simulated user database.
    pub fn get_user_by_id(user_id: u32, user_db: &HashMap<u32, User>) -> Option<&User>  {
        user_db.get(&user_id)
    }

    // Function to update a user's credential in the user database.
    pub fn update_user_credential(user_id: u32, new_username: &str, new_email: &str, user_db: &mut HashMap<u32, User>) -> bool {
        if let Some(user) = user_db.get_mut(&user_id) {
            user.username = new_username.to_string();
            user.email = new_email.to_string();
            true
        } else {
            false
        }
    }

    // Method to display the details of current user instance.
    pub fn display_user_details(&self){
       println!("ID: {}, Username: {}, Email: {}", self.id, self.username, self.email);
    }

The methods above are the common CRUD operations peculiar to backend systems. If you observe, you will notice that the display_user_details takes a &user parameter, while others do not. Methods can take self, &self, &mut self, or no self parameter.

  • self indicates that the method takes ownership of the instance and can modify it.

  • &self indicates that the method borrows the instance immutably, allowing read-only access.

  • &mut self indicates that the method borrows the instance mutably, allowing read-write access.

  • No self parameter means the method doesn't require access to the instance's data.

Note: If the if let Some control method looks alien to you. You will learn about it under Pattern Matching in this section.

Enums and complex data representations

Enums, short for enumerations, are a powerful Rust feature that allows you to define a custom type representing a set of distinct values. It is used to create types with a fixed set of possible values, meaning you can use them to represent different states, options, or variants. Let’s see how to define enums.

Defining Enums

Use the enum keyword to define an enum:

enum HTTPStatus {
    Ok,
    NotFound,
    BadRequest,
    InternalServerError
    // etc.
}

Enums also take the pub keyword when used in a module. The properties in an Enum can have specified data types:

pub enum ApiResponse {
    Success(String),
    Error(String),
}

This is pretty much all you need to know about enums. Let’s explore how to use enumerated data representations by pattern matching.

Pattern matching with Enums

Pattern matching with enums is essential for handling different outcomes in software systems. Let’s see how this works from our previous enum example:

fn process_response(response: ApiResponse) {
    match response {
        ApiResponse::Success(msg) => {
            println!("Success: {}", msg);
        }
        ApiResponse::Error(msg) => {
            println!("Error: {}", msg)
        }
    }
}

Complex data representation

Enums can hold more complex data, useful for representing structured information. Let’s see an example:

pub enum Request {
    Get(String),
    Post { path: String, body: String },
}

Enums and Result in error handling

We are yet to see how Error handling works in Rust, but here is just a peek at how Enums are used in handling errors:

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

Enums and Option for detecting presence and absence.

Like the Result enum, the Option enum is another fundamental construct in Rust that plays a crucial role in handling nullable values and representing the presence or absence of a value. Let's explore the Option enum and its relevance in backend engineering.

The Option Enum

The Option enum is defined in Rust's standard library and represents the presence or absence of a value. It is commonly used to handle cases where a value might be missing or is optional. The Option enum has two variants: Some (indicating the presence of a value) and None (indicating the absence of a value).

Here's the definition of the Option enum:

enum Option<T> {
    Some(T),
    None,
}

Using Option in real-world systems

In a real-world context, the Option enum is extremely valuable for handling nullable values, representing optional data, and indicating the absence of certain information. Here are some scenarios where Option is commonly used:

  1. User Authentication:

When handling user authentication, you might need to represent the absence of an authenticated user.

fn authenticate_user(username: &str, password: &str) -> Option<User> {
    // Authenticate the user and return Some(User) if successful, None otherwise
}
  1. Database Queries:

When querying a database, certain rows might not exist.

fn get_user_by_id(id: u32) -> Option<User> {
    // Retrieve user from the database; return Some(User) if found, None otherwise
}

We did this in our session management application under HashMaps above.

  1. Optional Configuration:

Backend systems often have optional configuration settings.

fn read_config(key: &str) -> Option<String> {
    // Read configuration value; return Some(value) if present, None otherwise
}

Pattern Matching with Option

Pattern matching with the Option enum is a crucial technique to extract or handle the absence of values. Here's an example:

fn main() {
    let maybe_value: Option<i32> = Some(42);

    match maybe_value {
        Some(value) => {
            println!("Got a value: {}", value);
        }
        None => {
            println!("No value found.");
        }
    }
}

Using Option and Result Together

Option and Result are often used together for comprehensive error handling. While Result indicates success or failure along with an error message, Option indicates the presence or absence of a value.

fn find_user_by_id(id: u32) -> Result<Option<User>, String> {
    // Search for the user by ID
    // Return Ok(Some(User)) if found, Ok(None) if not found, Err(error) if error occurs
}

The Option enum is a core construct in Rust that is vital for handling nullable values and representing optional data. In backend engineering, it helps manage cases where values might be missing or optional, enabling more precise and safer handling of data and outcomes.

The if let Some control flow

The if let Some control flow construct in Rust is a convenient way to handle pattern matching, specifically when you're interested in handling cases where an Option holds a Some variant. This is particularly useful when you want to extract and work with the value contained within a Some variant of an Option without having to explicitly match all possible variants of the Option.

The basic syntax of the if let Some construct is as follows:

if let Some(pattern) = some_option {
    // Code to execute if the option contains a Some value
}
  • pattern: A pattern that matches the value inside the Some variant.

  • some_option: The Option you want to match against.

Example

Let's look at a simple example to illustrate how if let Some works:

fn main() {
    let maybe_number: Option<i32> = Some(42);

    if let Some(number) = maybe_number {
        println!("Got a number: {}", number);
    } else {
        println!("No number found.");
    }
}

In this example, if maybe_number contains a Some variant with a value, the value will be bound to the variable number, and the code inside the block will execute. If maybe_number is a None variant, the else block will execute.

Benefits of if let Some

  1. Conciseness: It provides a more concise way to handle a specific case without explicitly handling all cases of an Option.

  2. Readability: It makes the code more readable by avoiding the need for a full match expression when you are only interested in one specific variant.

Nested if let Some

You can use nested if let Some constructs to handle more complex situations where multiple Option values need to be checked.

fn main() {
    let maybe_number1: Option<i32> = Some(42);
    let maybe_number2: Option<i32> = Some(99);

    if let Some(number1) = maybe_number1 {
        if let Some(number2) = maybe_number2 {
            println!("Got numbers: {} and {}", number1, number2);
        } else {
            println!("No number2 found.");
        }
    } else {
        println!("No number1 found.");
    }
}

Caveats

  • While if let Some is convenient for handling the Some variant, it doesn't provide a way to handle the None variant. Using a match expression is more appropriate if you need to handle both Some and None.

  • Using a match expression is recommended if you need to perform more complex patterns or multiple checks.

The if let Some control flow is useful for handling the specific case where an Option holds a Some variant. It simplifies code and improves readability when working with the value contained in the Some variant.

Exercise

  1. Enums and Complex Data: Design an enum to represent different types of geometric shapes (circle, square, triangle, etc.). Each enum variant should contain relevant data (radius, side length, etc.). Implement a function that calculates the area of each shape using pattern matching.

  2. Implementing Methods: Extend the String type with a method that checks whether it's a palindrome. Test this method with various strings, including palindromes and non-palindromes.

Whenever you're ready

There are 4 ways we can help you become a great backend engineer:

The MB Platform

Join 1000+ backend engineers learning backend engineering. Build real-world backend projects, learn from expert-vetted courses and roadmaps, track your learnings and set schedules, and solve backend engineering tasks, exercises, and challenges.

The MB Academy

The “MB Academy” is a 6-month intensive Advanced Backend Engineering BootCamp to produce great backend engineers.

Join Backend Weekly

If you like post like this, you will absolutely enjoy our exclusive weekly newsletter, Sharing exclusive backend engineering resources to help you become a great Backend Engineer.

Get Backend Jobs

Find over 2,000+ Tailored International Remote Backend Jobs or Reach 50,000+ backend engineers on the #1 Backend Engineering Job Board