Improving Our I/O Project

With this new knowledge about iterators, we can improve the I/O project in Chapter 12 by using iterators to make places in the code clearer and more concise. Let’s look at how iterators can improve our implementation of the Config::new function and the search function.

Removing a clone Using an Iterator

In Listing 12-6, we added code that took a slice of String values and created an instance of the Config struct by indexing into the slice and cloning the values, allowing the Config struct to own those values. In Listing 13-24, we’ve reproduced the implementation of the Config::new function as it was in Listing 12-23:

Filename: src/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 = env::var("CASE_INSENSITIVE").is_err(); Ok(Config { query, filename, case_sensitive }) } }

Listing 13-24: Reproduction of the Config::new function from Listing 12-23

At the time, we said not to worry about the inefficient clone calls because we would remove them in the future. Well, that time is now!

We needed clone here because we have a slice with String elements in the parameter args, but the new function doesn’t own args. To return ownership of a Config instance, we had to clone the values from the query and filename fields of Config so the Config instance can own its values.

With our new knowledge about iterators, we can change the new function to take ownership of an iterator as its argument instead of borrowing a slice. We’ll use the iterator functionality instead of the code that checks the length of the slice and indexes into specific locations. This will clarify what the Config::new function is doing because the iterator will access the values.

Once Config::new takes ownership of the iterator and stops using indexing operations that borrow, we can move the String values from the iterator into Config rather than calling clone and making a new allocation.

Using the Returned Iterator Directly

Open your I/O project’s src/main.rs file, which should look like this:

Filename: src/main.rs

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); }); // --snip-- }

We’ll change the start of the main function that we had in Listing 12-24 at to the code in Listing 13-25. This won’t compile until we update Config::new as well.

Filename: src/main.rs

fn main() { let config = Config::new(env::args()).unwrap_or_else(|err| { eprintln!("Problem parsing arguments: {}", err); process::exit(1); }); // --snip-- }

Listing 13-25: Passing the return value of env::args to Config::new

The env::args function returns an iterator! Rather than collecting the iterator values into a vector and then passing a slice to Config::new, now we’re passing ownership of the iterator returned from env::args to Config::new directly.

Next, we need to update the definition of Config::new. In your I/O project’s src/lib.rs file, let’s change the signature of Config::new to look like Listing 13-26. This still won’t compile because we need to update the function body.

Filename: src/lib.rs

impl Config { pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> { // --snip--

Listing 13-26: Updating the signature of Config::new to expect an iterator

The standard library documentation for the env::args function shows that the type of the iterator it returns is std::env::Args. We’ve updated the signature of the Config::new function so the parameter args has the type std::env::Args instead of &[String]. Because we’re taking ownership of args and we’ll be mutating args by iterating over it, we can add the mut keyword into the specification of the args parameter to make it mutable.

Using Iterator Trait Methods Instead of Indexing

Next, we’ll fix the body of Config::new. The standard library documentation also mentions that std::env::Args implements the Iterator trait, so we know we can call the next method on it! Listing 13-27 updates the code from Listing 12-23 to use the next method:

Filename: src/lib.rs

  1. # fn main() {}
  2. # use std::env;
  3. #
  4. # struct Config {
  5. # query: String,
  6. # filename: String,
  7. # case_sensitive: bool,
  8. # }
  9. #
  10. impl Config {
  11. pub fn new(mut args: std::env::Args) -> Result<Config, &'static str> {
  12. args.next();
  13. let query = match args.next() {
  14. Some(arg) => arg,
  15. None => return Err("Didn't get a query string"),
  16. };
  17. let filename = match args.next() {
  18. Some(arg) => arg,
  19. None => return Err("Didn't get a file name"),
  20. };
  21. let case_sensitive = env::var("CASE_INSENSITIVE").is_err();
  22. Ok(Config { query, filename, case_sensitive })
  23. }
  24. }

Listing 13-27: Changing the body of Config::new to use iterator methods

Remember that the first value in the return value of env::args is the name of the program. We want to ignore that and get to the next value, so first we call next and do nothing with the return value. Second, we call next to get the value we want to put in the query field of Config. If next returns a Some, we use a match to extract the value. If it returns None, it means not enough arguments were given and we return early with an Err value. We do the same thing for the filename value.

Making Code Clearer with Iterator Adaptors

We can also take advantage of iterators in the search function in our I/O project, which is reproduced here in Listing 13-28 as it was in Listing 12-19:

Filename: src/lib.rs

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 }

Listing 13-28: The implementation of the search function from Listing 12-19

We can write this code in a more concise way using iterator adaptor methods. Doing so also lets us avoid having a mutable intermediate results vector. The functional programming style prefers to minimize the amount of mutable state to make code clearer. Removing the mutable state might enable a future enhancement to make searching happen in parallel, because we wouldn’t have to manage concurrent access to the results vector. Listing 13-29 shows this change:

Filename: src/lib.rs

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

Listing 13-29: Using iterator adaptor methods in the implementation of the search function

Recall that the purpose of the search function is to return all lines in contents that contain the query. Similar to the filter example in Listing 13-19, this code uses the filter adaptor to keep only the lines that line.contains(query) returns true for. We then collect the matching lines into another vector with collect. Much simpler! Feel free to make the same change to use iterator methods in the search_case_insensitive function as well.

The next logical question is which style you should choose in your own code and why: the original implementation in Listing 13-28 or the version using iterators in Listing 13-29. Most Rust programmers prefer to use the iterator style. It’s a bit tougher to get the hang of at first, but once you get a feel for the various iterator adaptors and what they do, iterators can be easier to understand. Instead of fiddling with the various bits of looping and building new vectors, the code focuses on the high-level objective of the loop. This abstracts away some of the commonplace code so it’s easier to see the concepts that are unique to this code, such as the filtering condition each element in the iterator must pass.

But are the two implementations truly equivalent? The intuitive assumption might be that the more low-level loop will be faster. Let’s talk about performance.