Iteration
Since programs are essentially built to process data (and make decisions on that data), the patterns used to step through the data have a big impact on the program’s readability.
The iterator pattern has been around for decades, and suggests a “standardized” approach to consuming data from a source one chunk at a time. The idea is that it’s more common and helpful to iterate the data source—to progressively handle the collection of data by processing the first part, then the next, and so on, rather than handling the entire set all at once.
Imagine a data structure that represents a relational database SELECT
query, which typically organizes the results as rows. If this query had only one or a couple of rows, you could handle the entire result set at once, and assign each row to a local variable, and perform whatever operations on that data that were appropriate.
But if the query has 100 or 1,000 (or more!) rows, you’ll need iterative processing to deal with this data (typically, a loop).
The iterator pattern defines a data structure called an “iterator” that has a reference to an underlying data source (like the query result rows), which exposes a method like next()
. Calling next()
returns the next piece of data (i.e., a “record” or “row” from a database query).
You don’t always know how many pieces of data that you will need to iterate through, so the pattern typically indicates completion by some special value or exception once you iterate through the entire set and go past the end.
The importance of the iterator pattern is in adhering to a standard way of processing data iteratively, which creates cleaner and easier to understand code, as opposed to having every data structure/source define its own custom way of handling its data.
After many years of various JS community efforts around mutually agreed-upon iteration techniques, ES6 standardized a specific protocol for the iterator pattern directly in the language. The protocol defines a next()
method whose return is an object called an iterator result; the object has value
and done
properties, where done
is a boolean that is false
until the iteration over the underlying data source is complete.
Consuming Iterators
With the ES6 iteration protocol in place, it’s workable to consume a data source one value at a time, checking after each next()
call for done
to be true
to stop the iteration. But this approach is rather manual, so ES6 also included several mechanisms (syntax and APIs) for standardized consumption of these iterators.
One such mechanism is the for..of
loop:
// given an iterator of some data source:
var it = /* .. */;
// loop over its results one at a time
for (let val of it) {
console.log(`Iterator value: ${ val }`);
}
// Iterator value: ..
// Iterator value: ..
// ..
NOTE: |
---|
We’ll omit the manual loop equivalent here, but it’s definitely less readable than the for..of loop! |
Another mechanism that’s often used for consuming iterators is the ...
operator. This operator actually has two symmetrical forms: spread and rest (or gather, as I prefer). The spread form is an iterator-consumer.
To spread an iterator, you have to have something to spread it into. There are two possibilities in JS: an array or an argument list for a function call.
An array spread:
// spread an iterator into an array,
// with each iterated value occupying
// an array element position.
var vals = [ ...it ];
A function call spread:
// spread an iterator into a function,
// call with each iterated value
// occupying an argument position.
doSomethingUseful( ...it );
In both cases, the iterator-spread form of ...
follows the iterator-consumption protocol (the same as the for..of
loop) to retrieve all available values from an iterator and place (aka, spread) them into the receiving context (array, argument list).
Iterables
The iterator-consumption protocol is technically defined for consuming iterables; an iterable is a value that can be iterated over.
The protocol automatically creates an iterator instance from an iterable, and consumes just that iterator instance to its completion. This means a single iterable could be consumed more than once; each time, a new iterator instance would be created and used.
So where do we find iterables?
ES6 defined the basic data structure/collection types in JS as iterables. This includes strings, arrays, maps, sets, and others.
Consider:
// an array is an iterable
var arr = [ 10, 20, 30 ];
for (let val of arr) {
console.log(`Array value: ${ val }`);
}
// Array value: 10
// Array value: 20
// Array value: 30
Since arrays are iterables, we can shallow-copy an array using iterator consumption via the ...
spread operator:
var arrCopy = [ ...arr ];
We can also iterate the characters in a string one at a time:
var greeting = "Hello world!";
var chars = [ ...greeting ];
chars;
// [ "H", "e", "l", "l", "o", " ",
// "w", "o", "r", "l", "d", "!" ]
A Map
data structure uses objects as keys, associating a value (of any type) with that object. Maps have a different default iteration than seen here, in that the iteration is not just over the map’s values but instead its entries. An entry is a tuple (2-element array) including both a key and a value.
Consider:
// given two DOM elements, `btn1` and `btn2`
var buttonNames = new Map();
buttonNames.set(btn1,"Button 1");
buttonNames.set(btn2,"Button 2");
for (let [btn,btnName] of buttonNames) {
btn.addEventListener("click",function onClick(){
console.log(`Clicked ${ btnName }`);
});
}
In the for..of
loop over the default map iteration, we use the [btn,btnName]
syntax (called “array destructuring”) to break down each consumed tuple into the respective key/value pairs (btn1
/ "Button 1"
and btn2
/ "Button 2"
).
Each of the built-in iterables in JS expose a default iteration, one which likely matches your intuition. But you can also choose a more specific iteration if necessary. For example, if we want to consume only the values of the above buttonNames
map, we can call values()
to get a values-only iterator:
for (let btnName of buttonNames.values()) {
console.log(btnName);
}
// Button 1
// Button 2
Or if we want the index and value in an array iteration, we can make an entries iterator with the entries()
method:
var arr = [ 10, 20, 30 ];
for (let [idx,val] of arr.entries()) {
console.log(`[${ idx }]: ${ val }`);
}
// [0]: 10
// [1]: 20
// [2]: 30
For the most part, all built-in iterables in JS have three iterator forms available: keys-only (keys()
), values-only (values()
), and entries (entries()
).
Beyond just using built-in iterables, you can also ensure your own data structures adhere to the iteration protocol; doing so means you opt into the ability to consume your data with for..of
loops and the ...
operator. “Standardizing” on this protocol means code that is overall more readily recognizable and readable.
NOTE: |
---|
You may have noticed a nuanced shift that occurred in this discussion. We started by talking about consuming iterators, but then switched to talking about iterating over iterables. The iteration-consumption protocol expects an iterable, but the reason we can provide a direct iterator is that an iterator is just an iterable of itself! When creating an iterator instance from an existing iterator, the iterator itself is returned. |