Generators Motherf***r, do you use them?

Just about a half year ago we decided to start using ES6 in CKEditor 5. Now all the code uses classes, arrow functions, let and const, maps and sets. Those things are easy to adopt and there may be just a few doubts as to how and when to use them.

Generators and iterators are different because they change the way you think about your API. They’re not just another detail in the implementation, as they only make sense when used globally.

It’s a recurring question — should we use generators for that? Are we not abusing them?

The Obstacles

Using generators changes the way you use an API — for instance you can’t iterate twice over the same iterator:

const map = new Map( [ [ 1, ‘a’ ], [ 2, ‘b’ ], [ 3, ‘c’ ] ] );
const keys = map.keys();
for ( let key of keys ) {
console.log( key ); // 1, 2, 3
}
for ( let key of keys ) {
console.log( key ); // Not executed!
}

You need to get a new iterator every single time:

const map = new Map( [ [ 1, ‘a’ ], [ 2, ‘b’ ], [ 3, ‘c’ ] ] );
for ( let key of map.keys() ) {
console.log( key ); // 1, 2, 3
}
for ( let key of map.keys() ) {
console.log( key ); // 1, 2, 3
}

For developers who are used to cache properties it might result in some bugs. Especially when you use methods which iterate:

const map = new Map( [ [ 1, ‘a’ ], [ 2, ‘b’ ], [ 3, ‘c’ ] ] );
const keys = map.keys();

// In order to count items, the utility must iterate.
if ( utils.count( keys ) < 2 )
return;

for ( let key of keys ) {
console.log( key ); // Returns nothing!
}

The Opportunities

First of all, ES6 Set and Map API work this way. We’ll have to meet these downsides, whether we like it or not. Also, using iterators to return collections has some profits.

You prevent other developers from changing your collection

We had a discussion whether we should clone the array when returning it to prevent developers from making direct changes. Generators solve this problem: user can only iterate over the yielded values. If he wants to do something else he needs to call Array.from():

Array.from( selection.getRanges() );

This will create a clone of the collection. Thanks to that, the original array will never be modified by mistake.

You can stop code execution whenever you need to

Note that sometimes cloning an array is not enough. You may need to do deep clone to prevent user from making changes. We’ve met this case in Selection.getRanges():

*getRanges() {
for ( let range of this._ranges ) {
yield range.clone();
}
}

What I like about using generators is that, for example, if you need to find a specific range, then there’s no need to clone all ranges. When using generators you’ll stop cloning as soon as you’ve found the proper range:

for ( let range of selection.getRanges() ) {
if ( range.start.parent == someElement )
return range;
}

You can simplify your code

Generators yield items, and you don’t need to care how complex the structure is, you simply take them and iterate. For instance, in a DOM-like tree structure we had a collection of items which may contain document fragments intermixed with nodes. Instead of flattening the structure we were able to iterate it in a pretty simple way, delegating the iteration to the DocumentFragment:

*getNodes() {
for ( let node of this.nodes ) {
if ( node instanceof DocumentFragment )
yield* node;
else
yield node;
}
}

“Oh I’m sorry, did I break your concentration?”

I think that we should use generators every time we return collections (getChildren(), getAttributes(), getRanges(), etc.) or otherwise stay safe in our comfort zone.

Generators are the biggest change in the way we think about our code since objective oriented programming was introduced. But for me, after the first WTF! moment, the code became much better and cleaner.