![](/style/images/good.png)
![](/style/images/bad.png)
Learning Rust: Look Ma, No Exceptions!
source link: http://iextendable.com/2019/11/24/learning-rust-look-ma-no-exceptions/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
Introduction
Rust is a systems programming language (think C-like) that makes it easier to perform memory-safe operations than languages like C or C++. It accomplishes this by making it harder to do memory-unsafe operations–and catching these sorts of issues at compile-time instead of runtime.
In order to accomplish this, Rust imposes some constraints on the engineer through the borrow checker and immutable-by-default types. I’m not going to write about those things here as they have been covered in depth by others.
My focus for this post (and other posts in this potential series) is to focus on other language features and idioms that may be unfamiliar to managed-language developers.
In my first post in this series, I talked about the fact that Rust does not have the concept of null .
No Exceptions!
Rust does not have the concept of exceptions or the associated concept of try-catch
blocks. This is because once you get code to compile in Rust you can be sure there are no errors anywhere… just kidding.
Instead, in Rust we use an enum type called std::result::Result<T, E> . The T
in the generic signature is the return result. The E
represents the type of the Error should one occur. Rust provides some the helper functions Ok
and Err
to facilitate return Result
s. These are always in scope similarly to the Some
and None
variants used to create Option
types.
A Naive Example
Consider the following made-up function:
fn find_data(i: u32) -> Result<u32, String> { match i { 1 => Err("1 is not a valid value".to_string()), _ => Ok(i*2) } }
This function accepts an integer and doubles it. For whatever reason, 1
is not considered to be a valid value, so an error message is returned instead. Notice that Ok
and Err
are used to wrap the return and error values.
Now let’s look at how we would use the Result
type in a another function:
let result = find_data(5); match result { Ok(value) => { println!("The result was {}", value); }, Err(message) => { println!("{}", message); } }
The type of result
is std::result::Result<i32, String>
. We then treat it like any other enum, matching on the variants and doing the correct processing.
Adding Complexity
Things start to get a little complicated if we have a series of potential errors. Consider retrieving some data from a database. We could fail to connect to the database, construct our query correctly, or map the raw data to our intended representation.
fn get_employee_by_id(id: i32) -> Result<Employee, DataRetrivalError> { let connection = Database::create_connection(); match connection { Ok(conn) => { let raw_data = conn.execute("EmployeeByIdQuery", id); match raw_data { Ok(data) => { Employee = Employee::from_raw_data(data) } Err(error) => { Err(DataRetrievalError::QueryFailed) } } }, Err(error) => { Err(DataRetrivalError::ConnectionFailed) } } }
Yuck! This is pretty ugly. We could improve readability by removing the nesting:
fn get_employee_by_id(id: i32) -> Result<Employee, DataRetrivalError> { let connection_result = Database::create_connection(); if connection_result.is_err() { return connection_result; } let connection = connection_result.unwrap(); let raw_data = connection.execute("EmployeeByIdQuery", id); if (raw_data.is_err()) { return raw_data; } let data = raw_data.unwrap(); Employee::from_raw_data(data) }
This is better, but still pretty ugly. Fortunately, Rust offers some syntactic sugar to clean this up a lot in the form of the ?
operator. The ?
early return the result if it’s an error and unwrap it if it’s not. Here is the function rewritten to use the ?
operator.
fn get_employee_by_id(id: i32) -> Result<Employee, DataRetrivalError> { let connection = Database::create_connection()?; let data = connection.execute("EmployeeByIdQuery", id)?; Employee::from_raw_data(data) }
Much nicer!
If the error returned from an inner function does not match the error type expected by the outer function, the compiler will look for a From
implementation and do the type-coercion for you.
Comparing to Exception-based Languages
Rust’s error handling strategy does a great job of communicating possible failure modes since the error states of part of the signature of any function you call. This is a clear advantage over exception-based languages in which you (usually) have to read the documentation to know what exceptions can possibly occur.
On the other hand, it’s fairly common in exception-based languages to have some root handler for unhandled exceptions that provides standard processing for most errors.
In Rust, adding error handling can force you to edit much more code than in exception-based languages. Consider the following set of functions:
fn top_levl() -> i32 { mid_level1() + mid_level2() } fn mid_level1() -> i32 { low_level1 + low_level2() } fn mid_level2() -> i32 { low_level1() * low_level2() } fn low_level1() -> i32 { 5 } fn low_level2() -> i32 { 10 }
The top_level
function depends on the two mid_level
functions which in turn depend on the two low_level
functions. Consider what happens to our program if low_level2
is modified to potentially return an error:
fn top_levl() -> Result<i32, String> { // had to change this signature mid_level1() + mid_level2() } fn mid_level1() -> Result<i32, String> { // had to change this signature low_level1 + low_level2() } fn mid_level2() -> Result<i32, String> { low_level1() * low_level2() } fn low_level1() -> i32 { 5 } fn low_level2() -> Result<i32, String> { Ok(10) }
This sort of signature change will often bubble through the entire call stack, resulting in a much larger code-change than you would find in exception-based languages. This can be a good thing because it clearly communicates the fact that a low level function now returns an error. On the other hand, if there really is no error handling strategy except returning an InternalServerError
at an API endpoint, then requiring that every calling function change its signature to bubble the error is a fairly heavy tax to pay (these signature changes can also have their own similar side-effects in other call-paths).
I’m not making the argument that Rust error handling is therefore bad. I’m just pointing out that this error design has its own challenges.
Error Design Strategies
While mechanism by which errors are generated and handled in Rust is fairly simple to understand, the principles you should use in desigining your errors is not so straightforward.
There are essentially three dominant strategies available for designing your error handling strategy for your library or application:
From
- The crate-level enum will have many variants.
- Individual functions will only potentially return a subset of the crate-level errors but this subset will not be obvious to callers.
- Much smaller footprint than error per crate.
- Errors are contextually more relevant than crate-level error variants.
- This strategy still has the same drawbacks as the Error per crate strategy.
- Depending on how deep the module structure is, you could end up with a proliferation of error types.
- Each function defines its own error variants so its obvious what the caller may need to handle.
- Proliferation of error types throughout the system which makes the crate or module more difficult to understand.
Hybrid Strategy
I don’t think I have the right answer yet, but this hybrid strategy is the one I’ve settled on in my personal development. It basically creates an error hierarchy for the create that gets more specific as you approach a given function.
- Define an error enum per function.
- Define an error per module, the variants of which “source” the errors per function.
- Define an error per crate, the variants of which “source” the errors per module.
pub enum ConfigFileErrors { FileNotFound { path: String }, } fn load_config_file(path: String) -> Result<ConfigFile, ConfigFileErrors> { // snipped } pub enum ParsingError { InvalidFormat } fn parse_config(config: ConfigFile) -> Result<ConfigurationItems, ParsingError> { // snipped } pub enum ValidationError { RequiredDataMissing { message: String } } fn validate_config(input: ConfigurationItems) -> Result<ConfigurationItems, ValidationError> { // snipped } pub enum ConfigErrors { File { source: ConfigFileErrors }, Parsing { source: ParsingError }, Validation { source: ValidationError } } fn get_config() -> Result<ConfigurationItems, ConfigErrors> { let file = load_config_file("path/to/config".to_string())?; let parsed = parse_config(file)?; validate_config(parsed) }
This approach has many of the pros and cons of the other approaches so it’s not a panacea.
Pros:
- Each function clearly communicates how it can fail and is not polluted by the failure modes of other functions.
- No information is lost as you bubble up the call-stack as each low-level error is packaged in a containing error.
- The caller gets to match at the top-level error and decide for themselves if they wish to take finer-grained control of inner errors.
Cons:
- Proliferation of error types.
- New failure modes potentially impact the top-level crate design (e.g., adding a failure mode becomes a breaking change requiring a major revision if you are practicing Semantic Versioning.
- It’s not obvious how to deal with error variants that may be shared across multiple functions (e.g., parsing errors).
Recommend
About Joyk
Aggregate valuable and interesting links.
Joyk means Joy of geeK