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.

Example

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!

Bug

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())

Tip

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.

Example

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.

Example

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.