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)
);
}
}
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:
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:
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:
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:
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:
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
argsis changed tostd::env::Args, and it must also be declared mutable withmutbecausenextis a consuming iterator method. - The line that contains only
args.next();is there because the first value returned byenv::args()is the program name, not an argument. Callingargs.next();skips that value. -
queryandfilenameare then obtained in order by callingnext. At that point,queryandfilenameare ownedStringvalues. Sincenextreturns anOption, we can useunwrapto 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
}
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()
}
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()
}
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()
}
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)