DEV Community

Cover image for [Rust Guide] 13.9. Improving the I/O Project With Closures and Iterators
SomeB1oody
SomeB1oody

Posted on

[Rust Guide] 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:

  • Closures
  • Iterators
  • Improving the I/O Project with Closures and Iterators (this article)
  • Performance of Closures and Iterators

If you find this helpful, please like, bookmark, and follow. To keep learning along, follow this series.

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:

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.to_lowercase().lines() {  
        if line.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)  
        );  
    }  
}
Enter fullscreen mode Exit fullscreen mode

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);  
    }  
}
Enter fullscreen mode Exit fullscreen mode

13.9.2 Improving the new Function

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

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})  
    }  
}
Enter fullscreen mode Exit fullscreen mode

These two lines:

let query = args[1].clone();  
let filename = args[2].clone();
Enter fullscreen mode Exit fullscreen mode

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:

let args:Vec<String> = env::args().collect();  
let config = Config::new(&args).unwrap_or_else(|err| {  
    eprintln!("Problem parsing arguments: {}", err);  
    process::exit(1);  
});  
Enter fullscreen mode Exit fullscreen mode

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

let config = Config::new(env::args()).unwrap_or_else(|err| {  
    eprintln!("Problem parsing arguments: {}", err);  
    process::exit(1);  
});  
Enter fullscreen mode Exit fullscreen mode

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:

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})  
    }  
}
Enter fullscreen mode Exit fullscreen mode
  • 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:

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  
}
Enter fullscreen mode Exit fullscreen mode

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:

pub fn search<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    contents.lines().filter(|line| line.contains(query)).collect()  
}
Enter fullscreen mode Exit fullscreen mode

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:

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    contents.to_lowercase()  
        .lines()  
        .filter(|line| line.contains(&query.to_lowercase()))  
        .collect()  
}
Enter fullscreen mode Exit fullscreen mode

Note that query.to_lowercase() needs & because query.to_lowercase() produces a String, while contains expects &str. So you cannot pass query.to_lowercase() directly; you must pass a reference, that is, &query.to_lowercase(), for it to work correctly.

To convert to &str, you can use &, of course, but you can also use as_str:

pub fn search_case_insensitive<'a>(query: &str, contents: &'a str) -> Vec<&'a str> {  
    contents.to_lowercase()  
        .lines()  
        .filter(|line| line.contains(query.to_lowercase().as_str()))  
        .collect()  
}
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)