Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

13.9 Improving the I/O Project with Closures and Iterators

13.9.0 Before We Begin

During its design, Rust drew inspiration from many languages, and functional programming had a particularly strong influence on Rust. Functional programming often includes passing functions as values to parameters, returning them from other functions, assigning them to variables for later execution, and so on.

In this chapter, we will discuss some Rust features that are similar to what many languages call functional features:

13.9.1 Review

This article uses the grep project from Chapter 12 as an example to show how closures and iterators can improve an I/O project, so let’s review it first.

Chapter 12 builds a practical project: a command-line program. This program is a grep (Global Regular Expression Print) tool, a global regular-expression search and output utility. Its job is to search for specified text in a specified file.

The project is split into these steps:

  • Accept command-line arguments
  • Read a file
  • Refactor to improve modules and error handling
  • Develop library functionality with TDD (test-driven development)
  • Use environment variables
  • Write error messages to standard error instead of standard output

lib.rs:

#![allow(unused)]
fn main() {
use std::error::Error;  
use std::fs;  
  
pub struct Config {  
    pub query: String,  
    pub filename: String,  
    pub case_sensitive: bool,  
}  
  
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();  
        let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();  
        Ok(Config { query, filename, case_sensitive})  
    }  
}  
  
pub fn run(config: Config) -> Result<(), Box<dyn Error>> {  
    let contents = fs::read_to_string(config.filename)?;  
    let results = if config.case_sensitive {  
        search(&config.query, &contents)  
    } else {  
        search_case_insensitive(&config.query, &contents)  
    };  
    for line in results {  
        println!("{}", line);  
    }  
    Ok(())  
}  
  
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    let mut results = Vec::new();  
    for line in contents.lines() {  
        if line.contains(query) {  
            results.push(line);  
        }  
    }  
    results  
}  
  
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    let mut results = Vec::new();  
    let query = query.to_lowercase();  
    for line in contents.lines() {  
        if line.to_lowercase().contains(&query) {  
            results.push(line);  
        }  
    }  
    results  
}  
  
#[cfg(test)]  
mod tests {  
    use super::*;  
  
    #[test]  
    fn case_sensitive() {  
        let query = "duct";  
        let contents = "\  
Rust:  
safe, fast, productive.  
Pick three.  
Duct tape.";  
  
        assert_eq!(vec!["safe, fast, productive."], search(query, contents));  
    }  
  
    #[test]  
    fn case_insensitive() {  
        let query = "rUsT";  
        let contents = "\  
Rust:  
safe, fast, productive.  
Pick three.  
Trust me.";  
  
        assert_eq!(  
            vec!["Rust:", "Trust me."],  
            search_case_insensitive(query, contents)  
        );  
    }  
}
}

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| {  
        eprintln!("Problem parsing arguments: {}", err);  
        process::exit(1);  
    });  
    if let Err(e) = minigrep::run(config) {  
        eprintln!("Application error: {}", e);  
        process::exit(1);  
    }  
}

13.9.2 Improving the new Function

Take a look at the new function in lib.rs:

#![allow(unused)]
fn main() {
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();  
        let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();  
        Ok(Config { query, filename, case_sensitive})  
    }  
}
}

These two lines:

#![allow(unused)]
fn main() {
let query = args[1].clone();  
let filename = args[2].clone();
}

use cloning. That is because the argument passed in is &[String], which does not have ownership, but the Config struct needs to own the data. Only cloning lets Config own query and filename, even though cloning adds performance overhead.

After learning iterators, we can pass an iterator directly into new so that it can take ownership. We can also use the iterator to handle length checks and indexing, which makes the scope of new’s responsibility clearer.

Before changing new, we need to change how main handles input arguments. Originally it was:

#![allow(unused)]
fn main() {
let args:Vec<String> = env::args().collect();  
let config = Config::new(&args).unwrap_or_else(|err| {  
    eprintln!("Problem parsing arguments: {}", err);  
    process::exit(1);  
});  
}

Now we remove collect and pass the arguments from env::args() directly to new:

#![allow(unused)]
fn main() {
let config = Config::new(env::args()).unwrap_or_else(|err| {  
    eprintln!("Problem parsing arguments: {}", err);  
    process::exit(1);  
});  
}

The return type of env::args() is std::env::Args, which implements the Iterator trait, so it is an iterator.

Now let’s modify new:

#![allow(unused)]
fn main() {
impl Config {  
    pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {  
        if args.len() < 3 {  
            return Err("Not enough arguments");  
        }  
        args.next();  
        let query = args.next().unwrap();  
        let filename = args.next().unwrap();  
        let case_sensitive = std::env::var("CASE_INSENSITIVE").is_err();  
        Ok(Config { query, filename, case_sensitive})  
    }  
}
}
  • The parameter args is changed to std::env::Args, and it must also be declared mutable with mut because next is a consuming iterator method.
  • The line that contains only args.next(); is there because the first value returned by env::args() is the program name, not an argument. Calling args.next(); skips that value.
  • query and filename are then obtained in order by calling next. At that point, query and filename are owned String values. Since next returns an Option, we can use unwrap to extract the value.

13.9.3 Improving the search Function

The current search function looks like this:

#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    let mut results = Vec::new();  
    for line in contents.lines() {  
        if line.contains(query) {  
            results.push(line);  
        }  
    }  
    results  
}
}

contents.lines() also returns an iterator. Here we manually check whether each line contains the keyword stored in query, and if it does, we push that line into the Vector and finally return the Vector.

For finding items that satisfy a condition in an iterator and building a new iterator, we can use filter:

#![allow(unused)]
fn main() {
pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    contents.lines().filter(|line| line.contains(query)).collect()  
}
}

Using contains inside the closure implements the same logic.

Since the ordinary search function can use iterators, the case-insensitive search function can use them too:

#![allow(unused)]
fn main() {
pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    let query = query.to_lowercase();  
    contents  
        .lines()  
        .filter(|line| line.to_lowercase().contains(&query))  
        .collect()  
}
}

Here we still lowercase the query once up front. For each line, line.to_lowercase() creates a temporary lowercase String used only for the contains check, while the original line (a &str into contents) is what gets collected. That keeps the return type Vec<&'a str> valid.

If you instead wrote contents.to_lowercase().lines()...collect(), the iterator would yield references into a temporary lowercase String, and those references could not be returned as &'a str into the original contents—the code would not compile.

In terms of both code volume and readability, using filter is better. In addition, filter reduces temporary variables. Eliminating mutable state (let mut results = Vec::new();) also makes it possible to improve search performance through parallelization in the future, because we no longer need to worry about concurrent access safety for results.