2.5.1. Code Contracts
Your code, whether explicitly or implicitly, contains a contract.
A contract has two sides:
- A contract is a requirement, which is a restriction on how the code is used
- A contract is a promise, which is a guarantee about how the code behaves
When designing APIs, there is a useful rule of thumb: avoid imposing unnecessary restrictions, and only make promises you can keep.
Why?
- Adding restrictions or removing promises requires a major semantic version change and may break other code
- When you first design an API, loosening restrictions and later adding extra promises is usually backward-compatible
2.5.2. Restrictions and Promises
Common forms of restrictions in Rust are:
- Trait bounds
- Argument types
Common forms of promises are:
- Trait implementations
- Return types
Some Examples
Let's look at an API evolving through three versions:
fn frobnicate(s: String) -> String
- The first version takes a
Stringand returns aString - Its contract is that the caller performs allocation (because both the parameter and return value are owned, allocation is inevitable), and its promise is that it returns an owned
String - The problem with this function is that, without changing the signature, it cannot later be turned into a “no-allocation” function, because both the argument and return value are owned
fn frobnicate(s: &str) -> Cow<'_, str>
- The second version relaxes the contract a bit
- Its contract is that it accepts only a string reference, and its promise is that it returns either a string reference or an owned
String, namely theCowtype - This version is still somewhat rigid. For example, the argument is
&str; if I pass in aString, I still have to convert it first. Also, because the return value isCow, it cannot return string-owning types other thanStringand&str(for example,OsString)
fn frobnicate<T: AsRef<str>>(s: T) -> T
- The third version relaxes the contract further
- Now both the parameter and the return value only require a type that implements
AsRef<str>, that is, a type that can produce a string reference
These three functions all take a string and return a string; the only difference is the contract. None of them is better or worse than the others, only stricter or looser. When designing an API, carefully plan the contract, because changing it will cause breaking changes.
Let's look at the full example:
use std::borrow::Cow;
fn frobnicate<T: AsRef<str>>(s: T) -> T {
s
}
fn main() {
let string: String = String::from("example");
let borrowed: &str = "hello";
let cow: Cow<str> = Cow::Borrowed("world");
let result1: &str = frobnicate::<&str>(string.as_ref());
let result2: &str = frobnicate::<&str>(borrowed);
let result3: Cow<str> = frobnicate(cow);
println!("Result1: {:?}", result1);
println!("Result2: {:?}", result2);
println!("Result3: {:?}", result3);
}
- Whether it is
String,&str, orCow<str>(which is essentially also&str), this function can accept it (Stringneeds to be converted to&strfirst withas_ref) and return a value (the return value can also be a different type)
Output:
Result1: "example"
Result2: "hello"
Result3: "world"
2.5.3. Use Generic Parameters to Make Interfaces More Flexible
We can loosen function requirements by using generics. In most cases, it is worthwhile to use generics instead of concrete types.
Example of Using Generic Parameters
Example:
fn print_as_str<T: AsRef<str>>(s: T) {
println!("{}", s.as_ref());
}
fn main() {
let s: String = String::from("hello");
let r: &str = "world";
print_as_str(s); // calls `print_as_str::<String>`
print_as_str(r); // calls `print_as_str::<&str>`
}
- The
print_as_strfunction accepts a parameter that implementsAsRef<str> - This function is generic, which means it is monomorphized for every type that implements
AsRef<str>that you use with it. For example, if you call it with aStringand a&str, you will have two copies of the function in your binary,print_as_str::<String>andprint_as_str::<&str>, and each call will invoke the corresponding function
Note: the advantage of monomorphization is that it avoids runtime overhead, while the drawback is that the compiler generates one function for each input type, increasing binary size.
If you do not want multiple copies of a function in the binary, you can use dynamic dispatch:
fn print_as_str(s: &dyn AsRef<str>) {
println!("{}", s.as_ref());
}
fn main() {
let s: String = String::from("hello");
let r: &str = "world";
print_as_str(&s); // pass a trait object of type `&dyn AsRef<str>`
print_as_str(&r); // pass a trait object of type `&dyn AsRef<str>`
}
- This function is no longer generic; it accepts a trait object that can be any type implementing
AsRef<str> - This means it uses dynamic dispatch at runtime to call
as_ref, and you will only have one copy of the function in your binary
See 1.15.4. Dynamic Dispatch for more details. Note that dynamic dispatch has some runtime overhead compared with monomorphization, but it is very small.
Do Not Take Generic Parameters to the Extreme
Do not overuse generic parameters; it depends on the specific situation.
Whether to use generics (or trait objects) or concrete types depends on whether users will reasonably and frequently want to substitute other types for the concrete type you initially chose. If so, making the parameter generic is more appropriate.
The Trade-Off Between Monomorphization and Dynamic Dispatch
The advantage of monomorphization is that it avoids runtime overhead; the drawback is that the compiler generates one function for each input type, increasing binary size.
If you are worried that the generated binary will be too large, you can use dynamic dispatch. Although dynamic dispatch has some runtime overhead compared with monomorphization, it is very small.
- In high-performance applications, using dynamic dispatch inside frequently executed hot loops can become a fatal issue!
Dynamic dispatch can only be used with simple trait bounds (a single trait bound), such as T: AsRef<str> or impl AsRef<str>. Because Rust cannot create a vtable for complex trait bounds (for example, two or more trait bounds; see 1.15.5. vtable), dynamic dispatch cannot be used there.
For parameters taken by reference (dyn Trait is not Sized, so a wide pointer is needed to use them), dynamic dispatch can be used instead of generic parameters.
Let's look at an example:
// Generic function, static dispatch
fn process<T>(value: T) {
println!("processing T");
}
- This is the generic function form
// Dynamic dispatch
trait Processable {
fn process(&self);
}
struct TypeA;
impl Processable for TypeA {
fn process(&self) {
println!("processing TypeA");
}
}
fn process_trait_object(value: &dyn Processable) {
value.process();
}
- This is the dynamic-dispatch form
What if we put both together—how can we tell which one uses static dispatch and which one uses dynamic dispatch?
// Generic function, static dispatch
fn process<T>(value: T) {
println!("processing T");
}
// Dynamic dispatch
trait Processable {
fn process(&self);
}
struct TypeA;
impl Processable for TypeA {
fn process(&self) {
println!("processing TypeA");
}
}
struct TypeB;
impl Processable for TypeB {
fn process(&self) {
println!("processing TypeB");
}
}
fn process_trait_object(value: &dyn Processable) {
value.process();
}
fn main() {
let a = TypeA;
let b = TypeB;
process_trait_object(&a); // dynamic dispatch
process_trait_object(&b); // dynamic dispatch
process(&a); // static dispatch
process(&b); // static dispatch
process(&a as &dyn Processable); // static dispatch
process(&b as &dyn Processable); // static dispatch
}
- Calls to
process_trait_objectuse dynamic dispatch - Calls to
processuse static dispatch
The last two process calls are a little special. The argument passed in is &dyn Processable rather than a concrete type (because as &dyn Processable is used). The compiler will treat it as a type and monomorphize it, that is, it will monomorphize the code into:
fn process(value: &dyn Processable) {
println!("processing T");
}
This part is still static dispatch because T = &dyn Processable is determined at compile time.
At runtime, because &dyn Processable does not have a concrete static type behind the fat pointer, any method call through that trait object would look up the implementation via the vtable. In this particular process example, however, the function body never calls a method on value, so no vtable dispatch occurs; monomorphization still produces a single process::<&dyn Processable> specialization at compile time.
But overall, we still consider the call to the generic process function itself to be static dispatch.
Output:
processing TypeA
processing TypeB
processing T
processing T
processing T
processing T
When using generic parameters, the caller can always choose dynamic dispatch by passing a trait object (process(&a as &dyn Processable);).
The reverse is not true: if you accept a trait object as a parameter, then the caller must provide a trait object and cannot use static dispatch.
How Should APIs Consider Generic Parameters?
We can start by writing interfaces with concrete types and then gradually convert them to generics. That approach works, but it is not necessarily backward-compatible.
Example:
fn foo(v: &Vec<usize>) {
// ...
}
fn main() {
let iter = vec![1, 2, 3].into_iter();
foo(&iter.collect());
}
- In
main, theinto_itermethod converts theVecinto anIntoIter<usize> -
iter.collect()then convertsiterfromIntoIter<usize>back intoVec<usize>, and adding&in front of it makes it fully match the parameter type required byfooHere,collectknows to collectiterinto aVec<usize>because the compiler knows thatfooaccepts&Vec<usize>
Now let's rewrite foo using a trait bound:
fn foo(v: impl AsRef<[usize]>) {
// ...
}
fn main() {
let iter = vec![1, 2, 3].into_iter();
foo(&iter.collect());
}
- This program does not compile because the compiler does not know what type
collectshould collectiterinto. The compiler only knows thatfoo's parameter isAsRef<[usize]>, but many types satisfy that, such asVec<usize>and&[usize]
Output:
error[E0283]: type annotations needed
--> src/main.rs:7:15
|
7 | foo(&iter.collect());
| ^^^^^^^ cannot infer type of the type parameter `B` declared on the method `collect`
|
= note: the type must implement `FromIterator<i32>`
help: consider specifying the generic argument
|
7 | foo(&iter.collect::<Vec<_>>());
| ++++++++++
To solve this, the caller must explicitly tell collect what type to collect into:
fn foo(v: impl AsRef<[usize]>) {
// ...
}
fn main() {
let iter = vec![1, 2, 3].into_iter();
foo(&iter.collect::<Vec<usize>>());
}
Top comments (0)