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
}
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 + ?SizedBox<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()
}
}
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);
}
}
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);
}
Output:
Length: 12
Uppercased: HELLO, RUST!
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);
}
Output:
Length: 12
Uppercased: HELLO, RUST!
AsRef: Hello, Rust!
Converted back: Hello, Rust!
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);
}
Output:
Contains 'hello': true
Contains 'rust': false
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);
}
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);
}
-
containsis typed in terms ofBorrow, soAsRef<str>does not participate - Even if you wrote a helper that converted via
AsRefand then looked up somehow, nothing in the type system would forceCustomType'sHash/Eqto matchstr'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);
}
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
Titself canBorrow<T>, soTcan be used directly as an argument toBorrow<T> -
&Tcan alsoBorrow<T>, which lets an immutable reference satisfy aBorrow<T>bound -
&mut Tcan alsoBorrow<T>, which lets a mutable reference satisfy aBorrow<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)
}
The convenience provided by Borrow<T> is as follows:
-
Stringcan be used asstr'sBorrow<T>implementation, soHashMap<String, i32>can be queried using&stras the key -
find_item(&map, "hello")passes&strdirectly without converting it toString -
find_item(&map, &"hello".to_string())also works because&Stringalso satisfiesBorrow<str>
Top comments (0)