The heat is on: handling units in Rust

The heat is on: handling units in Rust

Hot enough for ya? Over the last few months, beginning with Here comes the sun, we’ve been building a little weather client in Rust, and it works pretty well:

weather London,UK

Partly cloudy 12.0ºC

It’s not just a command-line tool, of course: it’s also a crate that lets anyone write a Rust program to query the Weatherstack API and get weather data.

In Where the sun keeps shinin’, we created a “provider” abstraction that makes the crate more flexible and useful. So a minimal front end for this crate might look something like:

#[derive(Parser)]
/// Shows the current weather for a given location.
struct Args {
    /// Weatherstack API key.
    #[arg(short, long, env = "WEATHERSTACK_API_KEY", required = true)]
    api_key: String,
    /// Example: "London,UK".
    #[arg(required = true)]
    location: Vec<String>,
}

fn main() -> Result<()> {
    if env::args().count() < 2 {
        Args::command().print_long_help()?;
        return Ok(());
    }
    let args = Args::parse();
    let location = args.location.join(" ");
    let ws = Weatherstack::new(&args.api_key);
    let weather = ws.get_weather(&location)?;
    println!("");
    Ok(())
}

(Listing weather_3)

Since it’s the end of term, we’re allowed to bring in games. Let’s have a little fun now by implementing a bonus feature that should give my American friends a warm feeling: showing temperatures in Fahrenheit.

Converting from Celsius to Fahrenheit is straightforward, so shall we add an option that lets users request temperatures in Fahrenheit? See what you can do.

Adding a “Fahrenheit” feature

GOAL: Make it so that users can get temperatures in Fahrenheit if they want.


HINT: It’s up to you how much engineering you want to do here. The least you could do is add a new flag to the program (--fahrenheit, for example), and convert temperatures if the flag is supplied. On the other hand, you’re welcome to explore a deeper refactoring of the program if you like. We’ll talk over both approaches once you’ve had a go at this.


SOLUTION: Throughout this series, and throughout my book The Secrets of Rust: Tools, we’ve emphasised the importance of doing the least possible work that solves the problem. It’s not that we should always stop there, of course; the point is just that if we had to stop, we’d be in a reasonable place.

A ruthlessly minimal solution

So here’s about the least I can do:

#[derive(Parser)]
/// Shows the current weather for a given location.
struct Args {
    #[arg(short, long, env = "WEATHERSTACK_API_KEY", required = true)]
    /// Weatherstack API key
    api_key: String,
    #[arg(short, long)]
    /// Report temperatures in Fahrenheit
    fahrenheit: bool,
    #[arg(required = true)]
    /// Example: "London,UK"
    location: Vec<String>,
}

fn main() -> Result<()> {
    if env::args().count() < 2 {
        Args::command().print_long_help()?;
        return Ok(());
    }
    let args = Args::parse();
    let location = args.location.join(" ");
    let ws = Weatherstack::new(&args.api_key);
    let weather = ws.get_weather(&location)?;
    println!(
        "{}",
        if args.fahrenheit {
            weather.into_fahrenheit()
        } else {
            weather
        }
    );
    Ok(())
}

We can’t do the conversion right here in main, because the temperature field isn’t public. Anyway, we don’t really want to do that, because, as we know, any code we write in main is not importable. To really add a Fahrenheit feature to this program, we need to add it to the crate, which is why we invoke a new magic method on Weather: into_fahrenheit.

An into_fahrenheit method

We’ll say that into_fahrenheit takes a Weather and returns a replacement Weather whose temperature is expressed in Fahrenheit:

#[test]
fn into_fahrenheit_fn_correctly_converts_temperature() {
    let weather = Weather {
        temperature: 10.0,
        summary: "Partly cloudy".into(),
    };
    assert_eq!(weather.into_fahrenheit(), Weather {
        temperature: 50.0,
        summary: "Partly cloudy".into(),
        "wrong weather"
    });
}

And here’s the code:

