Rust learnings from a simple temperature converter

I recently started learning Rust by working through the online book for the Rust programming language, and I appreciate it a lot!

(Why learn Rust? I dunno, I hear it’s great for concurrency, type safety, and performance issues in distributed systems, and a lot of people in my Recurse batch are using Rust. So why not. Learning is always more fun with camaraderie.)

The Rust book explains the low-level nuances very thoroughly and socratically, and the documentation feels exceptional so far. The standard package manager / build tool, cargo, comes with a really nice command called cargo doc that generates the HTML documentation for a package and all its dependencies!

Of course, what works for me may not work well for you. My learning context:

The Rust book is pretty chock full of explanations, and is more geared toward people with experience in another strongly typed language, so I love it but it’s obviously not ideal for everyone. However, the official Rust website offers multiple beginner learning resources to cater to people whose learning style doesn’t involve reading books, which I think is extra cool of them!

At the end of Chapter 3 of the Rust book, an innocuous example exercise was offered:

Convert temperatures between Fahrenheit and Celsius.

My Python brain naively suggested that this would be a simple 5-line exercise, and of course was vastly wrong. I learned a lot more fun facts about Rust in the process than I had anticipated, and as usual, decided to lock in my learnings (at cost of 3x the labor) by writing up the process!

Parsing an input string

First, let’s take in an input string describing a temperature in either Fahrenheit or Celsius. For simplicity, I just started with positive integer values.

Regexing the input

To match a positive integer followed by F or C, we might construct this regex:

\d+ [FC]

I figured I might as well make the space optional and match the ° degree mark, in case I was pasting in a value from somewhere.

\d+ ?°?[FC]

I’m also going to match the start and end of the line, because I don’t want to validate inputs with extra junk info. In other words, 10F is valid, but foo10Fbar should not pass.

^\d+ ?°?[FC]$

Finally, I’m going to use named capture groups to match the numeric value and the F/C from the input string.

^(?P<val>\d+) ?°?(?P<scale>[FC])$

Lazy static compilation for regexes

You could just use the vanilla regex library to do the job.

However, the documentation included a helpful heads up about making sure that regexes only get statically compiled exactly once if you’re passing them around, since regex compilation is quite expensive. (Statics are sort of like global variables.) I went this route since it seemed useful to know for future reference.

There’s a lazy_static library that makes it simpler to declare lazily evaluated statics that are executed at runtime. In other words, it will make sure it’s only compiled once the first time it’s used, and the old value is globally reused thereafter.

For regexes specifically, there is also a lazy_regex library which makes things even simpler. It provides a regex! macro that wraps up the lazy static initialization out of sight for you.

let re = regex!(r"^(\d+) ?°?([FC])$");

Validating & capturing inputs

First, here are some inputs that shouldn’t match our regex.

// Test inputs that shouldn't parse, but also shouldn't panic
for s in ["10", " 10", "10 ", "10X", "foobar", "foo10Fbar", "30FFFF"] {
    assert_eq!(re.is_match(s), false, "'{s}' is not a valid input");
}

Here are some valid inputs.

// Test valid inputs
for s in ["10F", "10°F", "10 F", "10 °F", "10 C"] {
    assert_eq!(re.is_match(s), true, "failed to match '{s}'");
}

We can then extract the captured matches and unwrap them.

// Test valid inputs
for s in ["10F", "10°F", "10 F", "10 °F", "10 C"] {
    assert_eq!(re.is_match(s), true, "failed to match '{s}'");
    let cap = re.captures(s).unwrap();
    println!("'{s}'\t captures: '{}', '{}'", &cap["val"], &cap["scale"]);
}
'10F'    captures: '10', 'F'
'10°F'	 captures: '10', 'F'
'10 F'	 captures: '10', 'F'
'10 °F'	 captures: '10', 'F'
'10 C'	 captures: '10', 'C'

Temperature conversion

Great, let’s actually convert some temperatures now!

let val: u32 = cap["val"].parse().expect("Not a number!");
// F -> C
if cap["scale"] == "F" {
  let converted = (val - 32) * 5/9;
  println!("{val}°F = {converted}°C")
}
Input an integer temperature value, e.g. '50F' or '10C'
> 30F
thread 'main' panicked at 'attempt to subtract with overflow', src/main.rs:42:25
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

Oh no, overflow! Are we supposed to be using checked_sub instead of normal subtraction?? 😱

Oops, types! Let’s handle negative values.

Those of you with keener eyes for type declarations may have noticed that I messed up twice:

  1. By absent-mindedly using an unsigned int type, u32, which doesn’t reflect that the Celsius and Fahrenheit scales can have negative values
  2. By procrastinating dealing with negative numeric values

Nevertheless, I was overjoyed to bump into Rust’s overflow safety system so soon, and look forward to a future glorious day when it will surely divert me from silly errors that, in a less safe language, might otherwise wreck my day in prod.

After changing the type declaration to i32 signed ints and going back to update the regex (and test cases!) to handle negative values, everything works fine.

let re = regex!(r"^(?P<val>-?\d+) ?°?(?P<scale>[FC])$");
let cap = re.captures(&input).unwrap();
let val: i32 = cap["val"].parse().expect("Not a number!");

if cap["scale"] == "F" {
  // F -> C
  let converted = (val - 32) * 5/9;
  println!("{val}°F = {converted}°C")
} else if cap["scale"] == "C" {
  // C -> F
  let converted = val * 9/5 + 32;
  println!("{val}°C = {converted}°F")
}
Input an integer temperature value, e.g. '-50F' or '10C'
> -61F
-61°F = -51°C

Note that -61°F is actually equivalent to -51.666…C, which should round to -52°C. Since I never added float handling, we’re getting these integer values through truncation rather than rounding. In other words, the decimal part is just getting chopped off. Let’s go back and fix that now.

Float handling

The updated regex:

^(?P<val>-?\d*\.?\d+) ?°?(?P<scale>[FC])$

Yeah, no one likes reading another person’s regex. The key change: \d*\.?.

'.5C'	 captures: '0.5', 'C'
'0.5C'	 captures: '0.5', 'C'
'-1.5C'	 captures: '-1.5', 'C'

I then went back and changed everything to floating point arithmetic, which I probably should have just done in the first place. I also looked up the string formatting rules for float precision, because who ever needs more than three sig figs.

Input a temperature value, e.g. '-50F' or '10.5C'
> -61F
-61°F = -51.667°C

Absolute zero

You may notice that it’s possible to input some temperatures which are definitionally impossible in real life.

> -9000F
-9000°F = -5017.778°C

Let’s add checks for absolute zero.

const ABS_ZERO_C: f64 = -273.15;
const ABS_ZERO_F: f64 = -459.67;
> -9000F
Value must be >= absolute zero (-459.67°F)

That’s all I can think of for now

…until I get into using Rust to compile juicy homemade CLI tools, which I’m very excited for! ‘Til then, I shall delve further into the Rust book.