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(())
}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);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)
}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
}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.0If 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");
}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"
);
}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)
}
}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())
}
);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!




