Welcome to module 5
on the advantages of Rust's error handling
strategy.
Now that we've covered how to handle errors
in Rust, in this module,
we'll editorialize a bit about
the benefits that we've seen from separating
bugs from recoverable errors.
With bugs,
the advantages include failing loudly, fast, and
close to the cause,
and documenting and enforcing assumptions
that the code has made
and needs to be true in order to work.
For recoverable errors,
the advantages include knowing about the possibility
of errors from the type system,
and the compiler making sure you don't forget
to handle errors.
Then, because all engineering choices
come with trade-offs,
we'll end the module
by talking about the disadvantages
of Rust's error handling strategy,
and how it affects your development flow,
and how it can be tricky when there are multiple
error types that can happen.
Let's start by recapping the difference
between bugs
and recoverable errors.
In previous modules,
we talked about panic! and Result
and we talked about situations in which to
use each.
To review, panics are for situations
that indicate we're in a bad state,
from which there's no way to recover,
or a bug that would need to be fixed by
a programmer.
Results are for situations
we expect to happen sometimes,
that the program may be able to take some
action to recover from,
possibly with the help from the end user
of the program, as opposed to needing
to change the code of the program.
Many other programming languages
use the same mechanism for handling
both bugs and recoverable errors,
but they're actually separate situations
that deserve separate handling.
Rust builds appropriate handling
into panic and Result
if you use the one that best fits the situation.
So, let's talk about the advantages you
get from Rust's handling of bugs,
starting with how panics cause your program
to fail loudly, fast,
and close to the cause.
We're going to compare a Rust program
and a C program that both have the same
bug reading past the end
of a data structure.
This type of problem is known as a
buffer overread.
These two programs both initialize
a collection of integers named accts,
containing two values,
and then print the value at position
11 in the collection, which is out
of bounds.
Now we're going to illustrate what we mean
by failing loudly, fast,
and close to the cause.
By failed loudly,
we mean that when your program encounters a
problem, it lets you know
in a really obvious way,
as opposed to failing silently
and continuing on without any way
for you to tell that something is wrong.
The Rust program compiles without any
errors,
but when we run it, the program panics
with the message index out of bounds -
that's a loud, obvious failure.
The C program also compiles,
but when we run it, there's no error,
it just prints an unexpected
and incorrect result.
I get 0 -
you might get something different.
The program has silently continued
with bad data.
The advantage is that loud failures
are more likely to be noticed during the development
or testing of a feature, rather
than getting out to production where such
failures affect end users.
This example also illustrates
what we mean by fail fast.
As soon as the Rust program was asked
to perform an invalid operation,
it stopped.
The C program continued on without
ever stopping.
We could have performed all sorts of operations
on the invalid data.
Exploits
take advantage of our program continuing
with bad data to get programs
to do something they're not supposed to do.
The pervasive usage of panics
in Rust's Standard Library
and the ecosystem don't give hackers
that opportunity.
Related to failing fast is failing
close to the source.
When debugging, it takes longer
if you have to trace through lots of code
to figure out where an unexpected value
came from.
While you don't typically want your Rust
programs to panic, at least
when they do,
it'll be easier to figure out why
because the panic! happened right where
the problem occurred.
Next, let's look at how using
panic! to enforce expectations
can catch bugs.
There is a method on Result and Option
called expect that has similar
behavior to this match expression
we used in the module on Result and
Option.
If the value is the Ok
or Some variant, it returns
the inner value.
If the value is the Err
or None variant, it panics
with the message given as an argument to
expect.
This is useful when you have a Result or
Option, and you're mostly
sure the operation will succeed
or produce a value.
The expect documents that assumption
of yours,
and if you're ever wrong, you'll get a loud
failure.
Now, let's look at the advantages you get
from using Result for recoverable errors,
starting with how it makes the possibility
of errors clear.
When a function such as serde_json::from_str
returns a Result,
we know right away, from looking at the signature
in the docs, that the operation
this function performs
might fail.
If we write a function that calls
serde_json::from_str.
Our function needs to either handle the possibility
of failure
or also return Result.
Thus communicating to the code that calls our
function that there is a possibility of failure
even without looking at the implementation.
In contrast,
here's the Ruby documentation for JSON.parse,
which serves the same
purpose as serde_json::from_str.
There's no indication of failure in the
parse function's signature.
If we write a function that calls
JSON.parse, our function signature
doesn't convey that information either.
It's hard to tell what kinds of exceptions
a function in Ruby might raise that we
might want to handle.
Beyond being able to easily see
there's a possibility of failure,
the Rust compiler also makes sure
that we handle that possibility somehow.
As we saw in the module on Result
and Option, we can't use
the inner value from a Result without
doing something explicit to handle the
possibility of errors.
Even if we call expect to turn
an Err into a panic!,
when we decide to implement better error
handling, we can search for expect
and find all the places we need to update.
In contrast are the error handling conventions
in Go.
Functions that might fail return
both a success value
and an error value, rather
than one or the other as Rust does.
The convention is to check whether the
error value is nil right after a
function call, so close to
the source of the problem, which is
good.
The trouble is that the Go compiler
doesn't help you remember to
do this check
or help you check correctly.
It's possible to ignore failure by
assigning the error value to _
and only using the success value.
It's hard to search for underscores later
to add better error handling.
It's also possible to forget to
assign the err to a variable
and then check an uninitialized variable
instead, which makes it look
like you're checking for errors even though
you're not. This problem has
occurred in real code.
In Rust, the compiler helps you
avoid these kinds of problems.
Now that we've covered the advantages
of Rust's error handling strategy,
we'd be remiss if we didn't also
acknowledge the disadvantages, starting
with your development flow.
When you're not quite sure what you're building
yet
or what all the operations a function
needs to do are,
it can be annoying to be forced to think about
which operations might fail in which ways
and what should be done about that.
As a former Rubyist, it's a lot
smoother to just start writing code
in Ruby and get some functionality up
and running.
It's nice to go through the entire happy path
of an application feature without being
slowed down by thinking about errors.
I will say, though, that, in Ruby, I've
gotten more errors in production,
forgotten about more edge cases,
and in some cases never figured out why
my code wasn't behaving correctly
because it's hard to see when you're not
handling failures that happen.
When I'm starting a program
in Rust, I make liberal use of expect
to defer thinking about errors immediately,
but I found that knowing where errors might
happen, and being encouraged to handle errors
correctly, has made my code work
better in production.
Finally, let's talk about functions
that perform operations that result
in multiple error types.
For example, here's a function that
reads an environment variable,
then parses the environment variable string
value into a number
and adds 1 to it.
The function, env::var, returns
a Result with an error type of
env::VarError. The environment variable
might not be present
or it might not be valid Unicode.
Parsing might not work if the environment
variable's value isn't a number.
In that case, the parse function returns
a num::ParseIntError.
What do we put as our function's error
type?
And what do we do if we add more functionality
with different error types.
This is a bit tricky.
The next module is going to explore different
ways to handle this situation.
In this module,
we discussed how Rust treats bugs
and recoverable errors differently.
The way Rust handles bugs
helps you find bugs sooner in the development
cycle
and leaves fewer opportunities for
exploiting your program.
The way Rust handles recoverable errors
helps you to remember to handle all situations
and makes for a more reliable program.
This comes at a cost of added
friction when starting development
because Rust forces you to think about more
than the happy path.
Also, functions that can fail
in multiple ways are tricky to write,
which is what the next module is about.