DEV Community

Cover image for [Advanced Rust] 2.4. API Design Principles of Unsurprising Pt.4 - Ergonomic Trait Implementations, Wrapper Types, and Borrow Tr…
SomeB1oody
SomeB1oody

Posted on

[Advanced Rust] 2.4. API Design Principles of Unsurprising Pt.4 - Ergonomic Trait Implementations, Wrapper Types, and Borrow Tr…

Full title: [Advanced Rust] 2.4. API Design Principles of Unsurprising Pt.4 - Ergonomic Trait Implementations, Wrapper Types, and Borrow Trait

2.4.1. Ergonomic Trait Implementations

Rust does not automatically provide implementations for references to a type that implements a given trait.

For example, if Bar implements Trait, you still cannot pass &Bar to fn foo<T: Trait>(t: T). That is because implementing Trait for Bar does not automatically implement Trait for &Bar.

Example:

trait Trait {
    fn name(&self) -> &'static str;
}

struct Bar;

impl Trait for Bar {
    fn name(&self) -> &'static str {
        "Bar"
    }
}

fn foo<T: Trait>(t: T) {
    println!("{}", t.name());
}

fn main() {
    let bar = Bar;
    foo(bar); // OK

    let bar_ref = &Bar;
    foo(bar_ref); // error[E0277]: the trait bound `&Bar: Trait` is not satisfied
}
Enter fullscreen mode Exit fullscreen mode

If a user sees that a trait method only accepts &self (and not self or &mut self), they may still be surprised that &Bar does not satisfy T: Trait. That does not satisfy the unsurprising principle.

To solve this, when defining a new trait, we usually provide corresponding blanket implementations for the following (when the trait methods allow it—typically methods that take &self or &mut self):

  • &T where T: Trait + ?Sized
  • &mut T where T: Trait + ?Sized
  • Box<T> where T: Trait + ?Sized

Continuing the example above, to prevent foo(bar_ref); from failing, we need to manually provide a Trait implementation for &T:

impl<T: Trait + ?Sized> Trait for &T {
    fn name(&self) -> &'static str {
        (**self).name()
    }
}
Enter fullscreen mode Exit fullscreen mode

Note: if a trait method takes self by value (consuming ownership), you generally cannot provide a blanket impl Trait for &T that forwards to T, because a shared reference cannot move out of T.

For iterators, if a type can be iterated, then its references should also provide the corresponding trait implementations. In other words: for any iterable type, consider implementing IntoIterator for &MyType and &mut MyType. That way, we can use borrowed values directly in loops, which matches user expectations.

Example:

struct MyCollection {
    items: Vec<i32>,
}

// Implement IntoIterator for MyCollection
impl IntoIterator for MyCollection {
    type Item = i32;
    type IntoIter = std::vec::IntoIter<Self::Item>;

    fn into_iter(self) -> Self::IntoIter {
        self.items.into_iter()
    }
}

// Implement IntoIterator for &MyCollection
impl<'a> IntoIterator for &'a MyCollection {
    type Item = &'a i32;
    type IntoIter = std::slice::Iter<'a, i32>;

    fn into_iter(self) -> Self::IntoIter {
        self.items.iter()
    }
}

// Implement IntoIterator for &mut MyCollection
impl<'a> IntoIterator for &'a mut MyCollection {
    type Item = &'a mut i32;
    type IntoIter = std::slice::IterMut<'a, i32>;

    fn into_iter(self) -> Self::IntoIter {
        self.items.iter_mut()
    }
}

fn main() {
    let mut collection = MyCollection { items: vec![1, 2, 3] };

    // Iterate by taking ownership
    for item in collection {
        println!("Owned: {}", item);
    }

    let collection = MyCollection { items: vec![4, 5, 6] };

    // Iterate by immutable borrow
    for item in &collection {
        println!("Borrowed: {}", item);
    }

    let mut collection = MyCollection { items: vec![7, 8, 9] };

    // Iterate by mutable borrow
    for item in &mut collection {
        *item *= 2;
    }

    // Make sure the modification took effect
    for item in &collection {
        println!("Modified: {}", item);
    }
}
Enter fullscreen mode Exit fullscreen mode

2.4.2. Wrapper Types

Rust does not have inheritance in the traditional object-oriented sense, but Deref and AsRef provide something similar.

For example, if you have a value of type T and it satisfies Deref<Target = U>, then you can directly call methods from U on a value of type T.

