DEV Community

Cover image for [Advanced Rust] 2.6. API Design Principles of Flexibility Pt.2 - Object Safety, API Design, and Generic Trait Methods
SomeB1oody
SomeB1oody

Posted on

[Advanced Rust] 2.6. API Design Principles of Flexibility Pt.2 - Object Safety, API Design, and Generic Trait Methods

2.6.1. Object Safety

When defining a trait, whether it is object-safe is also part of the unstated contract.

Object safety is a concept in Rust related to trait objects. It determines whether a trait can be dynamically dispatched, that is, whether it can be used in the form of dyn Trait.

Traits That Are Object-Safe Must Satisfy the Following Conditions (Based on RFC 255)

  1. All supertraits must also be object-safe

    If a trait inherits from other traits, then those supertraits must also be object-safe.

  2. It must not require Sized

    A trait cannot use Sized as a supertrait, meaning it cannot contain a Self: Sized bound, because the size of a trait object is unknown at compile time.

  3. It cannot have associated constants.

  4. It cannot have associated types with type parameters.

  5. All associated functions (methods) must satisfy one of the following rules:

    • Dispatchable functions:
      • They cannot have any type parameters, though lifetime parameters are allowed.
      • They must be methods, and Self may only appear in receiver positions such as:
      • &self
      • &mut self
      • Box<Self>
      • Rc<Self>
      • Arc<Self>
      • Pin<P> (where P is one of the types above)
      • They cannot require Self: Sized, otherwise the trait would only be usable for types with known size and object safety would be broken.
  • Explicitly non-dispatchable functions:
    • They may return Self, but such functions must require Self: Sized, so they cannot be called on trait objects and can only be used with concrete types.

If you cannot remember all of the above, just remember object safety describes whether a trait can be safely turned into a trait object.


What Object Safety Does

If a trait is object-safe, meaning it satisfies all of the conditions above, then we can use dyn Trait to treat different types that implement the trait as a single generic type.

If it is not object-safe, the compiler will prevent you from using dyn Trait.


Object Safety and API Design

When designing APIs, it is recommended to make traits object-safe, even if that slightly reduces convenience, because it provides new ways to use the trait and increases flexibility.

Let's look at an example:

Suppose we have an Animal trait with two methods: name and speak. The name method returns &str and represents the animal's name. The speak method prints an onomatopoeic sound for the animal and returns nothing. We have two structs, Dog and Cat, and both implement this trait.

trait Animal {
    fn name(&self) -> &str;
    fn speak(&self);
}

struct Dog {
    name: String,
}

impl Animal for Dog {
    fn name(&self) -> &str {
        &self.name
    }

    fn speak(&self) {
        println!("Woof!");
    }
}

struct Cat {
    name: String,
}

impl Animal for Cat {
    fn name(&self) -> &str {
        &self.name
    }

    fn speak(&self) {
        println!("Meow!");
    }
}

fn main() {
    let dog = Dog { name: String::from("George") };
    let cat = Cat { name: String::from("Hamilton") };

    let animals: Vec<&dyn Animal> = vec![&dog, &cat];

    for animal in animals {
        println!("The name of this animal is {}", animal.name());
        animal.speak();
    }
}
Enter fullscreen mode Exit fullscreen mode
  • The Animal trait is object-safe because it does not return Self or use generic parameters
  • So we can use it to create a trait object: let animals: Vec<&dyn Animal> = vec![&dog, &cat];, and this Vec effectively becomes a trait-object collection

Output:

The name of this animal is George
Woof!
The name of this animal is Hamilton
Meow!
Enter fullscreen mode Exit fullscreen mode

Next, let's make a small change to the previous example:

We add a new clone method to the Animal trait, and it returns a Self value

trait Animal {
    fn name(&self) -> &str;
    fn speak(&self);
    fn clone(&self) -> Self;
}

struct Dog {
    name: String,
}

impl Animal for Dog {
    fn name(&self) -> &str {
        &self.name
    }

    fn speak(&self) {
        println!("Woof!");
    }

    fn clone(&self) -> Self {
        todo!()
    }
}

