Hello everyone, new episode of rust series is here, today we will discuss Error handling from composition point of view. We touched that previous episode and today we show more details and another approaches.
Composing results
We were already talking about processing Option and Result. Now it's time to take a look into situation when we have multiple calls and need to propagate result to other functions. Composing in terms of error handling means that we have some chain of operations that each of them return result that is used in another function.
Just result
In some languages we have exceptions, in other languages we modify parameters and return error codes. In rust we have robust solution with Options and Results. But check first situation where we don't work with errors and we have multiple function calls.
fn addTwo(x: i32) -> i32 {
x + 2
}
fn tenTimes(x: i32) -> i32 {
x * 10
}
fn main() {
let base = 10;
let result = tenTimes(addTwo(base));
println!("{}", result);
}
# output
120
Just result with error scenario
That was pretty simple case and we don't deal with any errors whatsoever. We can have just a bit more complex case and we have to deal with errors. Check code below when we can easily "break" code with zero division.
fn addTwo(x: i32) -> i32 {
x + 2
}
fn divide(x: i32, divisor: i32) -> i32 {
x / divisor
}
fn main() {
let base = 10;
let result = divide(addTwo(base),0);
println!("{}", result);
println!("Program finished correctly");
}
#output
thread 'main' panicked at 'attempt to divide by zero'
Returning Result with error
It's time to deal with that somehow. We can use Result and return Error if divisor is zero
fn addTwo(x: i32) -> i32 {
x + 2
}
fn divide(x: i32, divisor: i32) -> Result<i32, String> {
if divisor != 0 {
return Ok(x / divisor);
} else {
return Err("you divided by zero, no way!".to_string());
}
}
fn main() {
let base = 10;
let result = divide(addTwo(base),0);
println!("{:?}", result);
}
# output
Error caught: Err("you divided by zero, no way!")
Program finished correctly
You see, we've caught error program was finished without termination compared to the previous example.
Handling multiple Results
Now let's take to case when we're calling multiple functions where we must deal with results. We will need to check somehow all possible application states. That's good work for match statement.
// fake get_file_name function
fn get_file_name() -> Result<String, ()> {
Result::Ok("steemit.data".to_string())
}
// fake get_name_len function
fn get_name_len(name: &str) -> Result<i32, ()> {
Result::Ok(7)
}
fn main() {
let res = get_file_name();
match res {
Ok(r1) => {
println!("get_file_name OK");
let r2 = get_name_len(&r1);
match r2 {
Ok(len) => println!("get_name_len OK, lenght={}", len),
Err(e) => println!("err_len")
}
},
Err(e) => println!("err")
}
}
# output
get_file_name OK
get_name_len OK, lenght=7
This is just fine but dealing with resolution result in some matches tree. It works but as you can see code can grow a lot. Last time we've shown how to handle it with try! macro. Rust provides another approach to efficiently deal with this in more controlled way.
and_then
and_then method allow us to take result (Option or Result) and if there is some ok value (Some or Ok) then it calls given function on that result. Returns error (None or Err) otherwise. Like this we can create chain of method calls which behave as expected.
// fake get_file_name function
fn get_file_name<'a>() -> Result<&'a str, ()> {
Result::Ok("steemit.data")
}
// fake get_name_len function
fn get_name_len(name: &str) -> Result<i32, ()> {
Result::Ok(7)
}
fn main() {
let res = get_file_name().and_then(get_name_len);
match res {
Ok(l) => println!("Operations result: {}", l),
Err(e) => println!("Operations failed")
}
}
# output
Operations result: 7
Cool, we have nice method call chain and errors and results are handled as desired. All due to and_then method which you can check below (version for Option version).
fn and_then<F, T, A>(option: Option<T>, f: F) -> Option<A>
where F: FnOnce(T) -> Option<A> {
match option {
None => None,
Some(value) => f(value),
}
}
map method
map method is similar but it wraps result into Some, resp. Ok value. Check the definition below (Option version)
fn map<F, T, A>(option: Option<T>, f: F) -> Option<A> where F: FnOnce(T) -> A {
match option {
None => None,
Some(value) => Some(f(value)),
}
}
We can use this function as well for our example but we need to modify function return value from Result to i32 or deal with additional wrapping otherwise. With first approach it will be like this.
// fake get_file_name function
fn get_file_name<'a>() -> Result<&'a str, ()> {
Result::Ok("steemit.data")
}
// fake get_name_len function
fn get_name_len(name: &str) -> i32 {
7
}
fn main() {
let res = get_file_name().map(get_name_len);
match res {
Ok(l) => println!("Operations result: {}", l),
Err(e) => println!("Operations failed")
}
}
# output
Operations result: 7
Works fine. So you always need to decide which one is the optimal for you and what functions you're dealing with.
How to deal with errors?
So far we dealt with success states, how about errors. There is map_err which can (re)map errors. Let's take a step aside to mention ToString trait here that we will use in a while.
ToString trait
It's good to mention ToString trait here. It converts value to String. Besides "str" where we already met this it's also implemented for any types that implements Display trait. As std::io::Error implements Display, then ToString trait is also implemented.
use std::io::ErrorKind;
use std::io::Error;
let custom_error = Error::new(ErrorKind::Other, "Something strange happened!");
let custom_error_str = custom_error.to_string();
println!("{:?}",custom_error);
println!("{}",custom_error_str);
# output
Error { repr: Custom(Custom { kind: Other, error: StringError("Something strange happened!") }) }
Something strange happened!
map_err
Now let's use it in error mapping. Assume we raise std::io::Error. We will map error in such a way we will make error string just as uppercase.
// fake function returning IO error
fn get_file_name<'a>() -> Result<&'a str, std::io::Error> {
use std::io::ErrorKind;
use std::io::Error;
let custom_error = Error::new(ErrorKind::Other, "Something strange happened!");
Err(custom_error)
}
// fake function returning name length
fn get_name_len(name: &str) -> i32 {
1
}
fn main() {
let res = get_file_name().map(get_name_len).map_err(|err| err.to_string().to_uppercase());
match res {
Ok(l) => println!("{}", l),
Err(e) => println!("{}", e)
}
}
# output
SOMETHING STRANGE HAPPENED!
As you can see we can combine ok result handling with error handling so we can have something like
let final_result = f1().map_err(f2).and_then(f3).map_err(f4).map(f5);
Cool. We've learned some more about error handling and handle result composing. It's up to you what way will you choose, if you will handle it like this or with a bit simpler (and less customizable) way with try! macro. It depends how you choose to control you result flow. You can also mix both approaches partially. As you can see there is quite a lot but what is good is that Rust provides pretty robust built-in system for handling results and errors.
Postfix
That's all for handling, thank you for your appreciations, feel free to comment and point out possible mistakes (first 24 hours works the best but any time is fine). May Jesus bless your programming skills, use them wisely and see you next time.
Meanwhile you can also check the official documentation for more details if you wish.