How to Use Rust Traits, Generics and Bounds

rsdlt

Rodrigo Santiago

Posted on September 14, 2022

How to Use Rust Traits, Generics and Bounds

Every time I am developing the question I am always asking myself is "is this code idiomatic, ergonomic and extendable enough?"

I ask myself that question much more at the beginning, when all the scaffolding and critical building blocks are being created, and particularly when defining types that will be the workhorses of the entire project.

Because I know that if I don't do it correctly there will be significant pain in the form of refactoring later on.

And usually the answer to that question is "yes" up until I make some progress and then I realize that my code was in fact "not extendable enough" or that "there was a better way to do it."

And then comes the decision to either refactor or continue building on top of the code that I know is just not good enough.

And this is precisely what just happened with my project Ruxel

After I finished developing and testing all the vector and matrix logic for the ray tracer, I came back this weekend to review the code again, including my Ruxel Part 1 post, and I noticed that my implementation could have leveraged generics and traits in a much better way by using trait bounds.

Hence, I have decided to refactor part of my initial implementation and also share in this post how I plan to leverage Rust's Traits, Generics and Bounds in a way that makes the code more idiomatic, extendable and ergonomic.

Main Problem

At the heart of a ray tracer exist two types: Vector3 and Point3 that are very similar but with key differences, particularly because of its weight w: component, when expressed in their homogeneous form:

vector in homogeneous form

point in homogeneous form

It's convenient that both types can be declared with either floating point or integer values.

Both types share common behavior, but also each one has its own specific functionality.

And both types are the workhorses of the entire project so they need to be implemented in the most extendable, ergonomic and idiomatic way.

In summary, this is the scenario to implement:

Point3 vs Vector3
Point3 vs Vector3

What's the best approach?

Possible Alternatives

There are countless ways to approach the implementation of these types in Rust. However,I think the most common a programmer would try are:

  1. No generics, no traits
  2. Generic structs and traits

Let's review the implications of each in more detail…

1. No generics, no traits

To implement this approach, the following types would be required:

struct Point3F64 {
    x: f64,
    y: f64,
    z: f64,
    w: f64,
}

struct Point3I64 {
    x: i64,
    y: i64,
    z: i64,
    w: i64,
}

struct Vector3F64 {
    x: f64,
    y: f64,
    z: f64,
    w: f64,
}

struct Vector3I64 {
    x: i64,
    y: i64,
    z: i64,
    w: i64,
}
Enter fullscreen mode Exit fullscreen mode

From the get-go it's clear that this will become a nightmare, as each struct will need its own impl block like this:

impl Vector3F64 {
    fn new(x: f64, y: f64, z: f64) -> Self {
        Self { x, y, z, w: 0f64 }
    }

    fn zero() -> Self {
        Self {
            x: 0f64,
            y: 0f64,
            z: 0f64,
            w: 0f64,
        }
    }

    // Other Associated Functions

    fn dot(self, rhs: Vector3F64) -> f64 {
        self.x * rhs.x + self.y * rhs.y + self.z * rhs.z + self.w * rhs.w
    }

    // Other Methods
}
Enter fullscreen mode Exit fullscreen mode

And things will get ever more convoluted when implementing Operator Overloading for each type:

impl Add for Vector3F64 {
    type Output = Vector3F64;

    fn add(self, rhs: Self) -> Self::Output {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
            w: self.z + rhs.w,
        }
    }
}

impl Add for Vector3I64 {
    type Output = Vector3I64;

