How to Use Rust Traits, Generics and Bounds
Rodrigo Santiago
Posted on September 14, 2022
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:
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:
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:
- No generics, no traits
- 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,
}
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
}
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,
}
}
}
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,
}
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
}
}
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}
}
}
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()}
}
}
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
- Leverages the
-
More ergonomic, because it:
- Has less developer friction: declaring a new Vector is
let v = Vector3::new(...)
instead oflet v = Vector3F64::new(...)
orlet 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
- Has less developer friction: declaring a new Vector is
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
:
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
}
}
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
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;
}
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
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:
- It forces thinking twice and hard under which scope it makes logical sense to add a new capability.
- Once defined, the compiler will make sure that the capability is implemented everywhere it needs to be implemented.
- 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
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
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 forAdd
,AddAssign
,Sub
,SubAssign
,Mul
,Div
andNeg
fori64
.
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
Posted on September 14, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.