Skip to content

Latest commit

 

History

History

2_3_bound_impl

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

Step 2.3: Bound behavior, not data

Estimated time: 1 day

Often, when we want to abstract over some type or behavior in Rust we are going from this:

struct UserService {
    repo: UserRepo,
}

to this:

struct UserService<R: UserRepo> {
    repo: R,
}

We specify R: UserRepo bound here as we want to restrict types in repo field to implement UserRepo behavior.

However, such restriction directly on a type leads to what is called "trait bounds pollution": we have to repeat this bound in every single impl, even in those ones, which has no relation to UserRepo behavior at all.

struct UserService<R: UserRepo> {
    repo: R,
}

impl<R> Display for UserService<R>
where
    R: Display + UserRepo, // <- We are not interested in UserRepo here,
{                          //    all we need is just Display.
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "UserService with repo {}", self.repo)
    }
}

In a complex codebase such pollution multiplies from different types and may become a nightmare at some point.

The solution to this problem would be to understand that a trait represents a certain behavior, and, in reality, we need that behavior only when we're declaring one. Type declaration has nothing about behavior, it's all about data. It's functions and methods where behavior happens. So, let's just expect certain behavior when we really need this:

struct UserService<R> {
    repo: R,
}

// Expect Display when we expressing Display behavior.
impl<R: Display> Display for UserService<R> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "UserService with repo {}", self.repo)
    }
}

// Expect UserRepo when we expressing actual UserService behavior,
// which deals with Users.
impl<R: UserRepo> UserService<R> {
    fn activate(&self, user: User) {
        // Changing User state in UserRepo...
    }
}

Placing trait bounds on impl blocks, methods and functions, rather than on types, reduces the trait bounds pollution, lowers coupling of code parts and makes generic code more clean, straightforward and ergonomic.

Lift unnecessary bounds

As a more general rule: you should try to lift trait bounds as much as possible (especially in a library code), as it enlarges a variety of usages for a type.

Sometimes this requires to omit using #[derive] as this may impose unnecessary trait bound. For example:

#[derive(Clone)]
struct Loader<K, V> {
    state: Arc<Mutex<State<K, V>>>,
}

struct My;

let loader: Loader<My, My> = ..;
let copy = loader.clone(); // compile error as `My` doesn't impl `Clone`

This happens because #[derive(Clone)] applies K: Clone and V: Clone bounds in the derived code, despite the fact that they are not necessary at all, as Arc always implements Clone (also, consider T: ?Sized bound in the linked implementation, which lifts implicit T: Sized bound, so allows to use Arc::clone() even for unsized types too).

By providing hand-baked implementation we are able to clone values of Loader<My, My> type without any problems:

struct Loader<K, V> {
    state: Arc<Mutex<State<K, V>>>,
}

// Manual implementation is used to omit applying unnecessary Clone bounds.
impl<K, V> Clone for Loader<K, V> {
    fn clone(&self) -> Self {
        Self {
            state: self.state.clone(),
        }
    }
}

let loader: Loader<My, My> = ..;
let copy = loader.clone(); // it compiles now!

Task

Refactor the code contained in this step's crate to reduce trait bounds pollution as much as possible.