DEV Community

Cover image for [Advanced Rust] 2.5. API Design Principles of Flexibility Pt.1 - Contracts and More Flexible Interfaces with Generic Parameters
SomeB1oody
SomeB1oody

Posted on

[Advanced Rust] 2.5. API Design Principles of Flexibility Pt.1 - Contracts and More Flexible Interfaces with Generic Parameters

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
Enter fullscreen mode Exit fullscreen mode
  • The first version takes a String and returns a String
  • 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>
Enter fullscreen mode Exit fullscreen mode
  • 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 the Cow type
  • This version is still somewhat rigid. For example, the argument is &str; if I pass in a String, I still have to convert it first. Also, because the return value is Cow, it cannot return string-owning types other than String and &str (for example, OsString)
fn frobnicate<T: AsRef<str>>(s: T) -> T
Enter fullscreen mode Exit fullscreen mode
  • 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);
}
Enter fullscreen mode Exit fullscreen mode
  • Whether it is String, &str, or Cow<str> (which is essentially also &str), this function can accept it (String needs to be converted to &str first with as_ref) and return a value (the return value can also be a different type)

Output:

Result1: "example"
Result2: "hello"
Result3: "world"
Enter fullscreen mode Exit fullscreen mode

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>`
}
Enter fullscreen mode Exit fullscreen mode
  • The print_as_str function accepts a parameter that implements AsRef<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 a String and a &str, you will have two copies of the function in your binary, print_as_str::<String> and print_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>`
}
Enter fullscreen mode Exit fullscreen mode
  • 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");
}
Enter fullscreen mode Exit fullscreen mode
  • 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();
}
Enter fullscreen mode Exit fullscreen mode
  • 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
}
Enter fullscreen mode Exit fullscreen mode
  • Calls to process_trait_object use dynamic dispatch
  • Calls to process use 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");
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode
  • In main, the into_iter method converts the Vec into an IntoIter<usize>
  • iter.collect() then converts iter from IntoIter<usize> back into Vec<usize>, and adding & in front of it makes it fully match the parameter type required by foo Here, collect knows to collect iter into a Vec<usize> because the compiler knows that foo accepts &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());
}
Enter fullscreen mode Exit fullscreen mode
  • This program does not compile because the compiler does not know what type collect should collect iter into. The compiler only knows that foo's parameter is AsRef<[usize]>, but many types satisfy that, such as Vec<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<_>>());
   |                      ++++++++++
Enter fullscreen mode Exit fullscreen mode

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>>());
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)