Lesson 3
Applying Clean Code Principles in Rust: Reducing Dependencies with Ownership and Borrowing
Introduction

Welcome to the third lesson of the Applying Clean Code Principles in Rust course! In our previous lessons, we explored the significance of the DRY (Don't Repeat Yourself) principle in minimizing redundancy and the KISS (Keep It Simple, Stupid) principle for maintaining simplicity. Today, we'll delve into how Rust's unique ownership and borrowing system can help us reduce dependencies and write cleaner, more modular code. By understanding and applying these concepts, you'll be able to craft efficient and maintainable Rust programs. Let's dive in! 🦀

The Power of Ownership and Borrowing in Clean Code

Rust's ownership and borrowing system is not just about memory safety; it's a powerful tool for designing clean code with clear boundaries and minimal dependencies. By enforcing strict rules around how data is accessed and modified, Rust encourages you to write code that is modular and free from unintended side effects.

More specifically:

  • Ownership ensures that each piece of data (i.e., variable) has a single owner responsible for its lifetime, which is the scope during which the data is valid and can be accessed.
  • Borrowing allows you to access data without taking ownership, promoting data immutability and controlled mutation.

These concepts help you avoid common pitfalls like dangling pointers and data races, leading to cleaner and safer code.

Problem: Tight Coupling Without Ownership Principles

Let's look at an example where not following ownership and borrowing principles leads to tightly coupled code:

Rust
1struct Logger { 2 level: String, 3} 4 5impl Logger { 6 fn log(&self, message: &str) { 7 // Borrow `self` immutably to access `level` 8 println!("[{}] {}", self.level, message); 9 } 10} 11 12struct Application { 13 logger: Logger, // `Application` owns a `Logger` instance directly 14} 15 16impl Application { 17 fn run(&self) { 18 self.logger.log("Application is running"); 19 // Additional code... 20 } 21} 22 23fn main() { 24 let app = Application { 25 logger: Logger { 26 level: String::from("INFO"), 27 }, 28 }; 29 app.run(); 30}

What's wrong here?

  • The Application struct owns a Logger instance directly.
  • This tight coupling means Application cannot easily change logging behavior without modifying its own structure.
  • It increases dependencies and reduces flexibility.
Solution: Reducing Dependencies with Borrowing

By leveraging borrowing, we can decouple Application from Logger:

Rust
1struct Logger { 2 level: String, 3} 4 5impl Logger { 6 fn log(&self, message: &str) { 7 // Borrow `self` immutably to access `level` 8 println!("[{}] {}", self.level, message); 9 } 10} 11 12// The `'a` is a lifetime parameter indicating that `Application` cannot outlive the borrowed `Logger` 13struct Application<'a> { 14 logger: &'a Logger, // `Application` borrows `Logger` with lifetime `'a` 15} 16 17impl<'a> Application<'a> { 18 fn run(&self) { 19 // Use the borrowed `Logger` to call `log` 20 self.logger.log("Application is running"); 21 // Additional code... 22 } 23} 24 25fn main() { 26 let logger = Logger { 27 level: String::from("INFO"), 28 }; 29 let app = Application { logger: &logger }; // Pass a reference to `logger` 30 app.run(); 31}

What's been improved here?

  • Application now borrows Logger instead of owning it. This is achieved by making logger a reference to a Logger (&'a Logger) rather than owning it directly.
  • The lifetime parameter 'a specifies that Application cannot outlive the borrowed Logger, ensuring that logger remains valid as long as Application exists.
  • The use of lifetimes (<'a>) indicates that the Application struct holds a reference that is valid for a certain lifetime 'a, which ties the lifetime of Application to that of Logger. This ensures that Application does not hold references to data that has been dropped.
  • By borrowing Logger, we reduce dependency and allow different parts of the code to share the same Logger instance without transferring ownership.
  • It promotes flexibility and reusability, adhering to clean code principles.
Problem: Unnecessary Data Cloning

Cloning data when it's not necessary can lead to inefficient code with hidden dependencies.

Rust
1fn print_message(message: String) { 2 // `message` takes ownership of the passed `String` 3 println!("{}", message); 4 // `message` is dropped here 5} 6 7fn main() { 8 let msg = String::from("Hello, Rust!"); 9 print_message(msg.clone()); // Cloning `msg` to avoid moving ownership 10 // `msg` is still valid here, but we unnecessarily cloned it. 11}

In this snippet, cloning msg unnecessarily duplicates data. This can lead to performance overhead, especially with large data structures.

Solution: Borrowing Instead of Cloning

By borrowing message, we avoid unnecessary cloning and make dependencies explicit.

Rust
1fn print_message(message: &str) { 2 // `message` is borrowed immutably 3 println!("{}", message); 4 // `message` goes out of scope, but as it's a reference, no ownership is transferred 5} 6 7fn main() { 8 let msg = String::from("Hello, Rust!"); 9 print_message(&msg); // Borrow `msg` instead of cloning 10 // `msg` is still valid here without cloning. 11}

In this refactored version of the code, print_message borrows message, eliminating the need for cloning and thus reducing performance overhead while clarifying ownership. In other words, it makes the code cleaner and more efficient.

Problem: Mutable Access Without Control

Allowing unrestricted mutable access can lead to unexpected side effects and bugs.

Rust
1struct Config { 2 debug_mode: bool, 3} 4 5fn update_config(config: &mut Config) { 6 // Mutable borrow allows us to modify `config` 7 config.debug_mode = true; 8} 9 10fn main() { 11 let mut config = Config { debug_mode: false }; 12 update_config(&mut config); // Pass mutable reference to `config` 13 println!("Debug mode: {}", config.debug_mode); 14}

There are two main concerns around this code:

  • Any function with a mutable reference can alter Config, potentially introducing bugs.
  • It's hard to track where and how Config is modified.
Solution: Controlled Mutation and Encapsulation

Encapsulate mutable operations within methods to control how data is modified:

Rust
1struct Config { 2 debug_mode: bool, 3} 4 5impl Config { 6 fn enable_debug(&mut self) { 7 // Controlled mutation within the method 8 self.debug_mode = true; 9 } 10 11 fn is_debug_mode(&self) -> bool { 12 // Immutable borrow to read `debug_mode` 13 self.debug_mode 14 } 15} 16 17fn main() { 18 let mut config = Config { debug_mode: false }; 19 config.enable_debug(); // Mutate `config` through method 20 println!("Debug mode: {}", config.is_debug_mode()); 21}

How does this refactored code improve on the previous version?

  • Mutation is controlled through the enable_debug method.
  • External code cannot arbitrarily change debug_mode, reducing unintended side effects.
  • It enhances maintainability and adheres to clean code practices.
Applying Ownership to Manage Resources

Ownership helps manage resources like file handles or network connections, ensuring they're properly released.

Rust
1use std::fs::File; 2 3fn main() { 4 let file = File::open("example.txt").expect("Failed to open file"); 5 // `file` owns the `File` resource 6 // Do something with the file 7 // `file` is dropped here, and the file handle is closed automatically 8}

In this code, file owns the File resource, meaning that when file goes out of scope, the File is automatically closed. This eliminates the need for manual resource management, reducing errors and dependencies.

Problem: Extensive Use of Global State

Using global mutable state increases dependencies and makes code harder to reason about:

Rust
1static mut COUNTER: i32 = 0; 2 3fn increment() { 4 unsafe { 5 COUNTER += 1; // Unsafe mutable access to global variable 6 } 7} 8 9fn main() { 10 increment(); 11 unsafe { 12 println!("Counter: {}", COUNTER); // Unsafe read of global variable 13 } 14}

Here, mutable global state (COUNTER) can be modified from anywhere, leading to unpredictable behavior. To solve this, unsafe blocks are required, increasing the risk of bugs.

Solution: Use Local State and Pass as Needed

Encapsulate state within structures and pass them where needed.

Rust
1struct Counter { 2 value: i32, 3} 4 5impl Counter { 6 fn increment(&mut self) { 7 // Mutable borrow of `self` to modify `value` 8 self.value += 1; 9 } 10 11 fn get(&self) -> i32 { 12 // Immutable borrow of `self` to read `value` 13 self.value 14 } 15} 16 17fn main() { 18 let mut counter = Counter { value: 0 }; 19 counter.increment(); // Mutate `counter` through method 20 println!("Counter: {}", counter.get()); 21}

In this refactored version, Counter manages its own state, reducing global dependencies. No unsafe code is needed, and the code is easier to test and maintain.

Summary and Next Steps

In this lesson, we've seen how Rust's ownership and borrowing system can be leveraged to write clean, modular code with minimal dependencies. By:

  • Reducing Tight Coupling: Borrowing instead of owning when appropriate to decouple components.
  • Avoiding Unnecessary Cloning: Borrowing data to prevent performance overhead and hidden dependencies.
  • Controlling Mutability: Using methods and encapsulation to manage how and when data can be changed.
  • Managing Resources Safely: Relying on ownership to handle resource cleanup automatically.

By applying these principles, you not only adhere to Rust's safety guarantees but also follow clean code practices that make your programs more maintainable and robust.

As you proceed to the practice exercises, challenge yourself to identify areas where ownership and borrowing can help reduce dependencies in your own code. Embrace Rust's unique features to write code that is both safe and clean. Happy coding! 🌟

Enjoy this lesson? Now it's time to practice with Cosmo!
Practice is how you turn knowledge into actual skills.