Rust for Beginners: How to really use Option and Result
There are some fundamental tidbits in science or mathematics that can be used to make magical things happen, and when you’re able to learn them an keep them in the back of your mind as you work, you can seem to be a wizard. I love it when I come across these things: like knowing how to use a sous vide in cooking to effortlessly prepare a brisket that’s as good as one that spent days in a painstakingly-tended smoker or knowing how the proteins in mustard seed can be leveraged as an amazing emulsifier. It’s a little bit of science, applied right to work magic.
There are a few similar nuggets in Functional Programming that I had once observed some wizard-like developers leveraged to do seemingly magical things. I instinctively dedicated myself to learning them, and in so doing, I realized why a lot of my early coding was clumsy. In this first post in a short three-part series, we are going to start with some very simple examples. I’m going to show examples of how the Rust beginner might be mis-using Option
and Result
and how to mitigate some of this clumsiness. In doing so, we’re going set the stage to unpack the really magical gemstones.
This year I’ve been diving into Rust, using the “no-std” world of Embedded Rust as my entrée into the language. As, bit by bit, I’ve started to understand the intricate ecosystem of crate frameworks and development tooling, and as I’ve built and run successively more complex projects, I’ve kept my learnings and notes and turned them into a blog series. I’ve been doing this as a way to contribute something useful to the Rust community.
As a Rust beginner, I’ve felt unqualified to write anything about the language itself. I’ve heard it said that you need to write a lot of bad Rust code for a few months before you start really understanding the language, and after writing a lot of awkward code, I believe it.
So in order to simultaneously support the community while I learn, I realized that I could build an Embedded Rust example by lifting code snippets from various library examples and assembling them systematically, bit by bit, to create a project that runs on a microcontroller, pulling readings from some sensors, initializing a TCPIP stack and sending telemetry data to a server somewhere. That’s something I felt comfortable doing, so up to this point I’ve written eight articles about it.
For the last month, I’ve been grappling with the ninth article. It was time for me to refactor my 250+ line cobbled-together-from-lots-of-examples monolithic main.rs
code into some cleaner, more organized modules. I refactored my code with some significant pain and learning along the way. But when it came to starting to write about what I had done, I realized I was pulling some of these core magical concepts from my Scala days.
Since my blog series was intended for fellow Rust-beginners, I felt obliged to try and explain what I was doing and why. I started writing some conceptual sections and found myself going back down a familiar rabbit hole…
It is time to recreate my favorite three-part Scala series for the Rust world. I hope you are willing to go down this particular rabbit hole with me. I think you’ll find the experience rewarding.
Learning about Option
Rust and Scala share a similar reputation of helping developers to write cleaner, more reliable code. When I started learning Scala, one of the first tools I learned about was Option. It was easy enough to grok the concept: you never allow null values to exist by explicitly representing the lack of information with a value of None. You were forced to consider the idea that your variable may be missing and act accordingly.
Easy peasy.
I started using Option in my code in places where I might not have something defined. Let’s say I have a sensor connected to my microcontroller and I can try to get a temperature reading with the following:
struct Reading {
sensor: &'static str,
measurement: f32,
scale: &'static str
}
impl TempSensor {
fn get_reading(&self) -> Option<Reading> {...}
}
For the sake of this example, let’s also say I’ve got a library that helps me send this reading as ongoing telemetry to some network server. (I.e. something on the edge of my IoT environment.) We’ll have a function from that library with the signature:
fn send_telemetry(measurement: Reading) -> bool {...}
To make things interesting, let’s say that send_telemetry
returns true if the packet was sent successfully. (Not the best API, but I want to hold off on using Result
for a moment to keep things simple.)
In the old days, before I’d worked in Scala or Rust, I’d learned over a dozen languages. I knew that if I leaned how loops were constructed, how functions worked, and how if/then logical control flows worked, I could pick things up pretty quickly. But I’d never worked with an Option before. It was easy enough to create an Optional value with Some(reading)
or None
, but how did I get a hold of and work with the values inside? I found myself testing for the presence of a value and, if present, I would extract it like this:
fn take_temp(sensor: &TempSensor) -> bool {
let measurement: Option<Reading> = sensor.get_reading();
if measurement.is_none() {
false
} else {
send_telemetry(measurement.unwrap())
}
}
The idea of this function is this: if either I’m unable to get a reading (sensor.get_reading()
returns None
) or I’m unable to verify successful delivery of the reading to the telemetry server (send_telemetry
returns false), I’m going to return false in my function indicated an overall lack of success. If I’m successful with both tasks, I’ll return true.
In the back of my mind, I knew that .unwrap()
was an unsafe operation that could cause my app to panic, but I had carefully tested things to make sure that wouldn’t happen. I had reasoned-through logically that it wouldn’t panic, so I considered it “safe enough” code.
Improving things with Pattern Matching
Six or seven years ago when I started learning Scala, I had heard of pattern matching with another Functional Programming language, Erlang, that I’d dabbled with, but I really didn’t understand it well and didn’t use it much. The practice has started becoming more popular, and it’s been added to some more languages, so there’s a good chance that this isn’t as new to you. In Rust, you can use matching to clean up the above code like this:
fn take_temp(sensor: &TempSensor) -> bool {
match sensor.get_reading() {
Some(measurement) => send_telemetry(measurement),
None => false,
}
}
Here we don’t have any risky .unwrap()
calls to worry about. If there’s a measurement, we can define what we do with it in the Some(measurement)
clause. In other words, this allows us to unpack the contents of the Some()
branch and write logic that operates directly on that inner measurement
.
Introducing the .map function
There’s one other way to approach this problem, and that’s with the .map(fn)
method. If you have an object of type Option<T>
and you pass a function that transforms a value of type T to another value of type S into .map
, it will apply the transformation to the contents of the Option if there are any. If the value is None instead, nothing happens.
So if I say the following:
sensor.get_reading().map(send_telemetry)
…it will transform my Option<Reading>
into an Option<bool>
where the contained boolean represents whether the network send operation was successful. Unfortunately, if I just use this statement, my possible exit values are Some(true)
, Some(false)
, and None
. I want to return just true or false representing successful measurement and delivery of the sensor data. Fortunately, there’s a convenient method on Option
called .is_some_and(fn)
that lets you provide some test operation on the Option’s contents. It both tests the presence of a value and your predicate function.
In our case, our predicate function is just the true or false value contained in our new Option<bool>
so we could write this as:
sensor.get_reading().map(send_telemetry).is_some_and(|x| x)
Rust also happens to have a built-in representation for the identity function defined in core::convert
so we can clean this up further and write the whole function like this:
fn take_temp(sensor: &TempSensor) -> bool {
sensor.get_reading().map(send_telemetry).is_some_and(identity)
}
I’m not expecting you to get excited yet about this .map
feature. Yes, I was able to consolidate everything into a one-liner, but it’s harder to read for a non-FP person. If I were writing code for this exact use case, I’d probably stick to pattern matching to make my code more readable.
But bear with me. This is just an introductory example. Our journey has just started.
Shifting to Result
Let’s improve our sensor reading code by using the more appropriate Result
type class. Specifically, we’ll rewrite the get_reading
method like this:
impl TempSensor {
fn get_reading(&self) -> Result<Reading, &str> {...}
}
For a successful reading, our value will be Ok(measurement)
with the measurement wrapped in an Ok()
wrapper instead of the previous Some()
wrapper. For an unsuccessful reading, we will assume that the get_reading
method will return an Err<&str>
wrapper with some string message of what went wrong.
It’s typical in more production-grade code to use custom Error enumerations to encompass errors, but as I’ll show later in these articles, things can get complicated when we deal with resolving type signatures. My personal advice when starting to code up something new is to start with simple descriptive strings for your errors.
Once again, I’ll start with the naive implementation of testing and unwrapping Result values:
fn take_temp(sensor: &TempSensor) -> Result<(),&str> {
let measurement: Result<Reading, &str> = sensor.get_reading();
if measurement.is_err() {
Err(measurement.unwrap_err())
} else {
if send_telemetry(measurement.unwrap()) {
Ok(())
} else {
Err("Could not deliver reading telemetry")
}
}
}
Here we have to call both .unwrap
and .unwrap_err
to get the value and error respectively from the Result container. Clearly, pattern matching gives us a cleaner rewrite:
fn take_temp(sensor: &TempSensor) -> Result<(),&str> {
match sensor.get_reading() {
Ok(measurement) => if send_telemetry(measurement) {
Ok(())
} else {
Err("Could not deliver reading telemetry")
},
Err(e) => Err(e),
}
}
Here you can clearly see our three outcomes. If we get an error from the original sensor reading, we’ll pass it through the return value.
Applying Map to the Result
Just like with Option, Result has a .map(fn)
method that lets us pass a transformation function into map. If the value is a success, then the function will just be applied to the value wrapped in Ok(...)
. In order to apply it to this use case, we have to do the following:
fn take_temp(sensor: &TempSensor) -> Result<(),&str> {
let reading: Result<Reading, &str> = sensor.get_reading();
let delivery: Result<bool, &str> = reading.map(send_telemetry);
match delivery {
Ok(successful) => if successful { Ok(()) } else { Err("Could not deliver telemetry") },
Err(e) => Err(e),
}
}
You can see that we can map our send_telemetry
function cleanly to the contents of the Result<Reading, &str>
reading, turning it into a Result<bool, &str>
, but we still have to resort to a bit of pattern mapping to transform that second possible error state (the network delivery) into a proper Err<&str>
that is expected in the function return.
This wraps up our first part of the article. For any Rust beginners out there who haven’t dealt with Options and Results or their equivalents in some other language, you now know how to avoid constant testing-and-unwrapping of values, and you can see how critical pattern matching becomes as a day-to-day tool.
In the second part of our series, we’ll dive into the hairy nesting problem that we just started seeing where we’re being forced to explicitly walk through every outcome scenario. We will also see some more compelling uses of map and it’s big brother “flat-map”.