Example:

use std::ops::Deref;

// Define a wrapper type Wrapper that stores a String internally
struct Wrapper(String);

// Implement Deref so that Wrapper dereferences to String
impl Deref for Wrapper {
    type Target = String;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

fn main() {
    let my_wrapper = Wrapper(String::from("Hello, Rust!"));

    // Because Wrapper implements Deref<Target = String>,
    // we can call String methods directly without manual dereferencing
    let len = my_wrapper.len();
    let uppercased = my_wrapper.to_uppercase();

    println!("Length: {}", len);
    println!("Uppercased: {}", uppercased);
}
Enter fullscreen mode Exit fullscreen mode

Output:

Length: 12
Uppercased: HELLO, RUST!
Enter fullscreen mode Exit fullscreen mode

If you provide a relatively transparent type such as Arc<T>, then implementing Deref lets your wrapper type automatically dereference to the inner type at the point of use, so its methods can be called directly.

If accessing the inner type does not require any complicated or potentially inefficient logic, you should consider implementing AsRef, so users can easily use &WrapperType as &InnerType.

For most wrapper types, you should also implement From<InnerType> for the wrapper and From<Wrapper> for the inner type where possible (which also gives you Into for free), so users can easily add or remove the wrapper.

Example:

use std::ops::Deref;
use std::sync::Arc;

// Define a wrapper type
struct Wrapper(Arc<String>);

// Implement Deref to allow transparent access to the inner String
impl Deref for Wrapper {
    type Target = String;

