The iterator pattern is a design pattern which can be used to decouple (traversal) algorithms from containers.
We'll take a look into what it actually is, how to apply it, and which problems it solves!
What does it state?
- The elements of an aggregate object should be accessed and traversed without exposing its representation (underlying data structure)
- New traversal operations should be defined for an aggregate object without changing its interface
Those two points may sound a little complicated, but you can view it this way:
If you define access and traversal operations within an object's interface (its methods, viewable and usable from the outside), it becomes inflexible.
You simply can't change the interface without breaking some code, especially code which implements the interface.
This applies to all code. The one you've written, and the one others have written, third-parties so to say. But if you provide a common interface, which all containers and users can agree on, you can decouple access and traversal logic completely, and let your container only return one common interface.
An example problem
Imagine a collection of friends.
The class Friends
abstracts away the underlying storage of friends and uses an array for that.
getFriends(index)
already exposes some implementation details (by taking an index as an argument), and thus basically ties the container to a certain group of data structures (random access data structures to be precise and an array in this case).
class Friends {
constructor() {
this._friends = [];
}
getFriend(index) {
return this._friends[index];
}
addFriend(friend) {
this._friends.push(friend);
}
get count() {
return this._friends.length;
}
}
Try to think of what happens if someone wants to loop over all friends, what would you have to do?
The implementation could look something like the following.
const friends = new Friends();
friends.addFriend(friendOne);
friends.addFriend(friendTwo);
for (let i = 0; i < friends.count; i++) {
console.log(friends.getFriend(i));
}
But what happens if you want to refactor Friends
to use a Set?
Remember: A Set is an unordered collection. It usually doesn't provide index-based access, and it doesn't necessarily maintain the order of insertion, as the backing implementation may vary.
And this is where you have a problem. Your access method doesn't work anymore, as soon as you want to use another data structure which doesn't provide index-based access.
Solving the issue
By using an iterator, you can decouple the access and traversal logic of your container from its underlying data structure. And in case of your Friends
class, it could look like the following:
class Friends {
constructor() {
this._friends = [];
}
[Symbol.iterator]() {
let index = 0;
return {
next: () => {
return index < this.count
? { value: this._friends[index++], done: false }
: { done: true };
}
};
}
addFriend(friend) {
this._friends.push(friend);
}
get count() {
return this._friends.length;
}
}
See that interesting looking new method [Symbol.iterator]()
?
That's a JavaScript-specific, a Symbol (a well-known one) that the runtime will call on certain occasions, and it has basically replaced getFriend(index)
which leaked too much implementation detail.
You can now change the underlying data structure and then the implementation of the iterator, but every user can still loop over all the elements in the same way.
[Symbol.iterator]()
is called by the runtime every time you use a for..of
loop, and below you see how you can traverse Friends
from now on.
const friends = new Friends();
friends.addFriend(friendOne);
friends.addFriend(friendTwo);
friends.addFriend(friendThree);
for (const friend of friends) {
console.log(friend);
}
Most languages provide something like an iterator, be it an object behind a Symbol method, an interface, or something similar. And it's always a good idea to consider using those as a base of implementing traversal logic for containers.
Some interesting use cases of iterators in JavaScript
Using iterators to decouple the traversal and access logic of classes and objects is already a great use case, but you can do more with iterators than only that.
The following two examples will give you a general idea of how you can use iterators in JavaScript to implement readable and performant implementations for common problems.
Traversing an array in reverse order without reversing the array itself
It may sound tempting to simply reverse an array, but this is an in-place operation and it can be a costly one if your array is pretty large.
With an iterator, you don't have those issues, because the iterator object itself handles everything, as it only keeps track of the index to return the next element from.
function reversed(array) {
return {
[Symbol.iterator]() {
let index = array.length - 1;
return {
next: () => {
return index >= 0
? { value: array[index--], done: false }
: { done: true };
}
};
}
};
}
const array = [1, 2, 3];
for (const value of reversed(array)) {
console.log(value); // => 3 2 1
}
The original array can stay as it is.
Creating a range to iterate over
No need to create an array and fill it with values, which all need memory, if you can simply generate those numbers on demand. This will certainly save some memory as not all numbers have to be kept in-memory. When one was used, it can be thrown away and the garbage collector can delete it as soon as it likes to do.
function range(start, endExclusive) {
return {
[Symbol.iterator]() {
let current = start;
return {
next: () => {
return current < endExclusive
? { value: current++, done: false }
: { done: true };
}
};
}
};
}
for (const value of range(0, 10)) {
console.log(value); // => 0 1 2 3 4 5 6 7 8 9
}
Before you leave
If you like my content, visit me on Twitter, and perhaps you’ll like what you see!