12.5.0 Before We Begin
In Chapter 12, we will build a real project: a command-line program. This program is a grep (Global Regular Expression Print), a tool for global regular-expression search and output. Its job is to search for the specified text in the specified file.
This project has several steps:
- Receive command-line arguments
- Read files
- Refactor: improve modules and error handling (this article)
- Use TDD (test-driven development) to develop library functionality
- Use environment variables
- Write error messages to standard error instead of standard output
If you find this helpful, please like, bookmark, and follow. To keep learning along, follow this series.
12.5.1 Review
The previous two articles completed modularization optimization and error handling. In this article, we will do further optimization on that basis.
Here is all the code written up to the previous article:
use std::env;
use std::fs;
use std::process;
struct Config {
query: String,
filename: String,
}
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
impl Config {
fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
12.5.2 Extracting Logic from main
As discussed in 12.3. Refactoring Pt. 1, we follow the guiding principle for separating concerns in binary programs:
- Split the program into
main.rsandlib.rs, and put business logic inlib.rs - If the logic is small, keeping it in
main.rsis fine - As the logic becomes more complex, extract it from
main.rsintolib.rs
According to that principle, everything in main except configuration parsing and error handling should be extracted into a run function. That keeps main small enough that we can verify correctness just by reading it, while the rest of the logic can be verified through tests (see Chapter 11 for tests).
For the code we have so far, the run function should be:
fn run(config: Config) {
let contents = fs::read_to_string(config.filename)
.expect("Something went wrong while reading the file");
println!("With text:\n{}", contents);
}
main should also call run:
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
run(config);
}
12.5.3 Improving Error Handling in run
Right now, run uses expect for file-reading errors. That kind of error handling calls panic!. What we want instead is to propagate errors with Result, just like Config::new:
fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
The
Okvariant ofResultcorresponds to(), the unit type. This type means “nothing is returned” or “there is nothing here,” which is fine becauserundoes not need to return anything on success. The final lineOk(())returnsOkwith a unit value.The
Errvariant ofResultisBox<dyn Error>. You do not need to understand it in depth yet; just know that it represents any type that implements thestd::error::Errortrait (here I wrote justErrorbecause I imported it withuse std::error::Error;). This means different error types can be returned in different situations.dynis short for dynamic.The
?symbol was explained in detail in 9.3. Result Enum and Recoverable Errors Pt. 2. Briefly,read_to_stringreturns aResult. Adding?means that ifread_to_stringreturnsOk, the value insideOkis returned and assigned to the variable; if it returnsErr, the function terminates immediately and returns theErrand its attached error information. In other words,?is equivalent to:
let contents = match fs::read_to_string(config.filename){
Ok(contents) => contents,
Err(e) => return Err(e),
};
With this change, the error is propagated to the caller, which is main, so main must handle the error:
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
The if let used here is syntactic sugar for match; think of it as a match that handles only one branch. See 6.4. Simple Control Flow - If Let for details. Note that if let and if are not the same thing, so do not confuse them.
12.5.4 Moving the Business Logic
Now that all the functions and error handling are separated, the next step is to move them into lib.rs.
The things to move are these functions, structs, and related imports.
The result after moving them (lib.rs) is:
use std::error::Error;
use std::fs;
pub struct Config {
pub query: String,
pub filename: String,
}
impl Config {
pub fn new(args: &[String]) -> Result<Config, &'static str> {
if args.len() < 3 {
return Err("Not enough arguments");
}
let query = args[1].clone();
let filename = args[2].clone();
Ok(Config { query, filename})
}
}
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {
let contents = fs::read_to_string(config.filename)?;
println!("With text:\n{}", contents);
Ok(())
}
Note: all structs, methods on structs, and functions used by main.rs must be marked pub so they can be called.
Now look at main.rs:
use std::env;
use std::process;
use minigrep::Config;
fn main() {
let args:Vec<String> = env::args().collect();
let config = Config::new(&args).unwrap_or_else(|err| {
println!("Problem parsing arguments: {}", err);
process::exit(1);
});
if let Err(e) = minigrep::run(config) {
println!("Application error: {}", e);
process::exit(1);
}
}
All refactoring tasks are complete. The next step is to write tests (the next article).
Top comments (0)