impl Weather {
    #[must_use]
    pub fn into_fahrenheit(mut self) -> Self {
        self.temperature = self.temperature * 1.8 + 32.0;
        self
    }
}

Fine. Not great, but fine. So what’s unsatisfactory about this solution?

Dealing with quantities

For one thing, it’s strange that into_fahrenheit is a method on Weather, because Fahrenheit is a unit of temperature, not weather. It’s also slightly annoying that, given a Weather instance with unknown provenance, we don’t actually know what units its temperature is in. Maybe it’s fresh from the API, in which case it’s Celsius, or maybe someone already called into_fahrenheit on it, in which case it isn’t.

And if we were to call into_fahrenheit again on a value that’s already been converted once, the result would just be wrong. Since we have no way of knowing what units a given Weather is in, we can’t tell if it’s safe to call into_fahrenheit on it or not.

The real problem here is that temperature is not just a pure number, it’s a quantity—that is, a measurement in some system of units. To unambiguously express a temperature, we need the units as well as the number. Rust’s built-in types don’t allow for that: f64 expresses the numeric part, but it has no opinion about what units the user might have in mind.

So, while our current solution does work, after a fashion, it’s worth exploring what we’d do if we had the time to work on this a bit more. What do you think?

The “newtype” pattern and tuple structs

Well, it sounds as though we’re arguing that a conversion method, if we had one, should be on the temperature value itself, rather than the containing Weather struct. But the temperature field is f64, and we can’t add methods willy-nilly to f64: it’s not our house to build an extension on.

A common trick in Rust when you want to add methods to somebody else’s type is to wrap it in your own type, like this:

struct Celsius(f64)
struct Fahrenheit(f64)

Now we have a true quantity, because we can create a value of type Celsius, which contains an f64, but adds information to it: the type itself conveys the unit system.

We haven’t seen this kind of struct definition before, where we don’t give names to the fields, but Rust allows it: it’s called a tuple struct. We’re defining Celsius and Fahrenheit as structs with a single un-named field of type f64, which will hold the actual numeric value.

This way of wrapping an existing type is called the newtype pattern, and it would indeed let us add methods to convert temperatures from one unit system to another. We could write, for example:

impl Celsius {
    pub fn into_fahrenheit(mut self) -> Fahrenheit { ... }
}

Making Weather unit-agnostic

But this still doesn’t seem quite right. For one thing, which type should we declare for the temperature field on Weather?

pub struct Weather {
    temperature: ..., // what goes here?
    summary: String,
}

Celsius might seem to make sense, since that’s what the API gives us, but then if users want to convert that temperature to Fahrenheit, they can no longer store the result in a Weather struct.

We’d like users to be able to deal with Weather values regardless of temperature units. Indeed, we’d like to make temperature values independent of a particular unit system. After all, the temperature is what it is, no matter what units you choose to express it in.

A Temperature struct with internal units

So what does that look like in Rust? Well, our intuition seems to be guiding us towards defining a Temperature type:

pub struct Temperature(f64);

(Listing weather_4)

Again, we’re defining Temperature as a tuple (that is, list) of one element: an f64 representing the temperature value. Since there’s only one field, it hardly needs a name, though you’re welcome to give it one if you like:

pub struct Temperature {
    celsius: f64,
}

It comes to the same thing. The second version makes it clearer that the “internal” unit is Celsius, but we’re going to hide that from users altogether. We want them to be able to take some f64 value in Celsius, and use it to create a unit-agnostic Temperature:

