Rust for C++ developers part 5: Option and Result
There are two interesting types in Rust which are implemented with enums (check the previous
post for more about enums).
They are Option
and Result
. Let's start with Option
.
Option
Option
represents some value which might or might not exist. This is achieved via an enum with two
variants (a bit simplified definiton of Option
from std
):
enum Option<T> { /// No value. None, /// Some value of type `T`. Some(T), }
<T>
is a syntax for generics which are similar to the templates in C++. We haven't talked about
them so far but you can think about T
as some type which the compiler sets during compilation. For
example Option<u32>
will be expanded to:
enum Option<u32> { /// No value. None, /// Some value of type `u32`. Some(u32), }
Now back to Option
. Option<u32>
means that there is a u32
value which might or might not
exist. If it doesn't exist the enum's variant will be None
. If it does exist - it will be Some
and the actual value will be bundled with the variant. What does a "value doesn't exist" mean in
practice? Imagine you are writing a function which looks up a key in a hash map and returns it
value. This is a perfect candidate for Option<T>
. If the key exists in the hash map the function
will return Some(value)
. If it doesn't it will return None
.
Semantically None
is not considered an error. A value not found in a container is a valid case but
yet it needs to be handled. And Option
does exactly that.
Now let's see how we can use Option<T>
. Here is a very naive function, finding the square root of
the numbers up to 100:
fn find_square_root(input: u32) -> Option<u32> { for i in 1..10 { if i * i == input { return Some(i); } } None }
The function is really naive and inefficient but let's focus on how Option
is used. First check
the signature - input
is u32
and the function returns Option<u32>
. Ideally this function
should be generic but again it is just a demonstration.
Then our naive algorithm calculates the squares of the numbers of 1 to 10. If any of them matches
the input we return Some(i)
because this is our square root. If there is no match the loop ends
and we return None
.
Note that we use Some(i)
not Option::Some(i)
. Of course we can use the latter but
Options
are widely used so their variants are directly included by default.
Now let's see how we can use our dummy function:
fn main() { match find_square_root(25) { Some(sqrt) => println!("Found square root: {}", sqrt), None => println!("No square root found"), } }
We examine the return value of the function with a match expression and handle both cases. In the
match arm handling Some
we use destructuring to extract the actual result from the enum. With
Option
we can hardly forget to handle the missing value case. A typical error might be to call the
function and try use the return value without examining it. For example:
fn main() { let sqrt = find_square_root(25); println!("SQRT x 2 = {}", sqrt * 2); }
This won't compile:
error[E0369]: cannot multiply `Option<u32>` by `{integer}` --> src/main.rs:13:36 | 13 | println!("SQRT x 2 = {}", sqrt * 2); | ---- ^ - {integer} | | | Option<u32> For more information about this error, try `rustc --explain E0369`.
Of course the compiler doesn't know what we want to do but the error message should be enough to
figure out we are dealing with an Option
instead of an actual value.
Furthermore Option
type has got a lot of convenience methods implemented but I won't cover them
now. Have a look at the Option
documentation
here for more details.
Result
Error handling in Rust is usually accomplished via the Result<T, E>
type which is also an enum.
Its purpose is to represent an operation which might fail. The enum has got two variants - Ok
(for
the success case) and Err
for the error case. Its simplified definition looks like this:
pub enum Result<T, E> { /// Contains the success value Ok(T), /// Contains the error value Err(E), }
T
represents the result type and can be anything. E
is the error type which usually implements
std::error::Error
but this is not mandatory. You can represent the error with a whatever type you want - a
string, an integer, other custom type. For the compiler it doesn't matter.
Let's see Result
in action. Our square root example is not ideal in terms of error handling. For
example if we pass a number which has got a square root but it is above 100 we will return None
.
This is bad because the caller will think that the number hasn't got a square root which might not
be true. Let's handle this case better with a Result
:
fn find_square_root(input: u32) -> Result<Option<u32>, String> { if input > 100 { return Err("Only numbers between 1 and 100 are supported".to_string()); } for i in 1..10 { if i * i == input { return Ok(Some(i)); } } Ok(None) } fn main() { match find_square_root(25) { Ok(Some(sqrt)) => println!("Found square root: {}", sqrt), Ok(None) => println!("No square root found"), Err(e) => println!("Error: {}", e), } match find_square_root(400) { Ok(Some(sqrt)) => println!("Found square root: {}", sqrt), Ok(None) => println!("No square root found"), Err(e) => println!("Error: {}", e), } }
The first change is the signature of find_square_root
. Now it returns
Result<Option<u32>, String>
. You might wonder why? What's the point in returning an Option
wrapped in a Result
? Just semantics. Remember that Option
doesn't represent an error but a
missing value. Result
on the other hand represents an error condition. So mixing them makes
perfect sense. You will see this pattern a lot in Rust code bases.
Next let's talk about the error type we use. In our case it is just a string because a simple error
message is enough for our example. In a bigger project you will probably see a type implementing
std::error::Error
but that would be an overkill here.
Now let's move to the function body. First we validate the input. If the number is bigger than 100
we return Err("Only numbers between 1 and 100 are supported".to_string())
. This is the Err
variant of the enum which has got a bundled String
. We don't need to explicitly specify the enum
type (Result::Err(..)
). Like Option
, Result
is commonly used so its enum variants are already
imported.
Next note that we return Ok(Some(i))
in the happy case (when there is a square root). Ok
is the
success variant of Result
, Some
is "there is a value" variant of Option
. If there is no square
root the loop finishes and we return Ok(None)
. Ok(Some(i))
means "There was no error AND the
input has a square root". While Ok(None)
means "There was no error BUT the input hasn't got a
square root". These are just semantics. For your application you might decide that an input
without a square root is an error and use Result<u32, String>
as a return type. Then you can return
Err
if the square root doesn't exist. This is fine as long as it makes sense for your program. But
otherwise you should go for Result<Option<>>
because it clearly separates the error case.
Finally let's see how we can handle errors in main
. It's done with a match expression of course.
We have got three arms for the thee cases - happy case, no square root case and error case. We
handle the error case in the third match arm - Err(e)
where e
is the error message (the bundled
String
).
Also note how we destructure the Option
encapsulated within a Result
- Ok(Some(sqrt))
. Another
example for the convenience of the match expressions.
Result also has got a lot of convenience methods implemented. To learn more about them check out its documentation.
Operator ?
Let's pretend that the input validation of our square root example is not that simple and we want to extract it in a separate function. It will be something like this:
fn sanitize_input(input: u32) -> Result<u32, String> { if input > 100 { return Err("Only numbers between 1 and 100 are supported".to_string()); } Ok(input) }
Note that here we have got a missing value to handle so we don't need an Option
. We just return
Result<u32, String>
. Now find_square_root
needs to call this function and handle the error. An
unoptimal way to do it is:
fn find_square_root(input: u32) -> Result<Option<u32>, String> { let input = match sanitize_input(input) { Ok(input) => input, Err(e) => return Err(e), }; for i in 1..10 { if i * i == input { return Ok(Some(i)); } } Ok(None) }
We call sanitize_input
and use a match expression to extract the result if there are no errors. If
there is an error - we pass it up to the caller. This is clunky - we don't do anything with the
error but to bubble it up we write four lines of boilerplate code. Luckily there is a better way
with the operator ?
. We can replace the whole match expression with a single line and the logic
will remain the same:
fn find_square_root(input: u32) -> Result<Option<u32>, String> { let input = sanitize_input(input)?; for i in 1..10 { if i * i == input { return Ok(Some(i)); } } Ok(None) }
What does the ?
operator does? It works with functions returning a Result
(and Option
but more
on this later). The operator is put just after the function call and if the function returns
Ok(something)
it returns the something
. If the function returns Err(something)
the operator
does return Err(something)
or in other words "bubbles up the error to the caller".
There is one important caveat though. The error types of the function being called (in our case
sanitize_input
) and the function containing the call (find_square_root
) should be the same.
Which indeed is valid for our example - they are both String
. Note that just the error types (E
)
should match, the result ones (T
) can be different. Which is also the case in our example -
find_square_root
returns Result<Option<u32>, String>
while sanitize_input
- Result<u32,
String>
.
Here is the whole example rewritten with operator ?
:
fn find_square_root(input: u32) -> Result<Option<u32>, String> { let input = sanitize_input(input)?; for i in 1..10 { if i * i == input { return Ok(Some(i)); } } Ok(None) } fn sanitize_input(input: u32) -> Result<u32, String> { if input > 100 { return Err("Only numbers between 1 and 100 are supported".to_string()); } Ok(input) } fn main() { match find_square_root(25) { Ok(Some(sqrt)) => println!("Found square root: {}", sqrt), Ok(None) => println!("No square root found"), Err(e) => println!("Error: {}", e), } match find_square_root(400) { Ok(Some(sqrt)) => println!("Found square root: {}", sqrt), Ok(None) => println!("No square root found"), Err(e) => println!("Error: {}", e), } }
?
and Option
It's less common but ?
can be used with Option
too. Let's see a pointless example:
fn caller() -> Option<u32> { let res = callee()?; println!("Got {}", res); Some(res) } fn callee() -> Option<u32> { Some(42) } fn main() { assert_eq!(caller(), Some(42)); }
Here caller
returns Option<u32>
. callee
also returns Option<u32>
and is called by caller. In
caller
?
will extract the wrapped u32
if calee
has returned Some
or return None
otherwise. This is less common but still you can be useful somewhere.
When ?
is used with Option
, caller
and callee
should have the same return types. If for
example caller
returns just u32
?
can't be used because there is no way to handle the None
variant. This error will be caught by the compiler of course.
Conclusion
Option
and Result
are fundamental types in Rust. Their functionality might look overlapping but
don't forget that the main difference between them is purely semantical. Communicating intent is
very important in both small and big programs. Furthermore they force you to make error handling
explicit. There is no way to forget to handle an Option
or a Result
because extracting the
values from them requires handling.
Don't forget to have a look at the documentation of
Result
and
Option
and have a look at the convenience
methods I have mentioned before. It's a good exercise to review them so that you know what you can
do with the types.
And finally to learn more about error handling in Rust read Chapter
9 from the Rust book. Section
9.2 covers Result
.
Comments
Comments powered by Disqus