[Advanced Rust] 2.11. API Design Principles of Constrained Pt.2 - Sealed Traits, Re-exports, and Auto Traits
DEV Community

[Advanced Rust] 2.11. API Design Principles of Constrained Pt.2 - Sealed Traits, Re-exports, and Auto Traits

Trait Implementations

Rust's coherence rules forbid multiple implementations of the same trait for the same type. In general, the following trait-related operations are breaking changes:

  • Adding a blanket implementation to an existing trait (see 1.17.2. Blanket Implementations) is usually a breaking change
  • Implementing an external trait for an existing type, or implementing an existing trait for an external type
  • Removing a trait implementation (implementing a trait for a new type does not cause a breaking change)
  • Changing the signature of an existing trait method
  • Adding a new method (if the new method has a default implementation, it is not a breaking change)

Be careful when implementing any trait for any type. Consider the following example:

pub struct Unit;

pub trait Foo1 {
    fn foo(&self);
}

impl Foo1 for Unit {
    fn foo(&self) {
        println!("foo1");
    }
}

trait Foo2 {
    fn foo(&self);
}

impl Foo2 for Unit {
    fn foo(&self) {
        println!("foo2");
    }
}

fn main() {
    Unit.foo();
}

This code fails to compile with error [E0034]: multiple applicable items in scope. The problem is that foo is defined in two different trait implementations (Foo1 and Foo2) for the same type Unit. When foo is called in main.rs, the compiler cannot determine which trait's method to use.

Sealed Traits

Rust has sealed traits whose characteristic is that they can be used by other crates but cannot be implemented in other crates. They can prevent breaking changes when new methods are added to a trait.

Sealed traits are not a built-in language feature; there are several ways to implement them. Sealed traits are often used for derived traits. Specifically, they are traits that provide blanket implementations for types that implement certain other traits.

Example Implementation

mod sealed {
    pub trait Sealed {
        // private trait, not exposed publicly
    }

    // Only `i32` and `f64` can implement `MyTrait`
    impl sealed::Sealed for i32 {}
    impl sealed::Sealed for f64 {}

    pub trait MyTrait: sealed::Sealed {
        fn describe(&self) -> String;
    }

    // Blanket implementation: only `Sealed` implementers can use `MyTrait`
    impl MyTrait for i32 {
        fn describe(&self) -> String {
            format!("I am an i32: {}", self)
        }
    }

    impl MyTrait for f64 {
        fn describe(&self) -> String {
            format!("I am an f64: {}", self)
        }
    }
}

In this example, Sealed is private (lives inside mod sealed), so other crates cannot use it, achieving the sealing goal. Only i32 and f64 are allowed to implement Sealed.

When combined with derived traits, sealed traits can further restrict what can be implemented. For instance, BaseTrait inherits from sealed::Sealed, making it impossible for external types to implement it.

When to Use Sealed Traits

Use sealed traits only when external crates should not be able to implement your trait. This restricts which types can be used as type parameters and prevents downstream traits from adding unwanted methods.

Hidden Contracts

Changes made to one part of the code can subtly affect the contract of other parts of the interface. This primarily happens with re-exports and auto-traits.

Re-exports

If part of your interface exposes an external type, then any changes to that external type also become changes to your interface. It is usually better to wrap the external type in a newtype and expose only the parts of the external type that you consider useful.

Auto-Traits

Some traits are implemented automatically based on the contents of a type, such as Send and Sync. These traits add a hidden promise to almost every type in an interface. Implementations of these traits are usually added automatically by the compiler, and if the situation does not apply, they are not added automatically.

Consider the following scenario:

use std::thread;

struct B;

struct A {
    _b: B,
}

// Original code
assert_send<&A>() // passes, A is Send
let a = A { _b: B };
thread::spawn(move || {
    let _ = a;
}).join().unwrap();

// After modifying B to no longer implement Send
use std::rc::Rc;

struct B {
    _data: Rc<i32>,
}

struct A {
    _b: B,
}

fn assert_send<T: Send>() {}
fn main() {
    assert_send::<A>(); // compile error[E0277]: `Rc<i32>` cannot be sent between threads safely (so `A: Send` fails)
    let a = A { _b: B { _data: Rc::new(42) } };
    thread::spawn(move || {
        let _ = a;
    }).join().unwrap();
}

Here, B originally contained Rc<i32>, which made A Send. After changing B to contain Rc<T> (which is not Send), A is no longer Send, causing compilation failures. By including tests that verify trait implementations, you can detect such issues early.

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.