Learning Rust Part 1: A kitten's guide to Options and Results
To unwrap() or not to unwrap(), that is the question:
So I've finally given in and started to learn Rust last month. It's a really cool programming language, with some interesting differences to what I've used before. (JavaScript and Python, mostly)
There are some really pawesome guides out there, "The Rust programming language" is definitely a must-read in my opinion, and Rustlings is nyamazing for anyone who likes to learn by actively working through interactive problems.
After reading through a lot of those big thorough guides by experienced Rust developers, I've started working on
my first actual Project. I approached the development of this project by just trying to get small parts of it
working in any way I can manage, and then build upon this. In that process, I learned a lot of small subtilties that
guides like the ones named above just can't really cover. This post is for sharing those things, those cool little
tips to make your first Rust project just a little cleaner and more Rust-y. Originally I wanted to make this about a lot
of different topics, but then I've realized that my notes already contain so many things about just one part of Rust:
The Enums Option
and Result
. So this post will be about those, and hopefully will mark the start of a series on this blog.
While reading through this, you might think that the things I'm mentioning are obvious. That's okay, and that's the point. Nothing is ever completely obvious to everyone, and this is for those like me, who often don't immediately recognize the "obvious". And, to be honest, I am writing this just as much for myself, writing all of that stuff down to aid me in my own ongoing learning process.
So, let's start!
Firstly, a very quick introduction. Option
and Result
are part of the Rust standard library. Quoting the official documentation
is probably the easiest way to summarize their purpose:
Type
Option
represents an optional value: every Option is eitherSome
and contains a value, orNone
, and does not.Option
types are very common in Rust code, as they have a number of uses:
- Initial values
- Return values for functions that are not defined over their entire input range (partial functions)
- Return value for otherwise reporting simple errors, where
None
is returned on error- Optional struct fields
- Struct fields that can be loaned or “taken”
- Optional function arguments
- Nullable pointers
- Swapping things out of difficult situations
and
Result<T, E>
is the type used for returning and propagating errors. It is an enum with the variants,Ok(T)
, representing success and containing a value, andErr(E)
, representing error and containing an error value.
At first, it seems so easy to just add a quick .unwrap()
after every Option
or Result
, but this comes with
the disadvantage of your code panicking if it fails to unwrap.
Sometimes, this can be useful during development, to discover potential error cases you might not have thought about, but
is usually not what you want to happen.
So, what can you do instead?
First of all, don't use unwrap()
unless you are completely sure that the value will never panic. Sometimes that is the
case, because an earlier part of your code already made sure that it is Ok
or Some
.
In some cases, you actually want the program to panic. But even then, there is a slightly better way. You can use expect("foo")
to add a message to the panic, so the user actually knows what went wrong. That message should be worded in a specific way,
basically telling the user what you expected to happen.
fn main() {
let x = Some("Hello, World");
// There is no actual "None" type/value in Rust,
// this "None" is more specifically Option::None
let y: Option<String> = None;
let z = x.unwrap(); // We explicitly set this to Some, so we can safely unwrap it
let rip = y.expect("expected y to not be None");
println!("{}, {} will never print because it panics above", z, rip);
}
There are also the non-panicking siblings of unwrap()
, like unwrap_or()
, unwrap_or_else()
and unwrap_or_default()
.
fn main() {
let a: Option<String> = None;
let b: Option<i32> = None;
let c: Option<bool> = None;
// unwrap_or() lets you supply a specific value to use if the Option is None
let x = a.unwrap_or("Hello there".to_owned());
// unwrap_or_default() uses the types default value if the Option is None
let y = b.unwrap_or_default();
// unwrap_or_else() lets you specify a closure to run if the Option is None
let z = c.unwrap_or_else(|| if 1 + 1 == 2 { true} else { false });
assert_eq!(x, "Hello there".to_owned());
assert_eq!(y, 0);
assert_eq!(z, true);
}
And then there is this really cool question-mark operator, which comes in very handy once you go multiple functions deep
and keep having to work with more and more Result
s and Option
s. The way it works is that, if you have a None
or an Error
,
it passes up the handling of this one level higher, by returning out of the function early with a None
or Error
value itself.
Of course, since return types of functions have to be known at compile time, the question-mark operator only works inside
functions that already return Result
or Option
.
fn main() {
let x = 5;
let y = 10;
let z = 20;
match do_something(x, y, z) {
Some(result) => println!("Happy noises, {}", result),
None => println!("Sad noises"),
}
}
fn do_something(x: i32, y: i32, z: i32) -> Option<i32> {
let first_result = do_something_more(x, y)?;
let second_result = do_something_more(first_result, z)?;
Some(second_result)
}
fn do_something_more(x: i32, y: i32) -> Option<i32> {
Some(x + y)
}
The advantage of this is that you only have to handle your None
case exactly once. You don't have to add pattern matching, or
conditionals, or unwrap()
s all over the place, just a cute little question mark that delegates the handling to some logic
higher up.
"But sammy!" you say, "the compiler keeps shouting at me when I use the question mark on Options when my function returns Result `<-._.->´"
Don't worry my frien! Even this has been considered!
First of all, why does the compiler get upset? It's because the question-mark operator returns the same type that it's used on,
and Result
and Option
are different types. Because of that, I thought I'd have to manually handle None
cases in all
of my Result
-returning functions. Until one day, I was reading through some documentation (I know, I know, I'm a nerd who reads
through documentation for fun and not just to find specific things) and discovered Option::Ok_or().
Transforms the
Option<T>
into aResult<T, E>
, mappingSome(v)
toOk(v)
andNone
toErr(err)
.
This was a life-changer to me, and it was just hiding right there in plain sight. Now I can easily turn a None
where there shouldn't
be a None
into an Error
to pass up with my pawesome question-mark operator!
fn main() -> Result<(), String> {
let x = function_that_returns_option().ok_or("error message".to_owned())?;
// Instead of:
// let x = function_that_returns_option().unwrap();
// or any of the other ways to handle None
assert_eq!(x, ());
Ok(x)
}
fn function_that_returns_option() -> Option<()> {
return Some(());
}
The last thing I want to mention is both an example specific to Option
s, and a more general tip about how I discovered this one.
There is this wonderful friend for all Rust developers, called Clippy. No, not the Paperclip from Microsoft Word, but
A collection of lints to catch common mistakes and improve your Rust code.
Clippy is automatically installed when you install Rust via rustup
, and it runs a whole lot of checks against your code
to tell you what you can improve.
In my case, I had the following piece of code:
let insert = (
tracks::title.eq(match tag.title() {
Some(title) => Some(title.to_string()),
None => None,
}),
tracks::track_number.eq(match tag.track() {
Some(track) => Some(track as i32),
None => None,
}),
tracks::disc_number.eq(match tag.disk() {
Some(track) => Some(track as i32),
None => None,
}),
tracks::path.eq(match path.to_str() {
None => return Err(Error::msg("Could not get path")),
Some(path) => path.to_string(),
}),
tracks::year.eq(match tag.year() {
Some(year) => Some(year as i32),
None => None,
}),
tracks::album_id.eq(match album {
Some(album) => Some(album.id),
None => None,
}),
);
This code builds an insert statement for the database holding my music metadata, getting the values from the tags of a file.
The tag fields are all Option
s, since the tags might be empty. The databse entries are also all Option
s, (at least on the Rust side,
on the database they are just values marked as possibly being Null). So my intuitive idea to build this was to just go through all the entries,
match the tag, put in Some(value)
if there is a value, and None
if there is none.
It works, it's not wrong, but there is a cleaner and more readable way to do this. And clippy told me right away, I ran it from my IDE, and it told me:
Manual implementation of
Option::map
Huh, okay. Let's check the documentation
Maps an
Option<T>
toOption<U>
by applying a function to a contained value.
So basically exactly what I did with those match
statements!
My IDE even had a button to just easily fix this automatically with one click:
let insert = (
tracks::title.eq(tag.title().map(|title| title.to_string())),
tracks::track_number.eq(tag.track().map(|track| track as i32)),
tracks::disc_number.eq(tag.disk().map(|track| track as i32)),
tracks::path.eq(match path.to_str() {
None => return Err(Error::msg("Could not get path")),
Some(path) => path.to_string(),
}),
tracks::year.eq(tag.year().map(|year| year as i32)),
tracks::album_id.eq(album.map(|album| album.id)),
);
Great, that looks a lot cleaner immediately!
Note how one of the lines was not changed, that's because that one sets a DB value which is NOT NULL
, thus if the original Option
is
a None
it means something went wrong, and we should abort this insert and return with an Error.
And with that, we're done with my first blogpost about Rust, with hopefully many more to come! As I said, I am still learning, and writing this is part of my learning process. That being said, if you find this interesting, learned something from it, etc., feel free to leave me some feedback! I'd love to hear what you think! And if I made mistakes, please also tell me. I'm always happy to learn more and to fix those mistakes so others can learn from them too.
Thank you so much for reading 💜