Rust Type System: Making complex things simple.

  /   8 minutes   /   tech   rust   programming  

I want to tell you about a delightful experience I had recently, thanks to the Rust type system.

Not too long ago, I set out to write some physics code for $FUN (i.e. not $WORK). This is a story about my discovery and use of the uom crate, a library that leverages the Rust type system to make this kind of physics coding incredibly easy.

Contents

Dimensional Analysis

As you probably know, physics calculations usually involve dimensional analysis. But in case you don’t know that, I’ll explain a bit about it.

Dimensional analysis is a powerful technique used in physics, engineering, and other scientific disciplines to verify the correctness of equations, convert between units, and solve problems. It involves analyzing the units of measurement in a mathematical expression to ensure that they are consistent and make sense. The fundamental idea is that units must be consistent on both sides of an equation, allowing for the cancellation or combination of units to ensure a coherent result.

Let’s look at an example of dimensional analysis. Consider the equation for calculating the distance an object has traveled, given its initial velocity, acceleration, and the time it has been in motion:

$$ distance = initial \text{\textunderscore} velocity \cdot time + \frac{1}{2} \cdot acceleration \cdot time^2 $$

To perform dimensional analysis, we examine the units involved:

  1. \( distance \) is measured in units of length, \( L \) (e.g., meters or feet).
  2. \( initial \text{\textunderscore} velocity \) is measured in units of length per unit of time, \( \frac{L}{T} \) (e.g., meters per second or feet per second).
  3. \( time \) is measured in units of time, \( T \) (e.g., seconds or minutes).
  4. \( acceleration \) is measured in units of length per unit of time squared, \( \frac{L}{T^2} \) (e.g., meters per second squared or feet per second squared).

First, let’s translate the distance equation above into units:

$$ L = \frac{L}{T} \cdot T + \frac{1}{2} \cdot \frac{L}{T^2} \cdot T^2 $$

Now, let’s check the units on both sides of the equation, multiplying or dividing accordingly:

$$ \mathellipsis = \frac{L \cdot T}{T} + \frac{L \cdot T^2}{2 \cdot T^2} = L + \frac{L}{2} $$

Finally resulting in:

$$ L = L + \frac{L}{2} $$

Remember that we are trying to compute distance here, so we expected our result in units of length, \( L \), which you can see is exactly what we ended up with. The units are consistent on both sides, confirming that the equation is dimensionally correct.

Note: I’ve been calling these things \( L \) and \( T \) “units”; however it’d be more right to call them “quantities”. I’m just using “units” here for the sake of simplicity. From here on, I’ll use the term “quantities” and “units” more appropriately.

Now let’s consider another simple example that brings actual units into the picture, instead of focusing on just quantities as in the example above. If you know that a Newton (unit of force) is defined as \( \frac{kg \cdot m}{s^2} \), it’s plain to see that multiplying mass in \( kg \) by acceleration in \( \frac{m}{s^2} \) gives you force:

$$ F = m \cdot a = kg \cdot \frac{m}{s^2} = \frac{kg \cdot m}{s^2} = N $$

Now, that’s a simple example. Most will remember that \( F = m \cdot a \) from high-school physics. But, if for some reason you forgot that, or you just wanted to confirm that you’re getting your units right, this is a simple way to help you remember the equation and to verify your units are coherent.

While these examples are simplistic, anyone who has done a fair amount of dynamics, physics, or other engineering coding will tell you that dimensional analysis is a helpful tool in getting things right.

Hello uom crate, so nice to meet you!

I didn’t set out to find a Rust crate that did full on dimensional analysis. I just searched crates.io for libraries to help me represent my units. But when I found uom, I realized I discovered something special. The crate description says:

Units of measurement is a crate that does automatic type-safe zero-cost dimensional analysis. You can create your own systems or use the pre-built International System of Units (SI) which is based on the International System of Quantities (ISQ) and includes numerous quantities (length, mass, time, …) with conversion factors for even more numerous measurement units (meter, kilometer, foot, mile, …). No more crashing your climate orbiter!