    fn deref(&self) -> &Self::Target {
        &self.0
    }
}

// Implement AsRef<String> so users can obtain a `&String`
impl AsRef<String> for Wrapper {
    fn as_ref(&self) -> &String {
        &self.0
    }
}

// Implement From<String> so users can easily create a Wrapper
impl From<String> for Wrapper {
    fn from(s: String) -> Self {
        Wrapper(Arc::new(s))
    }
}

// Implement From<Wrapper> for String so users can convert the Wrapper back into a String (by cloning)
impl From<Wrapper> for String {
    fn from(w: Wrapper) -> Self {
        (*w).clone() // Deref allows Wrapper to be used as if it were a String
    }
}

fn main() {
    let wrapped = Wrapper::from("Hello, Rust!".to_string());

    // Because Deref is implemented, we can call String methods directly
    println!("Length: {}", wrapped.len());
    println!("Uppercased: {}", wrapped.to_uppercase());

    // Use AsRef to obtain a &String reference
    let str_ref: &String = wrapped.as_ref();
    println!("AsRef: {}", str_ref);

    // Convert back to String through Into (cloning the string)
    let original: String = wrapped.into();
    println!("Converted back: {}", original);
}
Enter fullscreen mode Exit fullscreen mode

Output:

Length: 12
Uppercased: HELLO, RUST!
AsRef: Hello, Rust!
Converted back: Hello, Rust!
Enter fullscreen mode Exit fullscreen mode

2.4.3. Borrow Trait

The Borrow trait is somewhat similar to Deref and AsRef, but it is aimed at a narrower use case and is more specialized.

The Borrow trait allows callers to provide any of several essentially identical variants of a unified type. These variants are called equivalents.

Note: The Borrow trait should only be used when your type is essentially equivalent to another type. In other words, Borrow is for “equivalent” cases, while AsRef and Deref are for “acts as” cases.

For example, for a HashSet<String>, Borrow allows callers to provide &str or &String.

Example:

use std::collections::HashSet;

fn main() {
    let mut set: HashSet<String> = HashSet::new();
    set.insert("hello".to_string());
    set.insert("world".to_string());

    // Query directly with &str without creating a String.
    // This works because the standard library already provides `impl Borrow<str> for String`.
    let exists = set.contains("hello");
    let not_exists = set.contains("rust");

    println!("Contains 'hello': {}", exists);
    println!("Contains 'rust': {}", not_exists);
}
Enter fullscreen mode Exit fullscreen mode

Output:

Contains 'hello': true
Contains 'rust': false
Enter fullscreen mode Exit fullscreen mode

Comparison with AsRef

Of course, the same effect as above can also be achieved with AsRef:

use std::collections::HashSet;

// Generic function that accepts any type implementing `AsRef<str>`, such as `&str` and `&String`
fn contains<S: AsRef<str>>(set: &HashSet<String>, value: S) -> bool {
    set.contains(value.as_ref()) // `AsRef<str>` converts `value` to `&str`
}

fn main() {
    let mut set: HashSet<String> = HashSet::new();
    set.insert("hello".to_string());
    set.insert("world".to_string());

    // Query directly with &str
    let exists = contains(&set, "hello");

    // You can also query with &String
    let string_value = "world".to_string();
    let exists_string = contains(&set, &string_value);

    println!("Contains 'hello': {}", exists);
    println!("Contains 'world': {}", exists_string);
}
Enter fullscreen mode Exit fullscreen mode

Using AsRef can achieve the same result, but without the extra requirements of Borrow, this implementation is unsafe for hash-table lookup, because Borrow requires the Hash, Eq, and Ord implementations of the borrowed form to match those of the owned type.

The potential problem is that AsRef<U> does not require (even as documentation) consistency of Hash, Eq, and Ord between the source type and U.

For example:

use std::collections::HashSet;

#[derive(Hash, Eq, PartialEq)]
struct CustomType {
    value: String,
}

// Implement AsRef<str>, but that alone does not make HashSet lookup with &str legal
impl AsRef<str> for CustomType {
    fn as_ref(&self) -> &str {
        &self.value
    }
}

fn main() {
    let mut set: HashSet<CustomType> = HashSet::new();
    set.insert(CustomType { value: "hello".to_string() });

    // This will not compile (error[E0308]): `contains` is keyed on `Borrow`, not `AsRef`,
    // so `&str` does not match without `CustomType: Borrow<str>`
    let exists = set.contains("hello");
    println!("Exists: {}", exists);
}
Enter fullscreen mode Exit fullscreen mode
  • contains is typed in terms of Borrow, so AsRef<str> does not participate
  • Even if you wrote a helper that converted via AsRef and then looked up somehow, nothing in the type system would force CustomType's Hash/Eq to match str's

By contrast, Borrow<U> is the trait HashMap/HashSet use for lookup, and its documentation requires Hash, Eq, and Ord to remain consistent between the type and the borrowed form (the compiler does not prove this; implementors must uphold it):

use std::borrow::Borrow;
use std::collections::HashSet;

#[derive(Hash, Eq, PartialEq)]
struct CustomType {
    value: String,
}

// `Borrow<str>` is what enables `contains("hello")`, and you must keep Hash/Eq aligned with `str`
impl Borrow<str> for CustomType {
    fn borrow(&self) -> &str {
        &self.value
    }
}

fn main() {
    let mut set: HashSet<CustomType> = HashSet::new();
    set.insert(CustomType { value: "hello".to_string() });

    let exists = set.contains("hello"); // safe lookup if Hash/Eq match str
    println!("Exists: {}", exists);
}
Enter fullscreen mode Exit fullscreen mode

Other Traits

Borrow also has blanket implementations for Borrow<T>, &T, and &mut T. This makes it convenient to use in trait bounds when you want to accept owned values or references of a given type.

The Rust standard library provides the following blanket implementations of Borrow<T> for all T, which means:

  • The type T itself can Borrow<T>, so T can be used directly as an argument to Borrow<T>
  • &T can also Borrow<T>, which lets an immutable reference satisfy a Borrow<T> bound
  • &mut T can also Borrow<T>, which lets a mutable reference satisfy a Borrow<T> bound

Suppose we have a find_item function that looks up a key in a HashMap<K, V>:

use std::borrow::Borrow;
use std::collections::HashMap;
use std::hash::Hash;

fn find_item<'a, K, V, Q>(map: &'a HashMap<K, V>, key: &Q) -> Option<&'a V>
where
    K: Eq + Hash + Borrow<Q>,
    Q: ?Sized + Eq + Hash,
{
    map.get(key)
}

fn main() {
    let mut map: HashMap<String, i32> = HashMap::new();
    map.insert("hello".to_string(), 42);

    // Because `String: Borrow<str>`, we can use `&str` directly to query `HashMap<String, i32>`
    let value = find_item(&map, "hello");

    println!("Value: {:?}", value); // Output: Value: Some(42)
}
Enter fullscreen mode Exit fullscreen mode

The convenience provided by Borrow<T> is as follows:

  • String can be used as str's Borrow<T> implementation, so HashMap<String, i32> can be queried using &str as the key
  • find_item(&map, "hello") passes &str directly without converting it to String
  • find_item(&map, &"hello".to_string()) also works because &String also satisfies Borrow<str>

Top comments (0)