Synchronous generators are a language feature that are available in JavaScript and Dart.
They are a way of producing a list of objects without actually constructing the list manually. In this post we will discuss how they work and why they are so useful.
What’s the Need for Synchronous Generators?
Imagine a function in Dart where you are provided with a list of String
isntances and you want to extract the strings that are less than or equal to 3 digits in length. You could go ahead and implement a function like so:
// simplest implementation of this function using the .where() // function on Iterable Iterable<String> shortStrings(Iterable<String> input) => input.where((s) => s.length <= 3);
This code looks fine to a lot of programmers including me. However, it is using functional programming which is a topic that some code-bases try to stay away from since it can get quite lengthy and difficult to follow (subject to opinions).
You could re-write this code so that it uses a traditional for-loop; but then you have to have an intermediate variable to keep track of your collected results, as shown here:
// having to use an intermediate List to store the results // is not an ideal solution! Iterable<String> shortStrings(Iterable<String> input) { List<String> result = []; for (final String s in input) { if (s.length <= 5) { result.add(s); } } return result; }
The first approach is functional and can get complicated if you keep chaining function calls and the last approach using the intermediate result
variable is too verbose since you have to create a custom list to contain your results. What if you could get the best of both worlds? What if you didn’t have to define that intermediate result
variable? That’s exactly where synchronous generators come into picture.
Anatomy of a Synchronous Generator
A synchronous generator in Dart is a function whose return value is an Iterable
and contains the keyword of sync*
right before its starting parenthesis as shown here:
Iterable myGenerator() sync* { // empty for now }
Inside the synchronous generator you then get access to a special keyword called yield
which is also available in JS. The yield
keyword allows you to quite literally inject a value into the resulting Iterable
of your function.
Let’s see an example. The following two functions achieve the exact same results:
// this is a synchronous generator Iterable<String> getRandomNames1() sync* { yield 'Fido'; yield 'Rex'; yield 'Spot'; } // this one uses a plain Iterable as its result Iterable<String> getRandomNames2() { return ['Fido', 'Rex', 'Spot']; }
The first function is a synchronous generator using the sync*
and the yield
keywords whereas the second function is a typical function that returns an Iterable
. Keep in mind that synchronous generators can only return an instance of Iterable
and not a List
.
Lazy Evaluation of Synchronous Generators
Synchronous generators in Dart, just like in JavaScript, are evaluated lazily. That means the call-site decides how much of the code in the synchronous generator is actually executed. This is the biggest difference between normal functions that return an Iterable and synchronous generators.
Here is an example of a normal function that returns an Iterable<String>
of 10 names:
// This is not a synchronous generator // it generates 10 strings and puts them in an instance // of List<String> and returns the List as an Iterable. Iterable<String> generateNames() { List<String> result = []; for (var i = 0; i < 10; i++) { 'Yielding at index #$i'.log(); result.add('name $i'); } return result; } // since the generateNames() is not a synchronous generator // it is executed in its entirety first and the result is // then returned to the call site void testIt() { // prints out: // [log] Yielding at index #0 // [log] Yielding at index #1 // [log] Yielding at index #2 // [log] Yielding at index #3 // [log] Yielding at index #4 // [log] Yielding at index #5 // [log] Yielding at index #6 // [log] Yielding at index #7 // [log] Yielding at index #8 // [log] Yielding at index #9 // [log] (name 0, name 1) generateNames().take(2).log(); }
But if you turn this function into a synchronous generator, you will reap the results of using synchronous generators and making sure the execution of the calculation of the resulting Iterable
is done lazily and only for the amount of times the call-site consumes the result:
// our function is now a synchronous generator // meaning that the loop might not be executed // in its entirety before the function returns Iterable<String> generateNames() sync* { for (var i = 0; i < 10; i++) { 'Yielding at index #$i'.log(); yield 'name $i'; } } // the call-site decides how much of the code // in the synchronous generator is executed by // deciding how many items from the resulting // iterable to consume void testIt() { // prints out: // [log] Yielding at index #0 // [log] Yielding at index #1 // [log] (name 0, name 1) generateNames().take(2).log(); }
Empty Synchronous Generators
An empty synchronous generator as its name indicates is a synchronous generator whose body does not produce values to be returned in the resulting Iterable
. This means that the synchronous generator might perform some work but at the end of the day, the resulting Iterable
will be empty. Here is an example:
Iterable<String> logLongNames(Iterable<String> names) sync* { // note how this function doesn't yield any values? // this is because it's a generator function that // doesn't use the yield keyword. for (final name in names) { if (name.length > 8) { name.log(); } } // such a generator function is rather useless since // its result cannot be used in any way. } void testIt() { final result = logLongNames( ['John Doe', 'Jane Doe', 'John Smith'], ); // result here is an empty iterable [] result.log(); }
Such an empty synchronous generator is quite against the whole point of a “generator” since a generator function is usually supposed to use the yield
keyword inside even though it might not end up yielding any results eventually but still it should ideally use the yield
keyword. The example above is best served as a normal function so if you see a synchronous generator without the yield keyword, change it to a normal function like so:
// this function doesn't return any values anymore // and has no mention of the sync* keyword void logLongNames(Iterable<String> names) { for (final name in names) { if (name.length > 8) { name.log(); } } } void testIt() { logLongNames( ['John Doe', 'Jane Doe', 'John Smith'], ); }
Nested Synchronous Generators
A synchronous generator can call and embed the result of another synchronous generator into its own result. The result of a synchronous generator is Iterable
so you cannot yield
a value into the resulting iterable if the value you are trying to yield is another Iterable
(the result of another synchronous generator).
There is another keyword in Dart called yield*
with an asterisk which allows you to yield the result of a synchronous generator, inside your own synchronous generator. Here is an example:
// this is a function that returns // an iterable of male names Iterable<String> maleNames() sync* { yield 'John'; yield 'Peter'; yield 'Robert'; } // this is a function that returns // an iterable of female names Iterable<String> femaleNames() sync* { yield 'Mary'; yield 'Patricia'; yield 'Linda'; } // this function embeds the maleNames() // and the femaleNames() functions into // its result using the yield* keyword Iterable<String> allNames() sync* { yield* maleNames(); yield* femaleNames(); } void testIt() { // (John, Peter, Robert, Mary, Patricia, Linda) allNames().log(); }
Example: Even / Odd Numbers Generator
The best way to learn synchronous generators in Dart is to put them to use. Let’s write a synchronous generator that generates even or odd numbers from a given number up to and including another number:
// an enumeration that dictates the type of the number // our function should be looking for, odd or even enum NumberType { even, odd, } // A generator function that goes from the numbers // starting from "from" and ending at "to" (inclusive) // and finds odd or even numbers, depending on the value // of the "type" parameter. Iterable<int> generator({ required NumberType type, required int from, required int to, }) sync* { // start off by creating a loop that goes from the "from" // number to the "to" number (inclusive) for (int i = from; i <= to; i++) { // then check if the number is odd or even if (type == NumberType.even && i % 2 == 0) { // yield the number if it's even yield i; } else if (type == NumberType.odd && i % 2 != 0) { // yield the number if it's odd yield i; } } } // put the function to use void testIt() { // prints out (0, 2, 4, 6, 8, 10) generator(type: NumberType.even, from: 0, to: 10).log(); // prints out (11, 13, 15, 17, 19) generator(type: NumberType.odd, from: 10, to: 20).log(); }
Where to Go From Here
I think the key to learning synchronous generators in Dart is to give them a try yourself. Their use-cases are quite limited but when you need them, you will be grateful that they are there. Also check out my Flutter Tips and Tricks repository on GitHub for more examples of synchronous generators.