Did you catch that? It says type-safe, automatic, and zero-cost dimensional analysis. Let’s dive into each of those.

uom is Type-Safe

So what makes uom type-safe? In short, it will not let you use incorrect units or quantities to arrive at a specific desired quantity and unit.

Let’s look at a few examples to demonstrate this. Consider this code, which is trying to add quantities of length, \( L \) and time, \( T \):

use uom::si::f64::*;
use uom::si::length::kilometer;
use uom::si::time::second;

fn main() {
    let length = Length::new::<kilometer>(5.0);
    let time = Time::new::<second>(15.0);
    let something = length + time; // error[E0308]: mismatched types
}

Since adding length and time is nonsensical, uom disallows this by not providing type compatibility between the two. This code results in the following compiler error:

error[E0308]: mismatched types
 --> src/main.rs:8:30
  |
8 |     let something = length + time; // error[E0308]: mismatched types
  |                              ^^^^ expected struct `PInt`, found struct `Z0`
  |
  = note: expected struct `Quantity<dyn Dimension<L = PInt<UInt<UTerm, B1>>, Th = Z0, Kind = (dyn Kind + 'static), N = Z0, M = Z0, J = Z0, I = Z0, T = Z0>, _, _>`
             found struct `Quantity<dyn Dimension<L = Z0, Th = Z0, Kind = ..., N = ..., M = ..., J = ..., I = ..., T = ...>, ..., ...>`
          the full type name has been written to '/Users/swaits/tmp/uomtest/target/debug/deps/uomtest-9f527559ddacdf5d.long-type-3383730111220641861.txt'

Another example: As discussed earlier, imagine we want to compute force. But, suppose we botched the input quantities completely, using velocity and time instead of mass and acceleration. Here’s a function that takes velocity and time and tries to multiply them to incorrectly arrive at a force:

fn calc_force(velocity: Velocity, time: Time) -> Force {
    velocity * time // error[E0308]: mismatched types
}

Of course, when we try this, we’re using incompatible types. Our function must return a Force, which you cannot get by multiplying velocity and time. And compiling with uom results in this error:

error[E0308]: mismatched types
 --> src/main.rs:6:5
  |
