Home How to Use Rust Traits, Generics and Bounds
Post
Cancel

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:

\[\begin{align} \vec{v} = \begin{bmatrix} x & y & z & w = 0 \end{bmatrix} \end{align}\] \[\begin{align} \vec{p} = \begin{bmatrix} x & y & z & w = 1 \end{bmatrix} \end{align}\]

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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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,
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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,
        }
    }
}

Following this approach would require implementing:

 non-generic implementation
Associated functions36
Methods14
Operator overload functions24
Copy attributes4
Default attributes4
Display, Debug and PartialEq functions1

This approach is:

  • Not extendable, because supporting another primitive type like i32 has the effect of requiring an additional:
 new primitive
Associated functions13
Methods7
Operator overload functions11
Copy attributes1
Default attributes1
Display, Debug and PartialEq functions4
  • 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Vector3<T> {
    x: T,
    y: T,
    z: T,
    w: T,
}

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

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:

1
2
3
4
5
6
7
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
    }
}

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:

1
2
3
4
5
6
7
8
9
10
11
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}
    }
}

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

1
2
3
4
5
6
7
8
9
10
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()}
    }
}

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 functions26
Methods6
Operator overload functions7
Copy attributes2
Default attributes2
Display, Debug and PartialEq functions3

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

 non-genericgenericdifference
Associated functions3626-10
Methods146-8
Operator overload functions247-17
Copy attributes42-2
Default attributes42-2
Display, Debug and PartialEq functions123-9

Hence, this approach is:

  • Much more extendable, because supporting other primitive like i32 would only require an additional:
 new primitive
Associated functions9
Methods0
Operator overload functions0
Copy attributes0
Default attributes0
Display, Debug and PartialEq functions0
  • 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:

Supertrait and Subtrait structure 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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// -- 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
    }

}

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

Supertrait, Subtrait and Type 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>:

1
2
3
4
5
6
7
8
9
// -- 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;
}

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
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

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:

1
2
3
4
5
6
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
1
2
3
4
5
6
7
8
9
10
11
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
1
2
3
4
5
6
7
8
9
10
11
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

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 functions36
Methods15
Operator overload func.14
Copy attributes0
Default attributes0
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

This post is licensed under CC BY 4.0 by the author.
Source code samples licensed under MIT or Apache-2.0