Welcome to your next step in mastering Clean Code! 🚀 In previous lessons, we discussed the importance of meaningful naming conventions. Now, we move into the realm of functions, which are the backbone of application logic and essential for code organization and execution. Structuring these functions effectively is vital for enhancing the clarity and maintainability of a codebase. In this lesson, we will explore best practices and techniques to ensure our code remains clean, efficient, and readable, leveraging Rust's powerful features.
Below are the key principles for writing clean functions in Rust:
- Keep functions small. Small functions are easier to read, comprehend, and maintain.
- Focus on a single task (Single Responsibility Principle). A function dedicated to one task is more reliable and easier to debug.
- Limit arguments to three or fewer. Excessive arguments make function signatures complicated and functions difficult to understand and use.
- Avoid boolean flags. Boolean flags can obscure the code's purpose; consider using enums to represent different behaviors more expressively.
- Eliminate side effects. Rust's default immutability helps prevent unintended side effects, making code more predictable and easier to understand; more generally, avoid altering external state or relying on external changes.
- Implement the DRY principle. Employ helper functions and leverage traits to reduce redundancy, enhance maintainability, and enable polymorphism.
Let's dive deeper into each of these principles.
Functions should remain small. If they become too large, consider splitting them into multiple, focused functions: while there's no strict limit on lines of code, maintaining readability and manageability is key.
Consider the process_order
function below, which is manageable but has the potential to become unwieldy:
Rust1use log::{error, info}; 2 3// Not Clean - Overly Complex Function 4fn process_order(order: &mut Order, inventory: &mut Inventory) { 5 // Step 1: Validate the order 6 if !order.is_valid() { 7 error!("Invalid Order"); 8 return; 9 } 10 // Step 2: Process payment 11 if !order.process_payment() { 12 error!("Payment failed"); 13 return; 14 } 15 // Step 3: Update inventory 16 inventory.update(order.get_items()); 17 18 // Step 4: Notify customer 19 order.notify_customer(); 20 21 // Step 5: Log order processing 22 info!("Order processed successfully"); 23}
To improve this process, we can extract each step into dedicated functions and handle errors idiomatically using the Result
type and the ?
operator:
Rust1use log::{error, info}; 2 3// Clean - Using Small Functions and Idiomatic Error Handling 🌟 4 5fn process_order(order: &mut Order, inventory: &mut Inventory) -> Result<(), OrderError> { 6 validate_order(order)?; 7 process_payment(order)?; 8 update_inventory(order, inventory)?; 9 notify_customer(order)?; 10 log_order_processing()?; 11 Ok(()) 12} 13 14fn validate_order(order: &Order) -> Result<(), OrderError> { 15 if order.is_valid() { 16 Ok(()) 17 } else { 18 error!("Invalid Order"); 19 Err(OrderError::InvalidOrder) 20 } 21} 22 23fn process_payment(order: &mut Order) -> Result<(), OrderError> { 24 if order.process_payment() { 25 Ok(()) 26 } else { 27 error!("Payment failed"); 28 Err(OrderError::PaymentFailed) 29 } 30} 31 32fn update_inventory(order: &Order, inventory: &mut Inventory) -> Result<(), InventoryError> { 33 inventory.update(order.get_items()) 34} 35 36fn notify_customer(order: &Order) -> Result<(), NotificationError> { 37 order.notify_customer() 38} 39 40fn log_order_processing() -> Result<(), LogError> { 41 info!("Order processed successfully"); 42 Ok(()) 43}
By breaking down process_order
into smaller functions and using Result
with the ?
operator, we make error handling more idiomatic and the codebase cleaner. This approach also leverages Rust's powerful type system for better error management.
A function should embody the principle of doing one thing only, which is called the Single Responsibility Principle. If a function handles multiple responsibilities, it can become lengthy and harder to maintain, and you should consider reorganizing it. Let's see an example of a save_and_notify_user
function that is both too long and does multiple different tasks at once:
Rust1// Not Clean - Function with Multiple Responsibilities 2fn save_and_notify_user( 3 user: &User, 4 data_source: &DataSource, 5 web_client: &WebClient, 6) -> Result<(), Box<dyn Error>> { 7 // Save the user to the database 8 let sql = "INSERT INTO users (name, email) VALUES (?, ?)"; 9 let mut conn = data_source.get_connection()?; 10 let mut stmt = conn.prepare(sql)?; 11 stmt.execute(&[&user.name, &user.email])?; 12 13 // Notify the user via web client 14 web_client 15 .post("/sendWelcomeEmail") 16 .body(user) 17 .send()?; 18 19 Ok(()) 20}
In this snippet, the save_and_notify_user
function is handling multiple responsibilities: interacting with the database to save the user and using a web client to send a notification email. This violates the Single Responsibility Principle and makes the function cumbersome.
To improve this code, we can create two dedicated functions for saving the user and sending the welcome email. This results in clear separation of concerns and dedicated responsibilities for each function:
Rust1// Clean - Single Responsibility with Proper Error Handling 🌟 2fn save_and_notify_user( 3 user: &User, 4 data_source: &DataSource, 5 web_client: &WebClient, 6) -> Result<(), Box<dyn Error>> { 7 save_user(user, data_source)?; 8 notify_user(user, web_client)?; 9 Ok(()) 10} 11 12fn save_user(user: &User, data_source: &DataSource) -> Result<(), SaveError> { 13 let sql = "INSERT INTO users (name, email) VALUES (?, ?)"; 14 let mut conn = data_source.get_connection()?; 15 let mut stmt = conn.prepare(sql)?; 16 stmt.execute(&[&user.name, &user.email])?; 17 Ok(()) 18} 19 20fn notify_user(user: &User, web_client: &WebClient) -> Result<(), NotificationError> { 21 web_client 22 .post("/sendWelcomeEmail") 23 .body(user) 24 .send() 25 .map_err(NotificationError::SendFailed) 26}
By refactoring the code, each function now has a single responsibility: save_user
handles database operations, and notify_user
manages sending the email. This adheres to the Single Responsibility Principle, enhances code readability, and makes maintenance easier.
Keeping the number of function arguments to a minimum enhances readability and usability. Aim for no more than three parameters.
Consider the save_address
function with five arguments, which makes the function less readable:
Rust1// Not Clean - Too Many Arguments 2fn save_address(street: &str, city: &str, state: &str, zip_code: &str, country: &str) { 3 // Logic to save address 4}
A cleaner approach encapsulates the details into an Address
struct, reducing the number of arguments:
Rust1// Clean - Using a Struct to Encapsulate Data 🌟 2struct Address { 3 street: String, 4 city: String, 5 state: String, 6 zip_code: String, 7 country: String, 8} 9 10fn save_address(address: &Address) { 11 // Logic to save address 12}
Using boolean flags in functions often suggests that the function may be handling multiple responsibilities or behaviors, leading to increased complexity and reduced clarity. Instead, it's better to use enums to represent different states or actions explicitly.
Consider the set_privileges
function below, which uses a boolean flag to determine whether to grant or revoke admin rights:
Rust1// Not Clean - Using a Boolean Flag 2fn set_privileges(user: &mut User, admin: bool) { 3 if admin { 4 // Logic for granting admin rights 5 } else { 6 // Logic for removing admin rights 7 } 8}
In this example, the admin
flag controls two different behaviors within the same function. This can make the code harder to understand and maintain, as the meaning of true
or false
isn't immediately clear without additional context. A cleaner approach is to use an enum to represent the privilege action explicitly, enhancinh clarity and expressiveness:
Rust1// Clean - Using an Enum for Clarity 🌟 2enum PrivilegeAction { 3 GrantAdmin, 4 RevokeAdmin, 5} 6 7fn set_privileges(user: &mut User, action: PrivilegeAction) { 8 match action { 9 PrivilegeAction::GrantAdmin => { 10 // Logic for granting admin rights 11 } 12 PrivilegeAction::RevokeAdmin => { 13 // Logic for removing admin rights 14 } 15 } 16}
By using enums and pattern matching, we make the possible actions explicit: the function set_privileges
now takes action: PrivilegeAction
as a parameter, and the match
expression handles each case clearly. This approach eliminates the ambiguity associated with boolean flags, making the code more readable and less error-prone.
Avoid side effects by not modifying external state. Rust’s default immutability and borrowing rules help achieve this, making code more predictable and easier to debug.
Below, the add_to_total
function demonstrates a side effect by modifying external state:
Rust1// Not Clean - Side Effect 2static mut TOTAL: i32 = 0; 3 4fn add_to_total(value: i32) -> i32 { 5 unsafe { 6 TOTAL += value; 7 TOTAL 8 } 9}
A cleaner calculate_total
function avoids side effects by not altering external state:
Rust1// Clean - No Side Effect 🌟 2fn calculate_total(initial: i32, value: i32) -> i32 { 3 initial + value 4}
By returning new values instead of modifying external variables, we maintain function purity and make our code safer. Rust's default to immutability means variables are immutable unless explicitly declared with mut
, helping prevent unintended modifications.
Reduce code redundancy by introducing helper functions and leveraging traits where applicable.
Below, both print_user_info
and print_manager_info
repeat similar logic, violating the DRY principle:
Rust1// Not Clean - Repetitive Code 2fn print_user_info(user: &User) { 3 println!("Name: {}", user.name); 4 println!("Email: {}", user.email); 5} 6 7fn print_manager_info(manager: &Manager) { 8 println!("Name: {}", manager.name); 9 println!("Email: {}", manager.email); 10}
To adhere to the DRY principle, we use a Person
trait and implement it for both User
and Manager
:
Rust1// Clean - Using a Trait for Polymorphism 🌟 2trait Person { 3 fn name(&self) -> &str; 4 fn email(&self) -> &str; 5} 6 7impl Person for User { 8 fn name(&self) -> &str { 9 &self.name 10 } 11 12 fn email(&self) -> &str { 13 &self.email 14 } 15} 16 17impl Person for Manager { 18 fn name(&self) -> &str { 19 &self.name 20 } 21 22 fn email(&self) -> &str { 23 &self.email 24 } 25} 26 27fn print_info<T: Person>(person: &T) { 28 println!("Name: {}", person.name()); 29 println!("Email: {}", person.email()); 30}
By using a trait, we enable polymorphism and code abstraction, allowing us to write generalized functions that can operate on any type implementing the Person
trait. This aligns with Rust's design philosophy and promotes code reusability.
In this lesson, we explored the importance of clean functions in maintaining readable and maintainable code. By keeping functions small, adhering to the Single Responsibility Principle, limiting arguments, avoiding boolean flags, eliminating side effects, and embracing the DRY principle—while leveraging Rust's features like error handling with Result
, default immutability, traits for polymorphism, and standard logging practices—you set a strong foundation for clean coding in Rust. Next, we'll practice these principles to enhance your coding skills further! 🎓