Introduction to Rust Part 2
Hello everyone, in my last article I gave a general introduction to the Rust Programming Language and some of its features as well as creating a simple command-line demo built with Rust. In this article, I'll be building upon that demo by going over error handling, libraries, and functions.
Introduction
Within the two parts of this article, I'll be giving a basic introduction to a few more advanced concepts within Rust. The first will be error handling, in which I'll be going over how Rust handles errors. And two, I'll be going over libraries and functions, which have a different name in Rust called crates.
Error Handling
When you write a program, making things run correctly the first time is very rare and that's why we have error handling. Making your program behave correctly and properly when something unexpected happens is the hard part. Error handling refers to the response of the program when conditions occur that the program did not anticipate, it is up to the programmer to make sure that the program has some sort of appropriate response to the problem.
Error handling helps maintain the proper flow of the program and is quite helpful in improving the program. Now there are two types of errors that we can encounter, human error and program errors. An example of human error would be someone forgetting to call a function, or forgetting to pass a required argument that is needed for a specific method, this is something easily fixable and something that probably won't take too much time to fix after a little bit of debugging. The second type of problem is where if a program asks the user to input in a string, and it returns a null value or something unexpected, that's program error and the cause could be completely unknown to the programmer and more significant amounts of time will be needed to solve it.
Rust has a major commitment in how it handles errors. It does that by grouping errors into two major categories, recoverable and unrecoverable. When an error is identified as recoverable, such as a file not found error, the program will attempt to tell the error to the user and then try again. An unrecoverable error is when that said task is impossible, such as an out-of-bounds exception within an array. The interesting thing is that most languages don't distinguish between these two types of errors and handles them both in the same way, but since Rust doesn't have exceptions in its language, it handles them in other formats.
Recovering with Result
Most of the time, the errors within the program aren't serious enough for the program to completely break and stop. When a function within the problem fails, there are many options available to the programmer that they can do.
The Result enum can have two different outcomes, Ok and Err, which is pretty self-explanatory. A simple function with the two would look something like this.
enum Result<A, B> { Ok(A), Err(B), }
A and B within that function are just two generic types of parameters, to put it simply A represents what type of value will be returned when Ok is successful, and B is what type of value will be returned when Err is true. Because Result has these types of generic parameters, we can use the Result type and all the functions that it comes with to determine what to do when the values are returned whether it be ok or err.
Let me do a quick demonstration of using the result enum.
use std::fs::File; fn main() { let f = File::open("helloworld.txt"); let f = match f { Ok(file) => file, Err(error) => panic!("Problem opening the file: {:?}", error), }; }
In this main function, we're attempting to open the "helloworld.txt" text file, there are two possible outcomes to this. In the case of Ok, the file will be opened, in the case of Err, if something does occur, the program is shut down with the panic function and there will be an error message shown to the user.
Unwrap and Expect
The result type has many shortcut methods that we can use to do various tasks, this helps the user out with different types of functionality.
The first method that I'll be talking about is the unwrap method, a method that works like the match method that I wrote about above. It works like this, if the result is Ok, then unwrap return to the user what is inside the ok value, If the result is an error, then the unwrap method will call the panic method for us. Very convenient!
fn main() { let f = File::open("helloworld.txt").unwrap(); }
This is just a simple example of what the unwrap method will look like, if there is no "helloworld.txt" file, then an error message will be displayed.
thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: Error { repr: Os { code: 2, message: "No such file or directory" } }',
The other method that I talked about is the expect method, it's usage is quite similar to the unwrap message, except that you can include your own message to the user.
fn main() { let f = File::open("helloworld.txt").expect("Failed to open helloworld.txt"); }
In this case, when the method is run and the "helloworld.txt" file is unable to be found, a slightly more detailed error message is shown.
thread 'main' panicked at 'Failed to open helloworld.txt: Error { repr: Os { code: 2, message: "No such file or directory" } }'
I'll be demonstrating a few different ways to handle errors in part two of this article.
Packages and Crates
A library or crate as it's called in Rust is basically a collection of functions that can be added to your program. They can make life a lot more convenient for you after you've written them as they're can help optimize a task and prevent additional lines of code that you don't have to write doing the same function over and over again. They are a necessary part of coding as it is your job as a developer to develop appropriate solutions to the problems presented to you by your clients.
As the programs we write grow in complexity and size, organizing your code is key because trying to keep track of everything just by memory will become incredibly difficult. The program that I wrote in my previous article was only one module in one file, as the program grows in size, you can organize the code by splitting it into multiple modules within multiple files.
A crate is another name for a library in Rust, the crate is the source file in which the Rust compiler recognizes your program and is what makes up the root components of the crate. A package is one or more crates, and a package contains the Cargo.toml file that tells the compiler how to build the program. There are a few rules that determine what a package can contain. A package must contain zero or one library crate, and the program won't work when it exceeds that amount. The package can contain as many binary crates as you want, but it must contain at least one library or binary crate.
I will now show you the structure of a rust package when a new project is created.
$ cargo new test-project Created binary (application) 'test-project' package $ ls test-project Cargo.toml src $ ls test-project/src main.rs
When the command is entered to create the test project, Cargo creates a new Cargo.toml file, making a package. When you look at the contents of the Cargo.toml file, there is no mention of the main.rs file because Cargo has the convention that the main.rs file located in the source folder is the crate root of the binary crate with the same name as the package. Cargo also knows that if the package directory also has a file within the src folder called lib.rs, the package will contain a library crate with the same name as the package, and then lib.rs as its root crate.
In this example, there is a package that only contains src/main.rs, which translates to meaning it only has a binary crate called test-project. In another example, if a package has both src/main.rs and src/lib.rs, that means there are two crates, both a library and a binary crate with both the same name as the package.
The reason for this is that a crate will group any sort of related functionality so that within a project that functionality is easily shared and accessible. For example, if I have a crate called structures which has a trait within the crate called abc. If there is another crate called examples which also has struct (custom data type) called abc as well. The compiler is not confused about which abc we are referring to because a crate's functionality is defined within its own location. In the examples crate, abc is referring to the custom struct that we have created, if we need to access the abc trait from the structures crate, then all we need to do is call that trait as structures::abc, the crate name in front and the trait behind. I will be creating an example of this later in the article as well.
Part 2
Error Handling
Where we left off in the previous article, I had introduced very basic error handling within the program. It looked like this snippet of code below, and it just included a very simple error message that tells us that when there's an error, there will be a message included in there that says, "could not read file."
let args = Commandline::from_args(); let content = std::fs::read_to_string(&args.path) .expect("could not read file");
What we see when I input in an invalid file, is something ugly like this:
There are obviously much nicer ways that we can handle errors in Rust, and I'll demonstrate a few different ways how.
Results
In our main function, we use the function read_to_string to read the input from the user. The thing about that function is that while it reads the input as a string, it doesn't return the output as a string. Instead, it returns a Result that either contains a String or an error of some sort. Since we know that Result is an enum (Enums allow you to define a type by listing its possible values), we can use the match function to check which one it is.
let result = std::fs::read_to_string("src/test.txt"); match result { Ok(content) => { println!("File content: {}", content); } Err(error) => { println!("Whoops: {}", error); } }
When we run the program and get an error, we can get something much nicer looking like this:
Now that looks way nicer than the previous error handling, instead of a big mess of an error response, we get a simple message along with the type of error that occurred.
Unwrapping
We can now access the contents of the file, but we can't really do anything with it after using the match block previously. Now we'll need to deal with the error case. Normally everything in the match block needs to return something else of the same type, but we can get around that pretty easily.
let result = std::fs::read_to_string("test.txt"); let content = match result { Ok(content) => { content }, Err(error) => { panic!("Can't deal with {}, just exit here", error);} }; println!("file content: {}", content);
We can use the String within content after the match block finishes computing. If there are no errors, then the match block goes through and the contents of the file are printed afterward. If there is an error, then the program just exits, this is actually a pretty convenient way of handling the error as exiting the program is an effective strategy. Here's what it'll look like:
Once again we get the custom error message that we typed out, I think this way is a bit safer as the program just aborts when it encounters the error.
Context Handling
Now we'll add some context to our errors. For example, when we run the code and get the error:
Error: The system cannot find the file specified. (os error 2)
If there are any cases where the code doesn't contain the file name, that sort of error message isn't that useful. To counter this, we will use the anyhow library to help us out. It provides a context trait that can be used to add a description to the error to make things easier for us to debug. It also includes the original error message to make finding the root cause that much simpler.
First, we will import the anyhow crate by adding anyhow = "1.0" to the dependencies section in the cargo.toml file. I talked about crates in the previous article but crates are basically libraries within Rust. Now our cargo.toml file will look like this:
[dependencies] structopt = "0.3.13" anyhow = "1.0"
Here we have the previous structopt crate which lets us create custom structures/structs, and we also have the anyhow crate which will let us add custom error messages.
The full example will now look a bit like this:
use anyhow::{Context, Result}; fn main() -> Result<()>{ let path = "test.txt"; let content = std::fs::read_to_string(path) .with_context(|| format!("could not read the input`{}`", path))?; println!("file content: {}", content); Ok(()) }
There's also a bit of additional functionality that I added within the code that I'll talk about in the next section, but for now, if we run the code and get an error, it'll now look like this:
Now we get a nice error message that shows what we inputted and the file that we were trying to get the input from along with the original error message directly from the Rust Compiler. In the future, when programs get more complex, more in-depth error messages will prove to be a valuable asset.
Packages and Crates
In this part of the article, I'll show you how to write additional functions in Rust as well as creating our own small library/crate.
Currently, within the program, all of the code is located within the same main function. This isn't doesn't follow good coding practices so what we're going to do is create a function for finding the content of the file and separate it from the original main function.
So to do that, we're going to create a function called find_result, the result should look like this:
fn find_result(result: &str, pattern: &str) { for line in result.lines() { if line.contains(pattern) { println!("{}", line); } } }
Now we'll create a new file within our source folder called lib.rs. Then, move the whole find_result function to the newly created lib.rs file and add a pub in front of the function to make it something that users can access.
pub fn find_result(result: &str, pattern: &str) { for line in result.lines() { if line.contains(pattern) { println!("{}", line); } } }
We then remove the find_result from the main.rs file and we can now call the new function from the lib.rs file by adding the name of the folder in front of the function, in this case, grrs::.
grrs::find_result(&result, &args.pattern);
Making the main function cleaner helps increase the visibility of your main function as you never want it to be too cluttered. In the future, as you plan out the applications of a specific program, you can think about writing a program for it and then implementing it within your project as having a library can help increase the efficiency and you can even use those functions you wrote in other applications.
Conclusion
I hope you enjoyed the article. I wanted to go a bit more in-depth but the article was already getting quite long. If you have any more questions feel free to comment or check out the rust programming language book, it helped a lot when I was writing the article as the language is quite complex and there are quite a few complicated interactions even with a program that isn't all that complex.
Sources:
The Rust Programming Language - The Rust Programming Language. (2018). Rust Programming Language. https://doc.rust-lang.org/book/