How to Implement Dimensioned Scalar Arithmetics in Rust?

Viewed 74

Rust beginner.

When trying to implement (just for learning) a dimensioned scalar system capable of the four basic arithmetics, i.e., to achieve something as follows:

fn main() {
    let length1 = 3.meters();
    //              ^^^^^^ to be implemented
    let length2 = 4.meters();
    let area = length1 * length2;
    println!("{:?}", area); // maybe something like DimensionedScalar{value: 12, unit: {meter: 2}}, where {meter: 2} means meter-square

    let temperature = 243.kelvins();
    //                    ^^^^^^^ to be implemented
    let dilation_rate = 3.2.meters() * 1.meters() * 1.meters() / 1.kelvins();
    let non_sensical = temperature + dilation_rate; // should raise error because of dimensionality mismatch
}

, I encounter a problem with the dimensionality check:

If the dimensionality check happens at compile time

In this case it seems we would need a dependent type, since the exponent of the units can be arbitrary numbers.

It the dimensionality check happens at run time

It then would require either:

  • that the operators (+ and -) to return Result type instead of the scalar type, which seems impossible with the current std::ops::{ Add, Sub } trait definitions, or
  • panicking when the check fails, which as solution is not satisfying.
Of course, there is a less cool solution

Eventually, I can of course abandon the native syntax of + and - and define my own method such as subtract() or add_with() so I can return a Result type. But this will be undoubtedly less cool.

Any suggestion?

1 Answers

A general technique is to view your n-dimensional type algebra as a vector space in ℤn, where each vector coefficient corresponds to an exponent of your unit. Your 'basis' of this vector space can be the SI units for example. Then multiplication of units becomes addition in this vector space, and division becomes subtraction. You can store the coefficients of your vector space as template parameters, this way you can ensure that addition/subtraction is implemented between values that have the same unit. To aid with this I would suggest the typenum crate. Finally you can add type aliases to make working with it sensible.

As an example with just Meter, Kelvin and Second, using a f64 as storage:

use core::marker::PhantomData;
use typenum::{Integer, Z0, P1};

#[derive(Clone, Copy)]
struct SIValue<M, K, S> {
    val: f64,
    unit: PhantomData<(M, K, S)>,
}

type Scalar = SIValue<Z0, Z0, Z0>; // Unitless - zero vector.
type Meter  = SIValue<P1, Z0, Z0>; // Basis type vector for meter.
type Kelvin = SIValue<Z0, P1, Z0>; // Basis type vector for Kelvin.
type Second = SIValue<Z0, Z0, P1>; // Basis type vector for second.

fn main() {
    let g = Meter::new(9.8) / Second::new(1.0) / Second::new(1.0);
    println!("Gravitional constant: {}", g);
    
    let mut dist = Meter::new(0.0);
    let mut accel = Meter::new(0.0) / Second::new(1.0);
    let mut t = Second::new(0.0);
    let dt = Second::new(0.001);
    let maxt = Second::new(5.0);
    while t < maxt {
        accel = accel + dt * g;
        dist = dist + accel * dt;
        t = t + dt;
    }
    println!("Distance after falling for {t:.1}: {dist:.1}");
    println!("Acceleration after {t:.1}: {accel:.1}");
}

Which prints:

Gravitional constant: 9.8 m s^-2
Distance after falling for 5.0 s: 122.5 m
Acceleration after 5.0 s: 49.0 m s^-1

Given the following implementation (or in the playground):

use core::ops::{Add, Sub, Mul, Div};
use core::fmt;

impl<M, K, S> SIValue<M, K, S> {
    fn new(val: f64) -> Self {
        Self { val, unit: PhantomData }
    }
}

impl<M: Integer, K: Integer, S: Integer> fmt::Display for SIValue<M, K, S> {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        let write_unit = |f: &mut fmt::Formatter, unit, exp| {
            match exp {
                0 => Ok(()),
                1 => write!(f, " {}", unit),
                n => write!(f, " {}^{}", unit, exp),
            }
        };
        
        write!(f, "{}", self.val)?;
        write_unit(f, "m", M::to_i32())?;
        write_unit(f, "K", K::to_i32())?;
        write_unit(f, "s", S::to_i32())
    }
}

impl<M, K, S> Add for SIValue<M, K, S> {
    type Output = Self;
    
    fn add(self, rhs: SIValue<M, K, S>) -> Self::Output {
        SIValue::new(self.val + rhs.val)
    }
}

impl<M, K, S> Sub for SIValue<M, K, S> {
    type Output = Self;
    
    fn sub(self, rhs: SIValue<M, K, S>) -> Self::Output {
        SIValue::new(self.val - rhs.val)
    }
}

impl<LM, LK, LS, RM, RK, RS> Mul<SIValue<RM, RK, RS>> for SIValue<LM, LK, LS>
where
    LM: Add<RM>, LK: Add<RK>, LS: Add<RS>,
{
    type Output = SIValue<
        <LM as Add<RM>>::Output,
        <LK as Add<RK>>::Output,
        <LS as Add<RS>>::Output,
    >;
    
    fn mul(self, rhs: SIValue<RM, RK, RS>) -> Self::Output {
        SIValue::new(self.val * rhs.val)
    }
}

impl<LM, LK, LS, RM, RK, RS> Div<SIValue<RM, RK, RS>> for SIValue<LM, LK, LS>
where
    LM: Sub<RM>, LK: Sub<RK>, LS: Sub<RS>,
{
    type Output = SIValue<
        <LM as Sub<RM>>::Output,
        <LK as Sub<RK>>::Output,
        <LS as Sub<RS>>::Output,
    >;
    
    fn div(self, rhs: SIValue<RM, RK, RS>) -> Self::Output {
        SIValue::new(self.val / rhs.val)
    }
}

If you were to try to add the wrong units, you would get an error, like if you were to do accel + dt in the above example:

error[E0308]: mismatched types
  --> src/main.rs:17:25
   |
17 |         accel = accel + dt;
   |                         ^^ expected struct `PInt`, found struct `Z0`
   |
   = note: expected struct `SIValue<PInt<UInt<UTerm, B1>>, _, NInt<UInt<UTerm, B1>>>`
              found struct `SIValue<Z0, _, PInt<UInt<UTerm, B1>>>`

Unfortunately, the error messages aren't particularly good.

Related