Part 1: Getting a result (5 points)
In this part, you’re going to implement a simple method for returning different types of errors from a single function.
Recall that in Rust, we typically indicate that a function may succeed and
return a value or fail and return an error using the standard type
Result<T, E>
. For example, we might have the following code.
fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, String> { if divisor == 0 { Err(format!("Cannot divide {dividend} by zero")) } else { Ok(dividend / divisor) } } fn main() { println!("{:?}", safe_divide(25, -7)); println!("{:?}", safe_divide(25, 0)); }
If divisor
is 0, then it returns an error (click Run to see the results).
Otherwise it returns the quotient.
This works really well! Unfortunately, it has one slightly unfortunate
drawback: we can only return a single type of error. In this case, we’re
returning a String
as the error. The functions that perform input/output
(e.g., reading from a file), use a special type std::io::Error
to indicate an error.
So the question becomes, how can we return multiple different types of errors from a single function?
There are multiple ways to return different types of errors from a function.
One approach is to pick an error type for the Result
that can hold
different types that share a common interface (i.e., implement some trait).
In Lab 4, we encountered a similar situation. We needed to be able
to say that we had some type that implemented the BufRead
trait, but we
didn’t want to be specific about exactly which type. We used a Box
.
We’re going to do the same thing here. The type we want to return is a
Result<T, Box<dyn std::error::Error>>
An object of this type is either an Ok(T)
where T
is some concrete type
like i32
or String
(the success case) or it’s an Err(Box<dyn std::error::Error>)
which is just some object on the heap that implements the
std::error::Error
trait (the error case). All of the error types in
the Rust standard library implement this trait.
Returning to our example, we will need to do more than replace Result<i32, String>
with Result<i32, Box<dyn std::error::Error>>
, but not much more!
fn safe_divide(dividend: i32, divisor: i32) -> Result<i32, Box<dyn std::error::Error>> { if divisor == 0 { Err(format!("Cannot divide {dividend} by zero")) } else { Ok(dividend / divisor) } } fn main() { println!("{:?}", safe_divide(25, -7)); println!("{:?}", safe_divide(25, 0)); }
Click the Run button to see the error.
As the error message states, “expected Box<dyn Error>
, found String
”. The
issue is that a String
is not a boxed error. Fortunately, Rust knows how to
convert it into one using the .into()
method:
Err(format!("Cannot divide {dividend} by zero").into())
There are a pair of traits From<T>
and Into<T>
which define .from()
and .into()
respectively. These functions are used to convert from some type T
or into some type T
. We’ll encounter these functions fairly frequently.
In this case, Box<dyn Error>
implements the From<String>
trait and String
implements the Into<Box<dyn Error>>
. (One typically only implements the From
trait because the compiler will provide a default Into
).
The previous example didn’t showcase returning multiple possible errors. So let’s examine one more example that will try to read an integer from a text file.
Reading an integer from a file can fail in multiple ways: (1) The file might not exist or be readable; (2) the file might not contain valid UTF-8 text; or (3) the contents of the file might not constitute a valid integer.
#![allow(unused)] fn main() { use std::fs; fn read_int_from_file() -> Result<i32, Box<dyn std::error::Error>> { let num = fs::read_to_string("/some/file.txt")? // cases 1 and 2 .trim() .parse()?; // case 3 Ok(num) } }
If the file cannot be read or if the contents aren’t valid UTF-8,
fs::read_to_string()
will return an error of type std::io::Error
as
mentioned above. The ?
will unwrap the result if there is no error.
Otherwise, it’ll return the error from the read_int_from_file()
function. In
fact, it’s slightly better than that, it’ll call .into()
on it for us! This
is super important because otherwise we’d get an error about how an
std::io::Error
is not the same as a Box<dyn std::error::Error>
. By calling
.into()
, Rust knows to box the error up on the heap, just as it did with the
String
above.
If the contents of the file aren’t a valid integer (after trimming off
whitespace), then the .parse()
method will return a
std::num::ParseIntError
. This time, the ?
will unwrap an Ok(i32)
or
convert the Err(std::io::Error)
into the appropriately boxed type.
Your task
You’re going to define type alias for Result<T, Box<dyn std::error::Error>>
which will save on typing. We’ll be using this type alias (or a similar one)
in future labs!
A type alias is a way to give an existing type a second (usually shorter) name. An alias and its underlying type are the same in all respects. This is purely a way to make your code easier to read and write.
Inside your assignment repo, run
$ cargo init --name gutensearch
At the top of main.rs
, add the line
#![allow(unused)] fn main() { type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; }
Okay, but what did this accomplish? You’ve just defined a new generic type
alias Result<T>
which is exactly the same thing as the Result<T, Box<dyn std::error::Error>>
we used above. Here are the two functions from the
examples above using this type alias.
use std::fs; type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; fn safe_divide(dividend: i32, divisor: i32) -> Result<i32> { if divisor == 0 { Err(format!("Cannot divide {dividend} by zero").into()) } else { Ok(dividend / divisor) } } fn read_int_from_file() -> Result<i32> { let num = fs::read_to_string("/some/file.txt")? // cases 1 and 2 .trim() .parse()?; // case 3 Ok(num) } fn main() -> Result<()> { let divisor = read_int_from_file()?; let quotient = safe_divide(25, divisor)?; println!("Dividing 25 by {divisor} has quotient {quotient}"); Ok(()) }
Note that you can have main
return a Result
and it’ll print out the error
if there is an error. Since there’s no real return value, we return ()
wrapped in an Ok()
to indicate success. This
doesn’t show a terribly friendly error message though. Click Run to see what
this looks like.
As you can see from the previous example, the error message we get when we
return an error from main
isn’t great. Let’s not do that. Instead, define a
new function, run()
, that will return a Result<()>
that we can check in
main
. On an error, we can print the error message to stderr
and then exit
with return value 1.
Your code should look something like this now.
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>; fn run() -> Result<()> { todo!("Code for the next part goes here.") } fn main() { if let Err(err) = run() { eprintln!("{err}"); std::process::exit(1); } }
You’re done with this part, so continue to Part 2.