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:
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
}
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.
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 theSome
variant.some_option
: TheOption
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
Conciseness: It provides a more concise way to handle a specific case without explicitly handling all cases of an
Option
.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 theSome
variant, it doesn't provide a way to handle theNone
variant. Using amatch
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
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.
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.