Rust newbie: Unit conversion library

Hello Rustaceans,

I've been through most of The Book, and now I'm trying something on my own to reinforce the concepts and test myself. I'm building a small library to perform various unit conversions: inches to millimeters, Fahrenheit to Celsius, that kind of thing. It's at a point where the structure is emerging, so I think it's a good time to get the opinion of the experts to help me with idiomaticness, maintainability, and efficiency.

The whole thing is checked in here. To summarize:

The different types of conversions are in their own modules:

src/length/mod.rs
src/mass/mod.rs
... etc.

I've chosen to implement each type of conversion as an enum with tuple struct variants:

#[derive(Debug, Copy, Clone)]
pub enum Unit {
    Millimeters(f64),
    Centimeters(f64),
    Meters(f64),
    Kilometers(f64),
    Inches(f64),
    Feet(f64),
    Yards(f64),
    Miles(f64),
}

I have a method to extract the numeric value:

impl Unit {
    pub fn value(self) -> f64 {
        match self {
            Unit::Millimeters(x)
            | Unit::Centimeters(x)
            | Unit::Meters(x)
            | Unit::Kilometers(x)
            | Unit::Inches(x)
            | Unit::Feet(x)
            | Unit::Yards(x)
            | Unit::Miles(x) => x,
        }
    }
    // ...
}

And the conversions from one unit to another look like this:

impl Unit {
    // ...
    pub fn to_millimeters(self) -> Unit {
        match self {
            Unit::Millimeters(x) => Unit::Millimeters(x),
            Unit::Centimeters(x) => Unit::Millimeters(x * 10.0),
            Unit::Meters(x) => Unit::Millimeters(x * 1_000.0),
            Unit::Kilometers(x) => Unit::Millimeters(x * 1_000_000.0),
            // Not fully implemented yet
            _ => Unit::Millimeters(1.0),
        }
    }
    // similar implementations for to_centimeters, to_meters, etc....
}

I also re-export the modules from the top-level:

pub use length::Unit as Length;

Am I on a good path here? The thing that concerns me about the functions is how quickly they would balloon if I introduce even one more variant. But maybe that's unavoidable no matter how I implement. Also the value method seems like there should be a more direct way to access that value, but I couldn't find it.

What about ownership? I'm still pretty fuzzy on this. I think by implementing the Copy and Clone traits I've avoided issues with that, but I'm not sure.

Thanks for any advice you can give me,
Eric

2 Likes

Another option would be to keep all of your lengths in meters internally, and convert to/from other units as needed, which means you only need to add 2 functions for each new unit:

#[derive(Copy,Clone)] 
pub struct Length(f64);

impl Length {
    pub fn inches(x:f64) -> Self { Length(x * 0.0254) }
    /* ... */

    pub fn as_cm(self)->f64 { self.0 * 100.0 }
    /* ... */
}

If you want to keep an enum to let you select the units at runtime, you can keep the conversion factors in a single method and use that:

#[derive(Copy,Clone)] 
pub enum LengthUnit {
    Inch, Centimeter, /* ... */
}

impl LengthUnit {
    fn in_meters(self)->f64 {
        match self {
            Self::Inch => 0.0254,
            Self::Centimeter => 0.01,
            /* ... */
        }
    }
}

impl Length {
    pub fn new(count: f64, unit:LengthUnit)-> Self {
        Length(count * unit.in_meters())
    }
    pub fn in_unit(self, unit:LengthUnit)->f64 {
        self.0 / unit.in_meters()
    }
}

Thanks for the suggestion.

I think the difficulty there is that I lose the unit information at runtime. Once I've made the conversion, I only have another Length value, with no way to determine whether it represents centimeters, inches, etc. I suppose if I switched to a C-style struct that contains the value & units, then maybe that would work. I'll try it out.

Another option would be to keep all of your lengths in meters internally, and convert to/from other units as needed, which means you only need to add 2 functions for each new unit

I thought about doing that, but wouldn't that lose precision as I convert back & forth?

Probably not much more than will occur when you try to represent decimal fractions (e.g., 2.713 cm) in binary floating point. Normalizing to a single scale for each unit of measure means that there are at most two such conversions:

  1. one from your scaled input unit to the corresponding normalized input storage unit, and
  2. a second from the normalized storage unit of any output to your desired scaled output unit.

Have you looked at uom, which IMO is the best-developed crate for units of measurement? Even if you choose to reinvent it with your own code, you will still learn a lot from its documentation,.

1 Like

I'm curious in you can share the advantages of uom over dimensioned? I've not tried uom. At a glance I was turned off because the documentation defines a cgs unit system that is unlike any that are in use (charge is a derived unit). But that just means the author probably isn't a physicist.

Hmm, I guess that's true. I thought it would be odd if Length::Inches(12.0).value() != 12.0, but the nature of floating points means that's fairly likely to happen even before any arithmetic. With that being the case, building off of a base unit is a lot more attractive.

I hadn't looked at any prior art. This is purely an educational exercise, not something I had planned on releasing or maintaining. But I did look at the documentation, per your suggestion. At first I was taken aback by how verbose it is to create a value, but I see now that it stems from the flexibility of the library. Eventually, I would like to implement some of the same things, like arithmetic operations between values.

Author of uom here. It's been a while since I've reviewed dimensioned in detail but I'll try to give a comparison and I apologize in advance for any errors in my comparison of dimensioned.

uom and dimensioned both provide zero-cost type-safe dimensional analysis. Both support floating point underlying storage types (f32, f64). uom also supports (to a certain degree) the other primitive types along with ratio and big number types from the num crate. Both also allow users to define their own system of units using macros. Both support no_std and serde.

uom works at the quantity level (length, mass, time, ...) while dimensioned works at the unit level (meter, kilogram, second, ...). On the uom side the intention was that you only do unit conversion at code boundaries (formatting output for a user, reading values in, ... see uom's documentation for more details). Because of how floating point precision this works well as long as your values are close to 0 of the base unit (meter, kilogram, ... these can be redefined). However one of the benefits of this design is that any quantity, even ones not yet explicitly defined can be used in uom. I eventually want to add support to uom to work at the unit level, but the effort has been in progress for a while now.

Thanks to all the amazing contributors uom has 54 quantities at this point and over 1800 distinct units! dimensioned has a couple hundred units. uom also has more complicated quantities such as thermodynamic temperature and allows for multiple quantities that have the same dimension (e.g. ratio and angle) to be used in a type safe manner.

Unfortunately due to the additional complexity in uom the compile times are pretty abysmal (I believe uom hits some kind of edge case in rustc).

Any feedback is welcome.

Regarding the cgs system you are correct that I am not a physicist. The original intention was to show a simple example of how to change the base units in uom for the SI and isn't really a CGS system definition.

1 Like