impl Temperature {
    #[must_use]
    pub fn from_celsius(val: f64) -> Self {
        Self(val)
    }

(Listing weather_4)

Converting between units

We also need to provide a way for them to express that temperature in their units of choice:

#[must_use]
pub fn as_celsius(&self) -> f64 {
    self.0
}

#[must_use]
pub fn as_fahrenheit(&self) -> f64 {
    self.0 * 1.8 + 32.0
}

(Listing weather_4)

Notice that where we would ordinarily give a field name, with a tuple struct we give the field number instead. And because the numbering starts from zero, and there’s only one field, then it must be field number 0:

self.0

If you prefer the “named field” version, you should replace this with self.celsius.

Testing conversions

Let’s check our conversions with a test, then:

fn temperature_can_be_expressed_as_celsius_or_fahrenheit() {
    let temp = Temperature::from_celsius(10.0);
    assert_eq!(temp.as_celsius(), 10.0, "wrong celsius");
    assert_eq!(temp.as_fahrenheit(), 50.0, "wrong fahrenheit");
}

(Listing weather_4)

We need to make a few tweaks to our existing code to cope with this change. For example:

#[test]
fn deserialize_extracts_correct_weather_from_json() {
    let json =
        fs::read_to_string("tests/data/ws.json").unwrap();
    let weather = deserialize(&json).unwrap();
    assert_eq!(
        weather,
        Weather {
            temperature: Temperature::from_celsius(11.2),
            summary: "Sunny".into(),
        },
        "wrong weather"
    );
}

(Listing weather_4)

By calling from_celsius to create the Temperature, it’s clear right away that this temperature is in Celsius. (We could also have written a from_fahrenheit function, but we don’t need it for this program.)

Handle units and quantities with care

In general, whenever you deal with quantities in your Rust programs, it’s a good idea to use the type system like this to avoid potential confusion over units. We want to eliminate possible mistakes such as passing a distance in metres where a distance in feet was expected, or adding a force in pounds to a force in newtons. Rust gives us the tools to prevent disasters like that of the Mars Climate Orbiter, so let’s use them.

It’s also worth noting that quantities can have some subtle constraints and problems that aren’t always obvious. For example, our Temperature type is fine for expressing absolute temperatures, such as that experienced in London on a sunny day.

But it wouldn’t be suitable for expressing differences in temperature, such as saying that New York City is one Celsius degree warmer than the seasonal average. Converting that Temperature to Fahrenheit would give the wrong answer! So be careful, especially if your code needs to work on Mars (and you never know when someone might want to use it there).

Formatting temperatures for display

Okay, so what about displaying these temperatures? The version of Display we had before won’t work, because it assumes the temperature is Celsius:

impl Display for Weather {
    fn fmt(
        &self,
        f: &mut std::fmt::Formatter<'_>,
    ) -> std::fmt::Result {
        write!(f, "{} ºC", self.summary, self.temperature)
    }
}

(Listing weather_3)

But that’s sort of expected, because we’re saying that a given Weather isn’t in any particular temperature units. It’s up to the user to choose these by giving the --fahrenheit flag (or not), so let’s move this decision into the main function:

println!(
    "{} {}",
    weather.summary,
    if args.fahrenheit {
        format!("ºF", weather.temperature.as_fahrenheit())
    } else {
        format!("ºC", weather.temperature.as_celsius())
    }
);

(Listing weather_4)

Note that the temperature conversion code is still part of our crate, where it can be imported and used in other programs. It’s only the decision to convert the temperature that lives in main, where it arguably belongs—together with the choice about how to print it.

This means making the fields on Weather public, but that’s okay: we’re only exposing the data, not the logic.

Drawing the line

There’s more we could do here, but part of the skill of abstraction design is knowing when to stop—at least for now. While crate-oriented design is great, we should bear in mind that we’re not here to write a general-purpose crate for every imaginable weather-related program. A good rule of thumb is to build just the functionality that we need for our program, while not closing off any avenues for future expansion.

We’ve covered a lot of ground in this tutorial series, starting with Here comes the sun, and learned quite a few simple techniques for building and testing Rust API clients. That greatly expands the scope of tools we can write, because we can talk to any other program that exposes an HTTP interface.

If you’d like to learn more about writing practical programs in Rust, check out my book The Secrets of Rust: Tools: you’ll find dozens of code challenges to solve and working code samples to try. I hope you enjoy it!

Dancing backwards with Go: why TDD is weird

Dancing backwards with Go: why TDD is weird

0