Skip to content

← Closures and Iterators step 18 of 28

Easy Primitives

any, all, find, position: audit a list of records

Answer four questions about a list of "key=value" records. Each question should be answered by one call to an iterator method that stops as soon as it knows the answer.

pub fn audit(records: Vec<String>) -> (bool, bool, Option<usize>, Option<String>)

Splitting a record

Split each record at its first =. The text before it is the key and everything after it is the value, so "a=b=c" has key "a" and value "b=c". A record with no = at all uses the whole string as its key and has an empty value. Records can be any strings, including empty strings and non-ASCII text, and the list can be empty.

The four answers

Element Question
.0 Does any record have an empty value (zero characters)?
.1 Do all keys avoid the ASCII uppercase letters A to Z? Other characters, including non-ASCII uppercase letters such as É, do not count. Values are ignored.
.2 The zero-based index of the first record whose key is exactly equal (case-sensitive, byte for byte) to the key of an earlier record, or None.
.3 The first value, in record order, that str::parse::<i64> accepts and that is below zero, returned as the original value string, or None.

str::parse::<i64> accepts an optional leading + or - followed by one or more ASCII digits, and nothing else: no spaces, no underscores, and nothing outside the i64 range. So "-007" counts and is returned as "-007", while "- 7", "-1.5" and "-9223372036854775809" do not count. "-0" parses as 0, which is not below zero.

[]                          -> (false, true,  None,    None)
["a=1", "b=2", "a=3"]       -> (false, true,  Some(2), None)
["a=5", "b=-7", "c=-9"]     -> (false, true,  None,    Some("-7"))
["a=1", "B=2"]              -> (false, false, None,    None)
["noequals"]                -> (true,  true,  None,    None)

In the second example, record 2 has key "a", which record 0 already used. In the third, "-7" is the first negative value, so "-9" is never examined. In the fourth, key "B" contains an uppercase letter. In the last, the record has no =, so its value is empty.

The test harness turns your tuple into a JSON object with the fields any_empty, all_lower, first_dup and first_negative, using null for None.

Empty input

Look at the first example. On an empty input:

  • any() returns false. No element satisfies the predicate, because there are no elements.
  • all() returns true. There is no element that fails the predicate, so the claim "every element passes" holds. Logicians call this vacuously true.

This is the standard logic for "there exists" and "for all" over an empty collection, and it is not specific to Rust: Python's any([]) and all([]) give the same answers. It can still surprise a caller. "All users are verified" is true when there are no users, which may not be what the code that relies on it intended. When that case matters, check for emptiness separately.

Five short-circuiting methods

A method short-circuits when it stops pulling items from the iterator as soon as the answer is known. any stops at the first true, all at the first false, and find, position and find_map at the first match. Their signatures in std::iter::Iterator, with the where Self: Sized bounds left out, are:

fn any<F: FnMut(Self::Item) -> bool>(&mut self, f: F) -> bool
fn all<F: FnMut(Self::Item) -> bool>(&mut self, f: F) -> bool
fn find<P: FnMut(&Self::Item) -> bool>(&mut self, predicate: P) -> Option<Self::Item>
fn position<P: FnMut(Self::Item) -> bool>(&mut self, predicate: P) -> Option<usize>
fn find_map<B, F: FnMut(Self::Item) -> Option<B>>(&mut self, f: F) -> Option<B>

Two details in those signatures matter.

They take &mut self, not self. The iterator is borrowed, not used up, so after a search you can keep reading from where it stopped. This also means a second position call on the same iterator counts from the item after the first match, not from the original start:

let mut it = [1, 0, 2, 0].into_iter();
assert_eq!(it.position(|x| x == 0), Some(1));
assert_eq!(it.position(|x| x == 0), Some(1)); // index 3 overall

To get absolute indices from repeated searches, add the offsets yourself, or use enumerate() so each item carries its original index.

find's predicate takes &Self::Item; any, all and position take Self::Item. find must return the matching item, so it only lends each item to the predicate and keeps ownership. filter takes &Self::Item for the same reason. In this exercise parts.iter() yields &(&str, &str), so a find predicate receives &&(&str, &str). A tuple pattern such as |(_, v)| still works there, because patterns can match through references.

find_map is the short-circuiting relative of filter_map: it returns the first Some the closure produces and stops.

The stepper below shows the same stopping rule on the chain .map(|x| x * 10).filter(|x| x % 20 == 0).take(2) over 1..=6. Each step moves one item through every stage before the next item is pulled. Once take(2) has yielded two items it stops asking for more, so the remaining source items never reach map. any, all and the find family stop in the same way when they have their answer.

Loading visualization…

The starter passes the tests but fails the Clippy gate

The starter answers three of the questions with filter(..).next(), which Clippy's filter_next lint reports. That lint is on by default, and the grader runs Clippy with -D warnings, which turns every warning into an error:

error: called `filter(..).next()` on an `Iterator`
help: use `.find(..)` instead

Rewriting filter(p).next().is_some() as find(p).is_some() is only half the fix. That expression asks "is there a match?", which is what any(p) asks directly. Clippy's search_is_some lint suggests any for find(..).is_some() and !any(..) for find(..).is_none(), but it is not on by default. The starter turns it on with a crate-level attribute:

#![deny(clippy::search_is_some)]

Keep that line. An inner attribute (#![...]) that applies to the whole file must come before every item such as use or fn; comments above it are fine. With it in place, find(..).is_some() is reported as:

error: called `is_some()` after searching an `Iterator` with `find`

For the .1 question, negating any (is there any key with an uppercase letter?) also gives the right answer, but all states the question as asked.

Finding the first duplicate

position with a HashSet answers .2 in one call. HashSet::insert returns false when the value was already present, so !seen.insert(k) means "this key is a repeat", and position returns the index of the first record for which that is true.

A predicate that changes outside state deserves a second look, but this one is correct: position calls it on the items in iterator order, once each, and stops at the first true. When it is called for record i, the set holds exactly the keys of records 0 to i - 1.

The grade is: the code compiles, every test passes, and clippy -D warnings reports nothing.