Rust Concurrency Explained: Cleaning Code with Traits and Simplifying Services

ietxaniz

Iñigo Etxaniz

Posted on November 21, 2023

Rust Concurrency Explained: Cleaning Code with Traits and Simplifying Services

Rust Concurrency: Cleaning Code with Traits and Simplifying Services

Introduction

As I continue my journey with Rust, I've found that revisiting and reviewing my code is not just a best practice but a learning tool in itself. After getting my code to work, I like to take a step back and ponder if there are ways to enhance its readability and efficiency. This reflective practice often leads to significant improvements, making my code cleaner and more understandable.

Recently, I revisited the initial commit of my project, rust_service_example (link to first commit on GitHub). While reviewing, I noticed some areas that didn't quite sit right with me. For instance, the process of read-locking and write-locking was taking up more lines than necessary, creating repetitive patterns that I think will cumbersome during code reviews. This realization led me to explore how traits could streamline these aspects, which I'll delve into later in this article.

Another area I've been questioning is my approach to error handling. Initially, I transitioned from returning a String describing the error to returning Box<dyn Error>, which seemed like a step in the right direction. However, I'm now experimenting with the anyhow crate, seeking a more refined method, although I'm still evaluating if it's the best fit.

Lastly, I made a small change in the main code. By changing the field types in my Service struct from RwLock<T> to Arc<RwLock<T>>, I was able to implement the Clone trait. This change has simplified the code in main.rs, making it more approachable, especially from a beginner's perspective.

For those interested in seeing the code and its evolution, feel free to explore the repository on GitHub.

In this article, I'll share these refinements and discuss how they contribute to better, cleaner Rust code. Join me as I navigate through these improvements, and let's learn together how small changes can make a big difference.

Refining Error Handling

In my journey of transitioning from Go to Rust, I've come to realize the nuances in how both languages approach error handling. In my previous articles, I drew parallels between Rust and Go in terms of verbosity and explicitness in managing errors. However, with deeper exploration, I've discovered some key differences and improvements in my Rust coding practice, particularly regarding error handling.

Revisiting Rust vs. Go in Error Handling

In Go, the convention of functions returning both a value and an error is a pattern I've always appreciated for its clarity. This approach makes error handling an integral and explicit part of the function's contract. Initially, I viewed Rust's error handling through a similar lens, especially when using match statements to handle Result types.

Rust, however, offers a more streamlined approach with the ? operator. This operator allows for elegant error propagation, reducing the verbosity that comes with manual error checking. Unlike the explicit handling in Go, Rust's ? operator simplifies the code, making it less verbose while still maintaining clarity and predictability.

The Shift to anyhow in Rust Error Handling

My initial foray into Rust error handling involved returning strings as errors. While this method was straightforward, it wasn't in line with Rust's standard practices. I soon learned that a more idiomatic approach in Rust is to return Box<dyn Error>. This standard has its advantages, primarily its flexibility and compatibility with Rust's error propagation mechanisms.

Why Box<dyn Error>?

In Rust, Box<dyn Error> is commonly used for error handling, similar to how functions in Go often return an error type. Just like in Go, where a function can return a result and an error, Box<dyn Error> in Rust provides a dynamic way to handle various error types uniformly. This approach is particularly beneficial in Rust because of the language's ? operator, which enables seamless error propagation.

Using Box<dyn Error> in Rust is akin to Go's error return type in that it allows for a flexible response to different error scenarios. However, Rust's ? operator simplifies error handling further. By adding ? at the end of an operation, Rust can elegantly handle errors without the verbosity typical in Go, where each error requires explicit checking and handling.

The Challenge of Mixed Error Types

However, a challenge arises when there's a mix of error types being returned - sometimes a string, other times a boxed error. This inconsistency necessitates additional code for handling and converting errors, leading to a less streamlined and more verbose approach.

Embracing anyhow for Streamlined Error Handling

Recognizing this, I turned to the anyhow library for a more unified and efficient error handling strategy. anyhow simplifies the process by allowing the use of a single error type across the codebase. This aligns well with Rust's philosophy of concise and clear error handling while providing the flexibility to handle a wide range of error scenarios.

Using anyhow, I can write code that's consistent in its error handling approach, enhancing readability and maintainability. Moreover, anyhow integrates seamlessly with Rust's ? operator, further reducing verbosity and complexity.

The Importance of Consistency and Adaptability