struct Cat {
    name: String,
}

impl Animal for Cat {
    fn name(&self) -> &str {
        &self.name
    }

    fn speak(&self) {
        println!("Meow!");
    }

    fn clone(&self) -> Self {
        todo!()
    }
}

fn main() {
    let dog = Dog { name: String::from("George") };
    let cat = Cat { name: String::from("Hamilton") };

    let animals: Vec<&dyn Animal> = vec![&dog, &cat];

    for animal in animals {
        println!("The name of this animal is {}", animal.name());
        animal.speak();
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

error[E0038]: the trait `Animal` is not dyn compatible
  --> src/main.rs:47:27
   |
47 |     let animals: Vec<&dyn Animal> = vec![&dog, &cat];
   |                           ^^^^^^ `Animal` is not dyn compatible
   |
note: for a trait to be dyn compatible it needs to allow building a vtable
      for more information, visit <https://doc.rust-lang.org/reference/items/traits.html#dyn-compatibility>
  --> src/main.rs:4:24
   |
 1 | trait Animal {
   |       ------ this trait is not dyn compatible...
...
 4 |     fn clone(&self) -> Self;
   |                        ^^^^ ...because method `clone` references the `Self` type in its return type
   = help: consider moving `clone` to another trait
   = help: the following types implement `Animal`:
             Dog
             Cat
           consider defining an enum where each variant holds one of these types,
           implementing `Animal` for this new enum and using it instead
Enter fullscreen mode Exit fullscreen mode

After adding clone, Animal is no longer object-safe because clone violates the rule that the return type cannot be Self. A dyn Trait is called through a pointer, while Self refers to the concrete implementation type, whose size is unknown at compile time.

For example:

fn main() {
    let dog = Dog { name: "Ver".to_string() };
    let dog2 = dog.clone(); // this is fine because Self = Dog

    let animal: Box<dyn Animal> = Box::new(Dog { name: "Ver".to_string() });
    let animal2 = animal.clone(); // compile error: the concrete size is unknown at compile time
}
Enter fullscreen mode Exit fullscreen mode

If I want to keep Animal object-safe while also keeping the clone method, what should I do?

Going back to the first section of this article, look at explicitly non-dispatchable functions: they may return Self, but such functions must require Self: Sized, so they cannot be called on trait objects and can only be used with concrete types.

According to that requirement, we can change the code like this:

trait Animal {
    fn name(&self) -> &str;
    fn speak(&self);
    fn clone(&self) -> Self
    where
        Self: Sized;
}

// ...the rest of the code stays the same
Enter fullscreen mode Exit fullscreen mode

Output:

The name of this animal is George
Woof!
The name of this animal is Hamilton
Meow!
Enter fullscreen mode Exit fullscreen mode

That way, there is no error.

Note that clone can now only be called on concrete types; otherwise it will fail:

fn main() {
    let dog = Dog { name: String::from("George") };
    let cat = Cat { name: String::from("Hamilton") };

    let animals: Vec<&dyn Animal> = vec![&dog, &cat];

    for animal in animals {
        println!("The name of this animal is {}", animal.name());
        animal.speak();
        animal.clone();  // this will fail because `animal` is `&dyn Animal`, not a concrete type
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

error: the `clone` method cannot be invoked on a trait object
  --> src/main.rs:54:16
   |
 6 |         Self: Sized;
   |               ----- this has a `Sized` requirement
...
54 |         animal.clone();  // this will fail because `animal` is `&dyn Animal`, not a concrete type
   |                ^^^^^
Enter fullscreen mode Exit fullscreen mode

Because the trait method declares a Self: Sized requirement on the clone return value, and &dyn Animal does not have a known concrete size, the method cannot be called.

Of course, it definitely works on a concrete type:

fn main() {
    let dog = Dog { name: String::from("George") };
    let dog_clone = dog.clone(); // compiles successfully
}
Enter fullscreen mode Exit fullscreen mode

Generic Trait Methods and API Design

Put Generic Parameters on the Trait

If a trait must have a generic method, consider putting the generic parameter on the trait itself.

Example:

use std::collections::HashSet;
use std::hash::Hash;

trait Container<T> {
    fn contains(&self, item: &T) -> bool;
}

impl<T> Container<T> for Vec<T>
where
    T: PartialEq,
{
    fn contains(&self, item: &T) -> bool {
        self.iter().any(|x| x == item)
    }
}

impl<T> Container<T> for HashSet<T>
where
    T: Eq + Hash,
{
    fn contains(&self, item: &T) -> bool {
        HashSet::contains(self, item)
    }
}

fn main() {
    // Create `Vec<T>` and `HashSet<T>` instances
    let vec_container: Box<dyn Container<i32>> = Box::new(vec![1, 2, 3]);
    let hashset_container: Box<dyn Container<i32>> =
        Box::new(vec![4, 5, 6].into_iter().collect::<HashSet<_>>());

    // Call the contains method
    println!("Vector contains 2: {}", vec_container.contains(&2));
    println!("HashSet contains 4: {}", hashset_container.contains(&4));
}
Enter fullscreen mode Exit fullscreen mode
  • There is a trait called Container, and it has a method called contains. The implementation of contains will definitely need a generic parameter. But to preserve object safety, we cannot add type parameters to the method itself.
  • So we move the generic parameter to the trait rather than to the trait method, namely Container<T>, where T is the generic parameter
  • In this way, we can implement the Container trait for different container types, and each implementation has its own specific element type
  • For example, in the code above we implemented Container for Vec<T> and HashSet<T>

Output:

Vector contains 2: true
HashSet contains 4: true
Enter fullscreen mode Exit fullscreen mode

Use Dynamic Dispatch

Another option is to consider whether the generic parameter can be expressed with dynamic dispatch in order to keep the trait object-safe.

Example:

Suppose we have a Foo trait with a generic method bar that takes a generic parameter T:

trait Foo {
    fn bar<T>(&self, x: T);
}
Enter fullscreen mode Exit fullscreen mode

This trait is not object-safe, because object safety requires trait methods to have no generic parameters. The reason is that generic methods rely on monomorphization: Rust needs to determine the concrete type of T at compile time and generate different code for different Ts, while dyn Foo uses runtime dynamic dispatch, so the compiler cannot pre-generate code for every possible T.

But there is a workaround: replace the generic parameter with a dynamically dispatched form, like this:

trait Foo {
    fn bar(&self, x: &dyn Debug);
}
Enter fullscreen mode Exit fullscreen mode

Then the bar method can call x's Debug behavior through dynamic dispatch (via the vtable) without needing the concrete type at compile time, which keeps Foo object-safe.

Example:

trait Foo {
    fn bar<T>(&self, x: T); // generic method, so the trait is not object-safe
}

struct MyStruct;

impl Foo for MyStruct {
    fn bar<T>(&self, x: T) {
        println!("Received a value!");
    }
}

fn main() {
    let obj = MyStruct;

    let obj_ref: &dyn Foo = &obj; // compile error: the trait `Foo` is not dyn compatible
    obj_ref.bar(42);  // cannot call this because `T` must be known at compile time
}
Enter fullscreen mode Exit fullscreen mode

This will not work, so we need to switch to a dynamic-dispatch version:

use std::fmt::Debug;

trait Foo {
    fn bar(&self, x: &dyn Debug); // use a trait object instead of a generic parameter to keep it object-safe
}

struct MyStruct;

impl Foo for MyStruct {
    fn bar(&self, x: &dyn Debug) {
        println!("Received a value: {:?}", x);
    }
}

fn main() {
    let obj = MyStruct;

    let obj_ref: &dyn Foo = &obj; // now it can be used as a trait object
    obj_ref.bar(&42);  // output: Received a value: 42
    obj_ref.bar(&"Hello"); // output: Received a value: "Hello"
}
Enter fullscreen mode Exit fullscreen mode

The Cost of Object Safety

How much do we have to give up in order to achieve object safety?

  • Think about how users will use your trait; if they are likely to treat it as a trait object, then do your best to make it object-safe

Top comments (0)