5 | fn calc_force(velocity: Velocity, time: Time) -> Force {
  |                                                  ----- expected `Quantity<(dyn Dimension<L = PInt<UInt<UTerm, B1>>, Th = Z0, Kind = (dyn Kind + 'static), N = Z0, M = PInt<UInt<UTerm, B1>>, J = Z0, I = Z0, T = NInt<UInt<UInt<UTerm, B1>, B0>>> + 'static), (dyn uom::si::Units<f64, thermodynamic_temperature = uom::si::thermodynamic_temperature::kelvin, electric_current = uom::si::electric_current::ampere, amount_of_substance = uom::si::amount_of_substance::mole, time = uom::si::time::second, mass = uom::si::mass::kilogram, length = uom::si::length::meter, luminous_intensity = uom::si::luminous_intensity::candela> + 'static), f64>` because of return type
6 |     velocity * time
  |     ^^^^^^^^^^^^^^^ expected struct `PInt`, found struct `Z0`
  |
  = note: expected struct `Quantity<(dyn Dimension<L = ..., Th = ..., Kind = ..., N = ..., M = ..., J = ..., I = ..., T = ...> + 'static), ..., ...>` (struct `PInt`)
          the full type name has been written to '/Users/swaits/tmp/uomtest/target/debug/deps/uomtest-9f527559ddacdf5d.long-type-8652923195758179394.txt'
             found struct `Quantity<(dyn Dimension<L = ..., Th = ..., Kind = ..., N = ..., M = ..., J = ..., I = ..., T = ...> + 'static), ..., ...>` (struct `Z0`)
          the full type name has been written to '/Users/swaits/tmp/uomtest/target/debug/deps/uomtest-9f527559ddacdf5d.long-type-10745726420672192385.txt'

If we correct our function as follows, it compiles fine.

fn calc_force(mass: Mass, acceleration: Acceleration) -> Force {
    mass * acceleration
}

And because it compiles, we can be confident we’ve got the quantities and equations correct!

This is as magical as it gets.

uom is Automatic

By now you probably noticed that our function calc_force() is working with quantities like mass and acceleration. In other words, it’s not concerned with units like kilograms, slugs, feet per second squared, or meters per second squared.

For example, suppose we need force in Newtons. As we showed above, a Newton is defined in units of \( \frac{kg \cdot m}{s^2} \). With uom, we don’t really care about that. We can use any units as long as they’re acceptable by the specified quantities. Here’s an example using slugs (for mass) and feet per second squared (for acceleration) to compute force in Newtons.

use uom::fmt::DisplayStyle::Abbreviation;
use uom::si::f64::*;
use uom::si::{acceleration::foot_per_second_squared, force::newton, mass::slug};

fn calc_force(mass: Mass, acceleration: Acceleration) -> Force {
    mass * acceleration
}

fn main() {
    let mass = Mass::new::<slug>(5.0);
    let acceleration = Acceleration::new::<foot_per_second_squared>(5.0);
    let force = calc_force(mass, acceleration);

    println!("Force = {}", force.into_format_args(newton, Abbreviation));
}

Here we’re using slugs and feet per second squared. And, we still get Newtons in the output:

> cargo run

Force = 111.205518 N

We didn’t care how a Newton is defined. It didn’t matter that our input units were unusual and different from our final result of Newtons. We just stuck to the right quantities, used the units we wanted, and uom figured out the rest.

What if we didn’t want force in Newtons? Maybe we want pounds force or dynes? We’d just ask uom to do the conversions for us like this:

println!("Force = {}", force.into_format_args(pound_force, Abbreviation));
println!("Force = {}", force.into_format_args(dyne, Abbreviation));

And here’s the new output:

Force = 111.205518 N
Force = 24.99999280611444 lbf
Force = 11120551.799999999 dyn

With uom, we don’t care about base units because it’s doing that automatically.

This is as simple as it gets.

uom is Zero-Cost

Look at what I’ve shown you so far in this post. Nearly every single thing we’ve talked about has happened at compile-time, not at runtime. That includes both checking compatible units and unit conversion of constants. Hence, using uom adds zero-cost to your runtime execution.

Now, conversions of anything other than constants will happen dynamically at runtime. But, they’re distilled down to a single multiplication.

That’s as cheap as it gets.

uom is Extensibile

uom comes with an enormous amount of quantities and units already supported. Want to see for yourself? Check out the source code here.

But suppose there’s a quantity you need that uom doesn’t already support? The library makes it pretty easy for you to define your own quantities.

In my dynamics coding exercise, I needed a quantity for specific power, also known as power-to-weight ratio. If you know me, you might guess this was to calculate \( \frac{W}{kg} \), a metric frequently used in the cycling world to normalize a cyclist’s performace by his or her mass.

uom didn’t support specific power. But I was able to easily add it. For details on how I did it, see this pull request, which is now merged.

Summary

When it comes to physics, science, and engineering programming, many of us have wasted countless hours hunting down bugs that stemmed from the use of incorrect or incompatible units. Through the power of the Rust type system, uom eliminates most of this pain by guaranteeing, at compile-time, you’re using the right quantities and compatible units.

And because it does base unit conversion automatically, it lets the programmer focus on quantities instead of specific units. Remembering that \( F = m \cdot a \) is much easier than remembering the specific units needed, \( kg \) and \( \frac{m}{s^2} \).

Discovering and using this crate was a magical experience for me. I was able to focus on the higher level problems I was trying to solve instead of the nitty-gritty of specific units. When I got things wrong, it simply didn’t compile, meaning it never escaped as a bug. I am confident that I arrived at correct answers much more quickly than if I hadn’t had something like uom helping me so much.

This speaks volumes about the power of Rust’s type system and how well uom is designed to take advantage of it. Ultimately, it saved me time and increased my productivity, helped me ensure correctness and eliminate large classes of bugs, and made the whole exercise a ton of fun!