In conclusion, the key takeaway from my experience with error handling is the importance of consistency. Whether it's using Box<dyn Error>, anyhow, or another method, sticking to a consistent approach is crucial. It's also essential to remain open to refining your error handling strategy as you gain more insight into Rust's best practices and the tools available. This flexibility allows for continuous improvement in writing more effective and idiomatic Rust code.

Practical Benefits of Using anyhow

Implementing anyhow in my Rust projects has brought several advantages:

  • Simplified Error Propagation: With anyhow, I can easily return errors from functions without worrying about their specific types. The ? operator works seamlessly with anyhow::Error, further reducing boilerplate code.

  • Enhanced Readability: The use of a single error type declutters the code, making it more readable and maintainable. It's easier to understand and handle errors when they're consistently represented.

Continuous Learning and Refinement

This exploration into Rust's error handling is a testament to the ongoing learning process in programming. What seemed like an established understanding can evolve as you delve deeper into a language's features and best practices. By embracing libraries like anyhow and utilizing Rust-specific features like the ? operator, I'm refining my approach to writing more idiomatic and efficient Rust code.

Refactoring the Service Struct for Simplified Usage

In the process of evolving the Rust codebase, a significant refactor was made to the Service struct. This change not only streamlined the code but also enhanced its readability and maintainability. Let's explore the transformation of the Service struct and how it positively impacted the usage in main.rs.

Original Service Struct

Initially, the Service struct was defined as follows:

pub struct Service {
    count: std::sync::RwLock<u64>,
    count_thread1: std::sync::RwLock<u64>,
    count_thread2: std::sync::RwLock<u64>,
    write_lock: std::sync::RwLock<u8>,
}
Enter fullscreen mode Exit fullscreen mode

This structure effectively managed the state using Rust's RwLock for thread-safe mutable access. However, when it came to sharing Service instances across threads, the usage in main.rs required explicit handling of Arc (Atomic Reference Counting pointers) to manage the shared ownership.

The Refactored Service Struct

To simplify this pattern, the Service struct was refactored as follows:

#[derive(Clone)]
pub struct Service {
    count: Arc<RwLock<u64>>,
    count_thread1: Arc<RwLock<u64>>,
    count_thread2: Arc<RwLock<u64>>,
    write_lock: Arc<RwLock<u8>>,
}
Enter fullscreen mode Exit fullscreen mode

By incorporating Arc<RwLock<T>> directly into the struct and deriving the Clone trait, the Service struct became more ergonomic to use. This change abstracts away the explicit handling of Arc, making the struct more straightforward to clone and share across threads.

Simplified Usage in main.rs

Before the Refactor

Originally, sharing an instance of Service across threads required explicitly creating an Arc and then cloning it:

let service = Arc::new(Service::new());

let service1 = Arc::clone(&service);
let service2 = Arc::clone(&service);
Enter fullscreen mode Exit fullscreen mode

After the Refactor

With the refactored Service struct, the code in main.rs becomes cleaner:

let service = Service::new();

let service1 = service.clone();
let service2 = service.clone();
Enter fullscreen mode Exit fullscreen mode

This refactoring makes the code more intuitive and straightforward. The cloning of the service instance is now a simple method call, enhancing the overall readability.

Using Traits for Code Simplification and Cleaning

One of the most powerful features of Rust is its trait system, which allows for code abstraction and reuse in a way that's both efficient and elegant. In this part of the article, I'll demonstrate how we can leverage traits to simplify and clean up our code. Our goal is to transform a common pattern in our codebase into a more concise and readable form, without altering the application's functionality.

The Objective: Streamlining Lock Operations

Consider this snippet from our current codebase:

let count = *self
            .count
            .read()
            .map_err(|e| format!("Failed to read-lock count: {}", e))?;
Enter fullscreen mode Exit fullscreen mode

Here, we're acquiring a read lock on a resource and handling potential errors. While this code is functional, it's also somewhat verbose and repetitive, especially if similar patterns are used throughout the application.

Now, let's look at how we can streamline this with the help of a custom trait:

let count = *self.count.lock_read("count")?;
Enter fullscreen mode Exit fullscreen mode

With this new approach, the code becomes much more succinct and clear. The error handling is still there, but it's abstracted away by our trait, making the main logic easier to read and maintain.

Simplifying Lock Operations with the LockExt Trait in Rust

In Rust, managing locks, especially with RwLock, can often involve verbose and repetitive error handling. To address this, let's explore the LockExt trait, a solution that streamlines these operations. This trait is a great example of how Rust's powerful trait system can be used to enhance code readability and efficiency.

Here's the code for the LockExt trait:

use anyhow::{anyhow, Error};
use std::sync::{Arc, RwLock};
use std::sync::{RwLockReadGuard, RwLockWriteGuard};

pub trait LockExt<T> {
    fn lock_write(&self, name: &str) -> Result<RwLockWriteGuard<T>, Error>;
    fn lock_read(&self, name: &str) -> Result<RwLockReadGuard<T>, Error>;
}

impl<T> LockExt<T> for Arc<RwLock<T>> {
    fn lock_write(&self, name: &str) -> Result<RwLockWriteGuard<T>, Error> {
        self.write()
            .map_err(|e| anyhow!("Failed to write-lock {}: {}", name, e))
    }

    fn lock_read(&self, name: &str) -> Result<RwLockReadGuard<T>, Error> {
        self.read()
            .map_err(|e| anyhow!("Failed to read-lock {}: {}", name, e))
    }
}
Enter fullscreen mode Exit fullscreen mode

This trait abstracts the process of acquiring read and write locks, simplifying error handling. Instead of writing lengthy error handling every time a lock is acquired, the LockExt trait encapsulates this in two concise methods: lock_write and lock_read. And allows the simplification we where looking for at the beginning of this section.

Emphasizing the Choice of anyhow in the LockExt Trait

Having previously discussed the nuances of error handling in Rust and the advantages of utilizing libraries like anyhow, the implementation of the LockExt trait further exemplifies why anyhow was the preferred choice. This trait demonstrates the practical application of anyhow in a real-world scenario, highlighting its benefits in streamlining error handling.

Simplified Error Handling with anyhow

Consider the lock_write function using anyhow:

use anyhow::{Result, anyhow};
use std::sync::{RwLockWriteGuard, RwLock};

fn lock_write<T>(&self, lock: &RwLock<T>, name: &str) -> Result<RwLockWriteGuard<T>> {
    lock.write()
        .map_err(|e| anyhow!("Failed to write-lock {}: {}", name, e))
}
Enter fullscreen mode Exit fullscreen mode

This implementation leverages anyhow for its concise and expressive error reporting. The anyhow! macro enables quick conversion of errors into an anyhow::Error, complete with a descriptive message. This approach significantly reduces boilerplate and enhances readability.

Contrast this with the more traditional approach without anyhow:

use std::sync::{RwLockWriteGuard, RwLock};
use std::error::Error;
use std::fmt;

struct MyError {
    details: String,
}

// Implementations for MyError...

fn lock_write<T>(&self, lock: &RwLock<T>, name: &str) -> Result<RwLockWriteGuard<T>, Box<dyn Error>> {
    lock.write()
        .map_err(|e| Box::new(MyError::new(&format!("Failed to write-lock {}: {}", name, e))) as Box<dyn Error>)
}
Enter fullscreen mode Exit fullscreen mode

Here, the necessity of a custom error type (MyError) and the additional code for error management increases the complexity, making the function more verbose.

Conclusion

Consistent Error Handling with anyhow

The transition to using anyhow for error handling in Rust represents more than just a technical refinement; it signifies an embrace of idiomatic Rust practices that prioritize clarity, brevity, and robustness. This shift not only made the error handling in my codebase more consistent but also underscored the importance of adaptability and continuous learning in software development. As Rust continues to evolve, so too should our approaches to coding within its ecosystem. The adoption of anyhow is a testament to this philosophy, showcasing a commitment to write code that is not only functional but also clean and maintainable.

The Impact of Refactoring Service Struct

Embedding Arc directly into the Service struct and leveraging the Clone trait proved to be a subtle yet impactful change. This refactoring underscores the philosophy of smart struct design in Rust—where the focus is not just on the functionality but also on how the structure of the code can lead to more intuitive and elegant usage patterns. By making these adjustments, the Service struct became more aligned with Rust's design principles, offering a more streamlined and idiomatic way of handling shared state in concurrent environments.

Leveraging Traits for Code Simplification

The implementation of the LockExt trait is a prime example of Rust's powerful trait system at work. It highlights how traits can be used not just for defining shared behavior, but also for simplifying and cleaning up code. This approach is particularly useful in Rust, where managing complexity is key to writing effective programs. By abstracting repetitive patterns into a trait, the codebase becomes more organized, allowing for easier maintenance and future enhancements. The LockExt trait, therefore, is not just a utility but a representation of the Rust philosophy of making code more modular and expressive.

💖 💪 🙅 🚩
ietxaniz
Iñigo Etxaniz

Posted on November 21, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related