Rust’s trait coherence rules are a key part of the language’s design, but they can be tricky to wrap your head around. I’ve spent a lot of time working with these rules, and I want to share what I’ve learned about using them to build extensible libraries.
At its core, trait coherence is about ensuring that trait implementations don’t conflict with each other. This is crucial for maintaining a consistent type system and avoiding ambiguity. The most famous aspect of coherence is the orphan rule, which states that you can only implement a trait for a type if either the trait or the type is defined in your crate.
This rule exists to prevent two different crates from implementing the same trait for the same type, which could lead to conflicts. While it might seem restrictive at first, it actually enables a lot of powerful patterns when used correctly.
Let’s start with a simple example. Say we’re building a library for working with geometric shapes. We might define a trait like this:
pub trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64;
}
Now, we can implement this trait for our own types:
pub struct Rectangle {
width: f64,
height: f64,
}
impl Shape for Rectangle {
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
}
But what if a user of our library wants to implement Shape for their own type? They can’t do it directly because of the orphan rule. However, we can provide a way for them to do this by using a newtype pattern:
pub struct Wrapper<T>(pub T);
impl<T: Shape> Shape for Wrapper<T> {
fn area(&self) -> f64 {
self.0.area()
}
fn perimeter(&self) -> f64 {
self.0.perimeter()
}
}
Now, users can implement Shape for their own types by wrapping them in our Wrapper type. This pattern allows for extensibility while still respecting coherence rules.
Another powerful technique is using associated types in traits. This can help create more flexible APIs that can be extended by users. Let’s modify our Shape trait:
pub trait Shape {
type Measurement;
fn area(&self) -> Self::Measurement;
fn perimeter(&self) -> Self::Measurement;
}
Now, implementors can decide what type to use for measurements. This could be useful for supporting different units or precision levels:
impl Shape for Rectangle {
type Measurement = f64;
fn area(&self) -> Self::Measurement {
self.width * self.height
}
fn perimeter(&self) -> Self::Measurement {
2.0 * (self.width + self.height)
}
}
struct HighPrecisionRectangle {
width: f64,
height: f64,
}
impl Shape for HighPrecisionRectangle {
type Measurement = f128; // Hypothetical 128-bit float type
fn area(&self) -> Self::Measurement {
self.width.into() * self.height.into()
}
fn perimeter(&self) -> Self::Measurement {
(2.0 * (self.width + self.height)).into()
}
}
This approach gives users more flexibility while still maintaining type safety and coherence.
Now, let’s talk about a more advanced concept: sealed traits. These are traits that can only be implemented by the crate that defines them. This can be useful for creating closed sets of implementations. Here’s how we might create a sealed trait:
mod sealed {
pub trait Sealed {}
}
pub trait SpecialShape: sealed::Sealed {
fn special_property(&self) -> bool;
}
pub struct Circle {
radius: f64,
}
impl sealed::Sealed for Circle {}
impl SpecialShape for Circle {
fn special_property(&self) -> bool {
true // Circles are always special!
}
}
In this example, only types in our crate can implement SpecialShape because they need to implement the private Sealed trait. This gives us more control over how our trait is used.
Another interesting aspect of Rust’s trait system is impl specialization. While it’s still an unstable feature, it allows for more efficient implementations in certain cases. Here’s a simplified example:
#![feature(specialization)]
trait MyTrait {
fn my_method(&self) -> u32;
}
impl<T> MyTrait for T {
default fn my_method(&self) -> u32 {
1
}
}
impl MyTrait for u32 {
fn my_method(&self) -> u32 {
*self
}
}
In this case, we have a default implementation for all types, but a specialized implementation for u32. This can lead to more efficient code in some cases.
When designing traits for extensible libraries, it’s important to consider how they might be used in different contexts. One approach is to create traits with minimal requirements and provide default implementations for more complex methods. This allows users to implement just the core functionality while still getting the benefits of the full API.
For example, we could modify our Shape trait like this:
pub trait Shape {
fn area(&self) -> f64;
fn perimeter(&self) -> f64 {
// Default implementation that might not be efficient for all shapes
let tiny_step = 0.001;
let mut total = 0.0;
let mut last_point = self.point_at(0.0);
for i in 1..=1000 {
let t = i as f64 * tiny_step;
let new_point = self.point_at(t);
total += last_point.distance_to(&new_point);
last_point = new_point;
}
total
}
fn point_at(&self, t: f64) -> Point;
}
struct Point {
x: f64,
y: f64,
}
impl Point {
fn distance_to(&self, other: &Point) -> f64 {
((self.x - other.x).powi(2) + (self.y - other.y).powi(2)).sqrt()
}
}
In this version, users only need to implement area and point_at. They get a default implementation of perimeter for free, which they can override if they have a more efficient method for their specific shape.
When working with external types, we can use the newtype pattern again to implement traits. This is particularly useful when working with types from the standard library or other crates. For instance, if we wanted to implement our Shape trait for a tuple representing a point:
pub struct PointShape(pub (f64, f64));
impl Shape for PointShape {
fn area(&self) -> f64 {
0.0 // A point has no area
}
fn perimeter(&self) -> f64 {
0.0 // A point has no perimeter
}
fn point_at(&self, _t: f64) -> Point {
Point { x: self.0.0, y: self.0.1 }
}
}
This allows us to work with basic types in our shape system without violating coherence rules.
Another powerful technique for creating extensible libraries is to use marker traits. These are traits with no methods that are used to indicate that a type has certain properties. For example:
pub trait Drawable {}
pub trait Shape {
fn draw(&self) where Self: Drawable;
fn area(&self) -> f64;
}
impl<T: Shape + Drawable> Shape for Box<T> {
fn draw(&self) where Self: Drawable {
(**self).draw()
}
fn area(&self) -> f64 {
(**self).area()
}
}
In this setup, we can implement Shape for types that aren’t Drawable, but they won’t be able to use the draw method. This allows for more flexibility in how the trait is implemented and used.
When designing traits, it’s also important to consider how they might be composed. Rust’s trait system allows for powerful compositions through trait bounds. For instance:
pub trait Scalable {
fn scale(&mut self, factor: f64);
}
pub trait ScalableShape: Shape + Scalable {}
impl<T: Shape + Scalable> ScalableShape for T {}
This allows us to work with shapes that can be scaled, without having to modify the original Shape trait.
One of the challenges with coherence rules is dealing with traits from external crates. Sometimes you might want to implement an external trait for a type in your crate, or vice versa. While this isn’t directly possible due to the orphan rule, there are workarounds. One common approach is to use a wrapper type:
extern crate some_external_crate;
use some_external_crate::ExternalTrait;
pub struct MyType;
pub struct Wrapper<T>(T);
impl<T> ExternalTrait for Wrapper<T> where T: MyTrait {
// Implementation here
}
pub trait MyTrait {
// Your trait definition
}
impl MyTrait for MyType {
// Implementation here
}
This pattern allows you to effectively implement external traits for your types, or your traits for external types, while still respecting coherence rules.
Another important aspect of designing extensible libraries is thinking about future compatibility. Rust’s type system and coherence rules can help here too. By using sealed traits or marker traits, you can create extensible APIs that still allow you to add new methods in the future without breaking existing code.
For example:
mod sealed {
pub trait Sealed {}
}
pub trait AdvancedShape: Shape + sealed::Sealed {
fn contains_point(&self, point: &Point) -> bool;
}
impl sealed::Sealed for Rectangle {}
impl sealed::Sealed for Circle {}
impl AdvancedShape for Rectangle {
fn contains_point(&self, point: &Point) -> bool {
point.x >= 0.0 && point.x <= self.width && point.y >= 0.0 && point.y <= self.height
}
}
impl AdvancedShape for Circle {
fn contains_point(&self, point: &Point) -> bool {
point.distance_to(&self.center) <= self.radius
}
}
In this setup, we can add new methods to AdvancedShape in future versions of our library without breaking existing code, because we control all the implementations.
When working with generic code, it’s often useful to use trait bounds to specify what operations are available. This can lead to more flexible and reusable code. For instance:
fn print_area<T: Shape>(shape: &T) {
println!("The area is: {}", shape.area());
}
fn scale_and_print_area<T: ScalableShape>(shape: &mut T, factor: f64) {
shape.scale(factor);
println!("The new area is: {}", shape.area());
}
These functions can work with any type that implements the required traits, making them very flexible.
In conclusion, Rust’s trait coherence rules might seem restrictive at first, but they enable powerful patterns for creating extensible and robust libraries. By leveraging techniques like associated types, sealed traits, marker traits, and careful API design, we can create libraries that are both flexible for users and maintainable for developers. The key is to think carefully about how your traits and types will be used and extended, and to use Rust’s type system to encode these design decisions. With practice, you’ll find that coherence rules become a powerful tool in your Rust programming toolkit, helping you create libraries that are not just powerful, but also play well with others in the Rust ecosystem.