“An ounce of prevention is worth a pound of cure.”
__ Benjamin Franklin
RUST : ERROR HANDLING
Welcome Back! This is the fifth post of Learning Rust series at Medium. Also now, you can read the same content via learning-rust.github.io 👈 . I think it’s more structured and easy to see the big picture.
In the previous posts we discussed about,
▸ Installation & Hello World ▸ Cargo & Crates ▸ Variable bindings , Constants & Statics ▸ Comments ▸ Functions ▸ Primitive Data Types ▸ Operators
▸ Control Flows
▸ Vectors ▸ Structs ▸ Enums ▸ Generics ▸ Impls & Traits
▸ Ownership ▸ Borrowing ▸ Lifetimes & Lifetime Elision
▸ Modules ▸ Crates ▸ Workspaces ▸ std modules and Preludes
Rust is a well designed language and safety is one of the main focus area of it. Its design decisions have been taken to prevent errors and major issues of systems programs like data races, null pointer exceptions, sensitive data leakage through exceptions and etc. So today, we are going to talk more about the concepts behind Error Handling in Rust.
Smart Compiler
The Rust compiler does the most significant job to prevent errors in Rust programs. It analyzes the code at compile-time and issues warnings, if the code does not follow memory management rules or lifetime annotations correctly. For example,
💭 In the third post; RUST : THE TOUGH PART , we have discussed memory management concepts like ownership, borrowing, lifetimes and etc.
Rust compiler checks not only issues related with lifetimes or memory management and also common coding mistakes, like the following code.
Above error messages are very descriptive and we can easily see where is the error. But while we can not identify the issue via the error message, rustc --explain
commands help us to identify the error type and how to solve it, by showing simple code samples which express the same problem and the solution we have to use. For example, rustc --explain E0571
shows the following output in the console.
💡 Also you can read the same explanations via Rust Compiler Error Index . For example to check the explanation of E0571
error, you can use https://doc.rust-lang.org/error-index.html#E0571
.
Panicking
panic!()
▸ In some cases, while an error happens we can not do anything to handle it, if the error is something, which should not be happened. In other words, if it’s an unrecoverable error.
▸ Also when we are not using a feature-rich debugger or proper logs, sometimes we need to debug the code by quitting the program from a specific line of code by printing out a specific message or a value of a variable binding to understand the current flow of the program.
For above cases, we can use panic!
macro. Let’s see few examples.
⭐
panic!()
runs thread based. One thread can be panicked, while other threads are running.
01. Quit from a specific line.
02. Quit with a custom error message.
03. Quit with the value of code elements.
As you can see in the above examples panic!()
supports println!()
type style arguments . By default, it prints the error message, file path and line & column numbers where the error happens.
unimplemented!()
If your code is having unfinished code sections, there is a standardized macro as unimplemented!()
to mark those routes. The program will be panicked with a “not yet implemented” error message, if the program runs through those routes.
unreachable!()
This is the standard macro to mark routes that the program should not enter. The program will be panicked with a “’internal error: entered unreachable code’” error message, if the program entered those routes.
We can set custom error messages for this as well.
assert!(), assert_eq!(), assert_ne!()
These are standard macros which usually use with test assertions.
assert!()
ensures that a boolean expression is true. It panics if the expression is false.
assert_eq!()
ensures that two expressions are equal. It panics if the expressions are not equal.
assert_ne!()
ensures that two expressions are not equal. It panics if the expressions are equal.
⭐ Expressions which use with
assert_eq!()
andassert_ne!()
should return same data type.
We can set custom error messages for these macros as well. For examples,
- With a custom message for
assert_eq!()
2. assert_eq!()
with debug data
debug_assert!(), debug_assert_eq!(), debug_assert_ne!()
🔎 These are similar to above assert
macros. But these statements are only enabled in non optimized builds by default. All these debug_assert
macros will be omitted in release builds, unless we pass -C debug-assertions
to the compiler.
Option and Result
Many languages use null
\ nil
\ undefined
types to represent empty outputs, and Exceptions
to handle errors. Rust skips using both, especially to prevent issues like null pointer exceptions, sensitive data leakages through exceptions and etc. Instead, Rust provides two special generic enums;Option
and Result
to deal with above cases.
💭 In the second post; RUST : BEYOND THE BASICS , we discussed about the basics of enums, generics and
Result
&Option
types.
As you know,
▸ An optional value can have either Some value or no value/ None.
▸ A result can represent either success/ Ok or failure/ Err
💭 Do you remember that? Not only
Option
andResult
, and also their variants are in preludes. So, we can use them directly without using namespaces in the code. Refer RUST : LET’S GET IT STARTED! for more info.
Basic usages of Option
When writing a function or data type,
▸ if an argument of the function is optional,
▸ If the function is non-void and if the output it returns can be empty,
▸ If the value, of a property of the data type can be empty
We have to use their data type as an Option
type
For example, if the function outputs a &str
value and the output can be empty, the return type of the function should set as Option<&str>
Same way, if the value of a property of a data type can be empty or optional like middle_name
of Name
data type in the following example, we should set its data type as an Option
type.
💭 As you know, we can use pattern matching to catch the relevant return type (Some
/ None
) via match
. There is a function to get the current user’s home directory in std::env
as home_dir()
. Because of all users doesn’t have a home directory in the systems like Linux, home directory of the user can be optional. So it returns an Option
type; Option<PathBuf>
.
⭐ However, when using optional arguments with functions, we have to pass None
values for empty arguments while calling the function.
🔎 Other than that,
Option
types are used with nullable pointers in Rust. Because of there is no null pointers in Rust, the pointer types should point to a valid location. So if a pointer can be nullable, we have useOption<Box<T>>
.
Basic usages of Result
If a function can produce an error, we have to use a Result
type by combining the data type of the valid output and the data type of the error. For example, if the data type of the valid output is u64
and error type is String
, return type should be Result<u64, String>
.
💭 As you know, we can use the pattern matching to catch the relevant return types (Ok
/Err
) via match
. There is a function to fetch the value of any environment variable in std::env
as var()
. Its input is the environment variable name. This can produce an error, if we passes a wrong environment variable or the program can not extract the value of the environment variable while running. So its return type is a Result
type; Result<String, VarError>
.
is_some(), is_none(), is_ok(), is_err()
Other than match
expressions, Rust provides is_some()
, is_none()
and is_ok()
, is_err()
functions to identify the return type.
ok(), err() for Result types
In addition to that Rust provides ok()
and err()
for Result
types. They convert the Ok<T>
and Err<E>
values of a Result
type to Option
types.
Unwrap and Expect
unwrap()
▸ If an Option
type has Some
value or a Result
type has a Ok
value, the value inside them passes to the next step.
▸ If the Option
type has None
value or the Result
type has Err
value, program panics; If Err
, panics with the error message.
The functionality is bit similar to the following codes, which are using match
instead unwrap()
.
Example with Option
and match
, before using unwrap()
Example with Result
and match
, before using unwrap()
Same codes in above main
functions can be written with unwrap()
using two lines.
⭐ But as you can see, when using unwrap()
error messages are not showing the exact line numbers where the panic happens.
expect()
Similar to unwrap()
but can set a custom message for the panics.
unwrap_err() and expect_err() for Result types
The opposite case of unwrap()
and expect()
; Panics with Ok
values, instead Err
. Both print the value inside Ok
on the error message.
💡 Usually use with tests.
unwrap_or(), unwrap_or_default() and unwrap_or_else()
💡These are bit similar to
unwrap()
, If anOption
type hasSome
value or aResult
type has aOk
value, the value inside them passes to the next step. But when havingNone
orErr
, the functionalities are bit different.
unwrap_or()
: WithNone
orErr
, the value you passes tounwrap_or()
is passing to the next step. But the data type of the value you passes should match with the data type of the relevantSome
orOk
.
unwrap_or_default()
: WithNone
orErr
, the default value of the data type of the relevantSome
orOk
, is passing to the next step.
unwrap_or_else()
: Similar tounwrap_or()
. The only difference is, instead of passing a value, you have to pass a closure which returns a value with the same data type of the relevantSome
orOk
.
Error and None Propagation
We should use panics like panic!()
, unwrap()
, expect()
only if we can not handle the situation in a better way. Also if a function contains expressions which can produce either None
or Err
,
▸ we can handle them inside the same function. Or,
▸ we can return None
and Err
types immediately to the caller. So the caller can decide how to handle them.
💡None
types no need to handle by the caller of the function always. But Rusts’ convention to handle Err
types is, return them immediately to the caller to give more control to the caller to decide how to handle them.
? Operator
▸ If an Option
type has Some
value or a Result
type has a Ok
value, the value inside them passes to the next step.
▸ If the Option
type has None
value or the Result
type has Err
value, return them immediately to the caller of the function.
Example with Option
type,
Example with Result
type,
try!()
⭐ ?
operator was added in Rust version 1.13. try!()
macro is the old way to propagate errors before that. So we should avoid using this now.
▸ If a Result
type has Ok
value, the value inside it passes to the next step. If it has Err
value, returns it immediately to the caller of the function.
Error propagation from main()
Before Rust version 1.26, we couldn’t propagate Result
and Option
types from the main()
function. But now, we can propagate Result
types from the main()
function and it prints the Debug
representation of the Err
.
💡 We are going to discuss about
Debug
representations in upcoming section “Error trait” under “Custom Error Types”.
💯 If you want to know about the all kind of errors
std::fs::File::open()
can produce, check the error list onstd::fs::OpenOptions
.
Combinators
Let’s see what a combinator is,
- One meaning of “combinator” is a more informal sense referring to the combinator pattern, a style of organizing libraries centered around the idea of combining things. Usually there is some type T, some functions for constructing “primitive” values of type T, and some “combinators” which can combine values of type T in various ways to build up more complex values of type T. The other definition is “function with no free variables” (wiki.haskell.org)
- A combinator is a function which builds program fragments from program fragments; in a sense the programmer using combinators constructs much of the desired program automatically, rather that writing every detail by hand.
__ John Hughes - Generalizing Monads to Arrows via Functional Programming Concepts
The exact definition of “combinators” in Rust ecosystem is bit unclear.
▸ or()
, and()
, or_else()
, and_then()
- Combine two values of type T and return same type T.
▸ filter()
for Option
types
- Filter type T by using a closure as a conditional function.
- Return same type T
▸ map()
, map_err()
- Convert type T by applying a closure.
- The data type of the value inside T can be changed. ex Some<&str>
can be converted to Some<usize>
or Err<&str>
to Err<isize>
and etc.
▸ map_or()
, map_or_else()
- Transform type T by applying a closure & return the value inside type T.
- For None
and Err
, a default value or another closure is applied.
▸ ok_or()
, ok_or_else()
for Option
types
- Transform Option
type into a Result
type.
▸ as_ref()
, as_mut()
- Transform type T into a reference or a mutable reference.
or() and and()
While combining two expressions which return either Option
/ Result
,
▸ or()
: If either one got Some
or Ok
, that value returns immediately.
▸ and()
: If both got Some
or Ok
, the value in the second expression returns. If either one got None
or Err
that value returns immediately.
🔎 Rust nightly support
xor()
forOption
types, which returnsSome
only if one expression gotSome
, not both.
or_else()
Similar to or()
. The only difference is, the second expression should be a closure which returns same type T.
and_then()
Similar to and()
. The only difference is, the second expression should be a closure which returns same type T.
filter()
💡 Usually in programming languages
filter
functions are used with arrays or iterators to create a new array/ iterator by filtering own elements via a function/ closure. Rust also providesfilter()
as an iterator adaptor to apply a closure on each element of an iterator to transform it into another iterator. However in here we are talking about the functionality offilter()
withOption
types.
The same Some
type is returned, only if we pass a Some
value and the given closure returned true for it. None
is returned, if None
type passed or the closure returned false. The closure uses the value inside Some
as an argument. Still Rust support filter()
only for Option
types.
map() and map_err()
💡 Usually in programming languages
map()
functions are used with arrays or iterators, to apply a closure on each element of the array or iterator. Rust also providesmap()
as an iterator adaptor to apply a closure on each element of an iterator to transform it into another iterator. However in here we are talking about the functionality ofmap()
withOption
andResult
types.
map()
: Convert type T by applying a closure. The data type ofSome
orOk
blocks can be changed according to the return type of the closure. ConvertOption<T>
toOption<U>
,Result<T, E>
toResult<U, E>
⭐ Via map()
, only Some
and Ok
values are getting changed. No affect to the values inside Err
(None
doesn’t contain any value at all).
map_err()
forResult
types : The data type ofErr
blocks can be changed according to the return type of the closure. ConvertResult<T, E>
toResult<T, F>
.
⭐ Via map_err()
, only Err
values are getting changed. No affect to the values inside Ok
.
map_or() and map_or_else()
Hope you remember the functionality of unwrap_or()
and unwrap_or_else()
functions we discussed under “Unwrap and Expect” section in this post. These functions also bit similar to them. But map_or()
and map_or_else()
apply a closure on Some
, Ok
values and return the value inside type T.
map_or()
: Support only forOption
types (not supportingResult
). Apply the closure to the value insideSome
and return the output according to the closure. The given default value is returned forNone
types.
map_or_else()
: Support for bothOption
andResult
types (Result
still nightly only). Similar tomap_or()
but should provide another closure instead a default value for the first parameter.
⭐ None
types doesn’t contain any value. So no need to pass anything to the closure as input with Option
types. But Err
types contain some value inside it. So default closure should able to read it as an input, while using this with Result
types.
ok_or() and ok_or_else()
As mentioned earlier, ok_or()
, ok_or_else()
transform Option
type into Result
type. Some
to Ok
and None
to Err
.
ok_or()
: A defaultErr
message should pass as argument.
ok_or_else()
: Similar took_or()
. A closure should be passed as the argument.
as_ref() and as_mut()
🔎 As mentioned earlier, these functions are used to borrow type T as a reference or as a mutable reference.
as_ref()
: ConvertOption<T>
toOption<&T>
andResult<T, E>
toResult<&T, &E>
as_mut()
: ConvertsOption<T>
toOption<&mut T>
andResult<T, E>
toResult<&mut T, &mut E>
Custom Error Types
Rust allow us to create our own Err
types. We call them “Custom error types”.
Error trait
As you know traits define the functionality a type must provide. But we don’t need to define new traits for common functionalities always, because Rust standard library provides some reusable traits which can be implemented on our own types. While creating custom error types std::error::Error
trait help us to convert any type to an Err
type.
As we discussed under traits inheritance in the second post; RUST : BEYOND THE BASICS, a trait can be inherited from another traits.
trait Error: Debug + Display
meansError
trait inherits fromfmt::Debug
andfmt::Display
traits.
▸ Display
- How should the end user see this error as a message/ user-facing output.
- Usually print via println!("{}")
or eprintln!("{}")
▸ Debug
- How should display the Err
while debugging/ programmer-facing output.
- Usually print via println!("{:?}")
or eprintln!("{:?}")
- To pretty-print, println!("{:#?}")
or eprintln!("{:#?}")
can be used.
▸ source()
- The lower-level source of this error, if any.
- Optional.
First, let’s see how to implement std::error::Error
trait on a simplest custom error type.
Hope you understood the main points. Now, let’s see some custom error type with an error code and an error message.
⭐️ Rust standard library provides not only reusable traits and also it facilitates to magically generate implementations for few traits via #[derive]
attribute. Rust support derive
std::fmt::Debug
, to provide a default format for debug messages. So we can skip std::fmt::Debug
implementation for custom error types and use #[derive(Debug)]
before struct
declaration.
For a struct
#[derive(Debug)]
prints, the name of thestruct
,{
, comma-separated list of each field’s name and debug value and}
From trait
When writing real programs, mostly we have to deal with different modules, different std
and third party crates at the same time. But each crate uses their own error types and if we are using our own error type, we should convert those errors into our error type. There is a standardized trait we can use for these conversions, std::convert::From
.
💡 As you know,
String::from()
function is used to create aString
from&str
data type. Actually this also an implementation ofstd::convert::From
trait.
Let’s see how to implement std::convert::From
trait on a custom error type.
In the above example, File::open(“nonexistent.txt”)?
produces std::io::Error
. But because of the return type is Result<(), AppError>
, it converts to an AppError
. Because of we are propagating the error from main()
function, it prints the Debug
representation of the Err
.
In the above example we deal with only one std
error type, std::io::Error
. Let’s see some example which handles multiple std
error types.
🔎 Search about the implementation of
std::io::ErrorKind
, to see how to organize error types further.
Okay, Let’s stop our fifth post of Learning Rust series in here. In this post we discussed about,
▸ Smart Compiler ▸ Panicking ▸ Option and Result ▸ Unwrap and Expect
▸ Error and None Propagation ▸ Combinators ▸ Custom Error Types
🐣 I am a Sri Lankan🇱🇰 Web Developer who works in Vietnam🇻🇳. I am not a native English speaker and I am just practicing Rust in my very little leisure time, while learning more about Golang, Devops and so much in the workplace.
So, if you found any mistake or something I need to be changed, even a spelling or a grammar mistake, please let me know.
Also as I mentioned in the beginning, now you can read the same content via learning-rust.github.io 👈. Feel free to create a ticket or contribute to make learning Rust easier to everyone. Thanks.
⭐ Contribute! 🥤 Buy me a coffee! 🚀 Hire me!