    fn add(self, rhs: Self) -> Self::Output {
        Self {
            x: self.x + rhs.x,
            y: self.y + rhs.y,
            z: self.z + rhs.z,
            w: self.z + rhs.w,
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Following this approach would require implementing:

non-generic implementation
Associated functions 36
Methods 14
Operator overload functions 24
Copy attributes 4
Default attributes 4
Display, Debug and PartialEq functions 1

This approach is:

  • Not extendable, because supporting another primitive type like i32 has the effect of requiring an additional:
new primitive
Associated functions 13
Methods 7
Operator overload functions 11
Copy attributes 1
Default attributes 1
Display, Debug and PartialEq functions 4
  • Not idiomatic, because it is not:
    • Leveraging the capabilities provided by Rust to solve this particular situation
    • Following the best practices of the Rust community
    • Succint in its approach
    • Using the expressing power of Rust effectively
  • Not ergonomic, because it:
    • Is full of development friction
    • Doesn't seek simplicity
    • Is inefficient
    • Makes testing exponentially more complicated

Hence, unless the intention is to support only one primitive per struct type, this approach should be discarded.

2. Generic structs and traits

The next approach involves the use of generics in the struct declarations as follows:

struct Vector3<T> {
    x: T,
    y: T,
    z: T,
    w: T,
}

struct Point3<T> {
    x: T,
    y: T,
    z: T,
    w: T,
}
Enter fullscreen mode Exit fullscreen mode

From the start, the required struct declarations are reduced to only 2. Even if in the future the project supports another primitive, like i32 these struct declarations would not change and no additional declarations would be required.

It's a big step in the right direction.

Implementing the associated functions and methods is also more ergonomic and idiomatic with this approach by leveraging Rust's trait bounds using the where keyword:

impl<T> Vector3<T>{
    fn dot(self, rhs: Vector3<T>) -> T
    where T: Copy + Add<Output = T> + Mul<Output = T>
    {
        self.x * rhs.x + self.y * rhs.y + self.z * rhs.z + self.w * rhs.w
    }
}
Enter fullscreen mode Exit fullscreen mode

The example above will work for any type that implements the Copy, Add and Mul traits, like: f64, f32, i64, i32, etc.

There is no more code to write to extend the dot product functionality for more primitives.

However, in those associated functions where a value, other than Default::default(), needs to be specified there is still the need to implement them separately:

impl Point3<f64>{
    fn new(x: f64, y: f64, z: f64)  -> Self{
        Self{x, y, z, w: 1f64}
    }
}

impl Point3<i64>{
    fn new(x: i64, y: i64, z: i64)  -> Self{
        Self{x, y, z, w: 1i64}
    }
}
Enter fullscreen mode Exit fullscreen mode

Yet for the cases where Default::default() applies there is only one function to specify:

impl<T> Vector3<T>{

    // Other generic associated functions

    fn new(x: T, y: T, z: T) -> Self
    where T: Copy + Add + Mul + Default
    {
        Self{x, y, z, w: Default::default()}
    }
}
Enter fullscreen mode Exit fullscreen mode

This generic new(...) function of Vector3<T> will work with any type that implements the Copy and Default traits, again like f64, f32, etc.

By my estimations, with this approach the following implementations would be required:

generic implementation
Associated functions 26
Methods 6
Operator overload functions 7
Copy attributes 2
Default attributes 2
Display, Debug and PartialEq functions 3

When compared versus the non-generic approach the improvement is significant:

non-generic generic difference
Associated functions 36 26 -10
Methods 14 6 -8
Operator overload functions 24 7 -17
Copy attributes 4 2 -2
Default attributes 4 2 -2
Display, Debug and PartialEq functions 12 3 -9

Hence, this approach is:

  • Much more extendable, because supporting other primitive like i32 would only require an additional:
new primitive
Associated functions 9
Methods 0
Operator overload functions 0
Copy attributes 0
Default attributes 0
Display, Debug and PartialEq functions 0
  • More idiomatic, because it:

    • Leverages the generics capabilities provided by Rust that solve this particular problem
    • Follow the best practices of the Rust community by using 'trait bounds' and generics
    • Significantly more succint in the approach, as the comparison table above proved
    • Uses the expressing power of Rust effectively
  • More ergonomic, because it:

    • Has less developer friction: declaring a new Vector is let v = Vector3::new(...) instead of let v = Vector3F64::new(...) or let v = Vector3I32::new(...)
    • Seeks simplicity with far less code
    • Is efficient as it enables the same capabilities with less
    • Testing is less burdensome as there are far fewer functions and scenarios to validate

It has been a big improvement by utilizing generics with trait bounds.

And most important: there is no impact on the speed and performance because this implementation is using static dispatch.

Supertraits and Subtraits

One additional Rust feature to further provide extensibility to the project is to define three traits in order to group the common behavior in a logical way via supertraits and subtraits:

Tuple Supertrait with Vector and Point Subtraits

The important part here is that the subraits don't inherit the functions or methods from the supertrait. Every type that implements the subtrait must implement the functions of the supertrait.

What this means in Rust code is the following:

// -- Trait declarations
trait Tuple<P> {
    fn new(x: P, y: P, z: P) -> Self
    where
        P: Copy + Default;
}

trait Vector<P>: Tuple<P> {
    fn dot(lhs: Vector3<P>, rhs: Vector3<P>) -> P 
    where
        P: Copy + Add<Output = P> + Mul<Output = P>;
}
trait Point<P>: Tuple<P> {
    fn origin(x: P) -> Self
    where
        P: Copy + Default;
}


// -- Supertrait implementations
impl<P> Tuple<P> for Vector3<P> {
    fn new(x: P, y: P, z: P) -> Vector3<P> where P: Copy + Default{
        Vector3 { x, y, z, w: Default::default() }
    }
}

impl Tuple<f64> for Point3<f64> {
    fn new(x: f64, y: f64, z: f64) -> Self {
        Point3 { x, y, z, w:1f64 }
    }
}

impl Tuple<i64> for Point3<i64> {
    fn new(x: i64, y: i64, z: i64) -> Self {
        Point3 { x, y, z, w:1i64 }
    }
}

// -- Subtrait implementations
impl<P> Vector<P> for Vector3<P> {
    fn dot(lhs: Vector3<P>, rhs: Vector3<P>) -> P 
    where
        P: Copy + Add<Output = P> + Mul<Output = P>,
        {
       lhs.x * rhs.x + lhs.w * rhs.w
    }

}
Enter fullscreen mode Exit fullscreen mode

Vector3<P> and Point3<P> are implementing the new(x:...) -> Self function from the Tuple<P> trait and not from one of its subtraits.

Because the type must implement the supertrait functions of those subtraits that it implements, it's critical to define under which scope a capability will be defined in order to balance logical grouping and efficiency:

  • Supertrait
  • Subtrait
  • Type implementation

Type implements supertrait functions, there is no inheritance in Rust

For example, defining the ones() function -which returns a Vector or Point with '1' value in all its coordinates- in the Tuple<P> supertrait scope forces the implementation of that function in both Point<P> and Vector<P> and all of their non-generic implementations like impl Tuple<i64> for Point3<i64>:

// -- Trait declarations
trait Tuple<P> {
    fn new(x: P, y: P, z: P) -> Self
    where
        P: Copy + Default;

    fn ones() ->Self
        where P: Copy + Default;
}
Enter fullscreen mode Exit fullscreen mode

The compiler will be happy to let us know where an implementation is missing:

Rust/playground on  master [!] > v0.1.0 | v1.63.0
 λ cargo test it_works -- --nocapture
   Compiling playground v0.1.0 (/home/rsdlt/Documents/Rust/playground)
error[E0046]: not all trait items implemented, missing: `ones`
  --> src/lib.rs:46:1
   |
29 | /     fn ones() ->Self
30 | |         where P: Copy + Default;
   | |________________________________- `ones` from trait
...
46 |   impl<P> Tuple<P> for Vector3<P> {
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `ones` in implementation

error[E0046]: not all trait items implemented, missing: `ones`
  --> src/lib.rs:52:1
   |
29 | /     fn ones() ->Self
30 | |         where P: Copy + Default;
   | |________________________________- `ones` from trait
...
52 |   impl Tuple<f64> for Point3<f64> {
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `ones` in implementation

error[E0046]: not all trait items implemented, missing: `ones`
  --> src/lib.rs:58:1
   |
29 | /     fn ones() ->Self
30 | |         where P: Copy + Default;
   | |________________________________- `ones` from trait
...
58 |   impl Tuple<i64> for Point3<i64> {
   |   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ missing `ones` in implementation

For more information about this error, try `rustc --explain E0046`.
error: could not compile `playground` due to 3 previous errors
Enter fullscreen mode Exit fullscreen mode

It could appear that adding the supertrait and subtrait capabilities is generating more headaches than benefits… However, the benefits of structuring the common behavior this way are in my humble view, the following:

  1. It forces thinking twice and hard under which scope it makes logical sense to add a new capability.
  2. Once defined, the compiler will make sure that the capability is implemented everywhere it needs to be implemented.
  3. Having a type defined within the bounds of the supertrait for cases that could be of benefit like ... where T: Tuple + Copy.

How could I have done better?

Going back to Ruxel Part 1 I am basically using two generic traits called CoordInit<T, U> and VecOps<T> which provide coordinate initialization and vector operations' capabilities, respectively.

Because they are generic traits, what followed was the implementation of each of those traits over the Vector3<f64> and Point3<f64> types:

impl VecOps<Vector3<f64>> for Vector3<f64> {
    fn magnitude(&self) -> f64 {
        (self.x.powf(2.0) + self.y.powf(2.0) + self.z.powf(2.0)).sqrt()
    }

    // Rest of Vector3<f64> operation methods and functions
Enter fullscreen mode Exit fullscreen mode
impl CoordInit<Vector3<f64>, f64> for Vector3<f64> {
    fn back() -> Self {
        Vector3 {
            x: 0.0,
            y: 0.0,
            z: -1.0,
            w: 0.0,
        }
    }

    // Rest of Vector3<f64> initialization functions
Enter fullscreen mode Exit fullscreen mode
impl CoordInit<Vector3<f64>, f64> for Point3<f64> {
    fn back() -> Self {
        Vector3 {
            x: 0.0,
            y: 0.0,
            z: -1.0,
            w: 0.0,
        }
    }

    // Rest of Point3<f64> initialization functions
Enter fullscreen mode Exit fullscreen mode

Now, how would this approach support the addition of an i64 primitive type?

Not in a very ergonomic or idiomatic or extendable way.

Essentially, the following implementation blocks would need to be created and all the existing functions and methods defined for the f64 primitive be repeated (almost carbon copy) for each:

  • impl VecOps<Vector3<i64>> for Vector<i64>.
  • impl CoordInit<Vector3<i64>, <i64>> for Vector3<i64>.
  • impl CoordInit<Vector3<i64>, <i64>> for Point3<i64>.
  • impl operator overloading functions for Add, AddAssign, Sub, SubAssign, Mul, Div and Neg for i64.

So that's:

new primitive
Associated functions 36
Methods 15
Operator overload func. 14
Copy attributes 0
Default attributes 0
Display, Debug and PartialEq func. 0

For a grand total of 55 methods & functions. And this is not counting the additional effort to properly test.

The approach I took was convenient enough to implement all the functionality quickly, but the solution could be better implemented by properly leveraging Rust's generics, traits and trait bounds.

Considering one of my primary Goals is to deliver idiomatic and ergonomic code, a refactoring over the implementation of the Vector3 and Point3 types is due.

Fortunately, it's going to be a minor refactor because the project is in its initial stage.


Links, references and disclaimers:

Header Photo by Brecht Corbeel on Unsplash

A version of this post was orginally published on https://rsdlt.github.io

💖 💪 🙅 🚩
rsdlt
Rodrigo Santiago

Posted on September 14, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related