Igor Proskurin
Posted on May 27, 2024
Some time ago, I decided to write a couple of posts about my experience with generic programming in Rust. For me, when someone says generics, C++ template metaprogramming and Alexander Stepanov immediately pop in my mind. Rust is different. So it was interesting to see what's going on out there, which motivated my original interest.
Learning by doing is the best for quick hands-on, so I decided to write a little generic numeric library for manipulating Bezier curves (polynomials in the Bernstein basis) that is (i) uses static dispatch (and no heap allocation calls), and (ii) can be used with different types for specifying Bezier control polygon: reals, rationals, complex, and, in general, something that implements standard vector-space operations.
What I learned is that writing generic libraries in Rust is not a piece of cake. There are two major facts that contribute into this: (i) Rust's explicit safety-oriented type system, and (ii) the absence of decent support of generic constant expressions in stable Rust. So what I am writing about here is based on Rust Nightly.
So before we take of, there is a couple of previous posts
- Generic constant expressions: a future bright side of nightly Rust.
- Experimenting with generics in Rust: little library for Bezier curves - part 1.
And the Github repo that contains examples from these posts, some tests, and maybe even some demos, if I will manage to add them in nearby future.
First steps
First, make sure we use the unstable rust build. I will just set it up for all repositories with rustup default nightly
, but it is possible to apply it to a folder with rustup override set nightly
. After that:
PS > rustc --version
rustc 1.80.0-nightly (1ba35e9bb 2024-05-25)
and we also make sure that we include the following lines in our crate
#![allow(incomplete_features)]
#![feature(generic_const_exprs)]
As I briefly outlined here, Rust currently does not support generic constant expression in its type system. It is not that something is wrong with it in general, it is just not implemented due to technical difficulties. This is considered to be an unstable feature.
To describe a generic Bezier curve c(u) = (x(u), y(u), z(u), ...)
, I basically wrap a primitive array type [T; N]
into a struct
pub struct Bernstein<T, U, const N: usize> {
coef: [T; N],
segm: (U, U),
}
that contains a generic type parameter T
for Bezier control polygon, type parameter U
for curve parametrization, and a generic constexpression parameter N
for the number of basis polynomials (or just the size of the Bezier control polygon). For example, a cubic Bezier curve should have four points in its control polygon, i. e. N = 4
. The curve is always parameterized on 0 <= u <= 1
, so right now segm
is defaulted to (0, 1)
.
Some more details can be found in my previous post. In this post, I would like to discuss how implement generic methods on this type that would leverage generic constant expressions on the size of the control polygon N
.
Implementing eval-method
The first method to do is, of course, to implement eval()
method to find a point on the curve at some value of the parameter u
, so that we can write something like this
let p0 = Complex::new(0.0, 0.0);
let p1 = Complex::new(2.5, 1.0);
let p2 = Complex::new(-0.5, 1.0);
let p3 = Complex::new(2.0, 0.0);
// Define cubic Bezier curve in the complex plane.
let c: Bernstein<Complex<f32>, f32, 4> = Bernstein::new([p0, p1, p2, p3]);
let p = c.eval(0.5); // point on a curve of type Complex<f32>
This part is easy, and can be done even in stable Rust. I just use the De Casteljau's algorithm
impl<T, U, const N: usize> Bernstein<T, U, N> where
T: Copy + Add<T, Output = T> + Sub<T, Output = T> + Mul<U, Output = T>,
U: Copy + Num,
{
pub fn eval(&self, u: U) -> T {
// -- snippet --
// De Casteljau's algorithm
}
}
Trait bounds on types are quite transparent. I require Copy
trait to make my life easier when manipulating mathematical expressions, type U
should be a number, which is required by num::Num
trait. This is especially useful because Num
trait requires generic One
and Zero
traits, which provide methods such as U::zero()
and U::one()
. Type T
is required to implement vector space operations of addition, subtraction, and right-hand-side multiplication by a variable of type U
with the result of being of type T
(Mul<U, Output = T>
).
Implementing diff and integ methods
The next step is to implement generic diff()
and integ()
methods to find the parametric derivative of the Bezier curve dc(u)/du
, and the integral with respect to parameter u
. That's where generic constant expression come into play.
The problem is that our methods should take an array of control points [T; N]
of size N
as an input, and return an array of size N - 1
for diff()
or N + 1
for integ()
as output nicely wrapped into our custom Bernstein
type so that the signatures of the functions should be like these:
fn diff(&self) -> Bernstein<T, U, {N - 1}> {}
// c: T -- is the initial point to fix the constant of integration.
fn integ(&self, c: T) -> Bernstein<T, U, {N + 1}> {}
And stable Rust does not allow us to do that. Using generic_const_exprs
feature, it becomes possible, as we shall see shortly.
Another difficulty is related to Rust's explicit type system. In these methods, the size of the array N
becomes a part of mathematical expressions in the diff()
and integ()
algorithms. Rust requires the size of the array to be of a machine-dependent pointer size usize
(which totally make sense but it is not generic). Converting from usize
to other type is not considered to be a safe operation so I have to rely on third party traits for that purpose, such as num::FromPrimitive
trait that is implemented for usize
in the num
crate. Otherwise, multiplying and expression of type, let's say, f64
, by N
is not defined.
Having this in mind, let's discuss diff()
method (implementing integ()
is similar and may be found in the repo):
impl<T, U, const N: usize> Bernstein<T, U, N> where
T: Copy + Add<T, Output = T> + Sub<T, Output = T> + Mul<U, Output = T>,
U: Copy + Num + FromPrimitive,
{
pub fn diff(&self) -> Bernstein<T, U, {N - 1}> where
[(); N - 1]:
{
let coef: [T; N - 1] = array::from_fn(
|i| -> T {
(self.coef[i + 1] - self.coef[i]) *
(U::from_usize(N - 1).unwrap() / (self.segm.1 - self.segm.0))
}
);
Bernstein {
segm: self.segm,
coef: coef,
}
}
}
Here, there is a couple of new details. First, I require type U
to be bounded by FromPrimitive
trait that allows to convert from usize
to U
in a generic environment by calling U::from_usize(N - 1).unwrap()
. Second is that there is new bound [(); N - 1]:
which is required by generic_const_exprs
feature
We currently use where [(); expr]: as a way to add additional const wf bounds. Once we have started experimenting with this it is probably worth it to add a more intuitive way to add const wf bounds.
The bounds on T
type is basically the same as in the eval()
method.
Now, we can find a derivative type from a basic type, by using for example the following
// Define cubic Bezier curve in the complex plane.
let c: Bernstein<Complex<f32>, f32, 4> = Bernstein::new([p0, p1, p2, p3]);
// Get the derivative, or hodograph curve at u = 0.2.
let d = c.diff().eval(0.2); // `d` is of type Complex<f32>
Generic product of two polynomials
The next example is a product of two polynomials of order N - 1
and M - 1
(the size of arrays of coefficients is N
and M
respectively). This is a little bit more involved since it has to take the array [T; N]
as an input, multiply it by [T; M]
and the type of output should be [T; M + N - 1]
. For example, multiplying a polynomial of the third order (N = 4
) by a second-order polynomial (N = 3
) should give a quintic polynomial (N = 6
).
The implementation may looks like this:
impl<T, U, const N: usize, const M: usize>
Mul<Bernstein<T, U, {M}>> for Bernstein<T, U, {N}> where
T: Copy + Add<Output = T> + Sub<Output = T> + Mul<Output = T> + Mul<U, Output = T>,
U: Num + FromPrimitive,
[(); N]:,
[(); M]:,
[(); N + M - 1]:
{
type Output = Bernstein<T, U, {N + M - 1}>;
fn mul(self, rhs: Bernstein<T, U, {M}>) -> Self::Output {
let mut coef = [self.coef[0] - self.coef[0]; N + M - 1];
// -- snippet --
// actual algorithm
Bernstein {
coef: coef,
segm: self.segm,
}
}
}
The required operation is specified by Mul<Bernstein<T, U, {M}>>
in the impl
, and the resulting type should be type Output = Bernstein<T, U, {N + M - 1}>
.
Another subtle moment is that I have to initialize the array in the body of the function mul()
because Rust does not allow to use uninitialized variables (remember old plain C89? -- who cared about initializing all the variables). One way to do it is to put a trait bound on T
to implement T::zero()
that can be used as an initial value. In this case, I chose a workaround instead (may change it later) which is to require subtraction Sub<Output = T>
and use self.coef[0] - self.coef[0]
as a kind of generic zero.
Note that generic-const-exprs
require additional trait bounds to be imposed for each of the array types we use [(); N]:
, [(); M]:
, [(); N + M - 1]:
.
Now, it possible to write
let p: Bernstein<f64, f64, 3> = Bernstein::new([0.0, 2.0, 1.0]);
let q: Bernstein<f64, f64, 4> = Bernstein::new([1.0, 2.0, 0.0, 0.0]);
// Quintic polynomial with real coefficient
let c = p * q;
Summary
Generic constant expressions in Rust give the flexibility of implementing generic types which size is known at compile time. So far, using an unstable Rust nightly 1.80, I didn't notice any issues with using generic-const-exprs
feature.
Posted on May 27, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.