Skip to content
academia.sh

Lesson 15 / 17

Generators

Pausable functions, the generator object's place in the iteration protocol, endless sequences, delegation, two-way communication, and lazy pipelines.

Contents

In the previous lesson, the same three pieces kept repeating in hand-written iterators: the variable holding position, the end condition, and the self-returning [Symbol.iterator] method. All three are mechanical and overshadow the function’s real job.

A generator is a function that can pause its execution and resume later from where it left off. It is defined in the function* form and pauses by giving a value with the yield statement. When called, its body does not run; it returns an object satisfying both of the protocol’s contracts. The lesson opens up this object’s behavior and reduces the previous lesson’s lazy series to a few lines.

A Pausable Function

A generator function’s body does not run at all until the first next call. Every next advances to the next yield statement and stops there.

let computeCount = 0;

function* calibratedSeries(rawValues, offset) {
  for (const raw of rawValues) {
    computeCount += 1;
    yield Number((raw + offset).toFixed(2));
  }
}

const generator = calibratedSeries([21.4, 19.8, 25.1], -0.4);

console.log(computeCount);
console.log(typeof generator.next);
console.log(generator[Symbol.iterator]() === generator);

console.log(JSON.stringify(generator.next()));
console.log(computeCount);
console.log(JSON.stringify(generator.next()));
console.log(JSON.stringify(generator.next()));
console.log(JSON.stringify(generator.next()));
console.log(computeCount);

console.log([...calibratedSeries([21.4, 19.8], -0.4)].join(","));
console.log(typeof calibratedSeries);
0
function
true
{"value":21,"done":false}
1
{"value":19.4,"done":false}
{"value":24.7,"done":false}
{"done":true}
3
21,19.4
function

The first line shows the body has not run at all. The third line confirms the generator object is both iterator and iterable: it carries a [Symbol.iterator] method that returns itself. All three pieces written by hand in the previous lesson are produced here by the language itself.

The fifth line measures laziness: the first next has done only one computation. Once the values run out, the done field becomes true and the value field stays undefined. The last line fixes a distinction — the generator function itself is an ordinary function; what is a generator is the object the call returns.

A generator object gets exhausted too. Traversing it again requires calling the function again; the spread in the tenth line sets up a new generator for exactly this.

Generator as a Class Method

A generator can also be defined in a class body by putting an asterisk before the method name. The MeasurementSeries class from the previous lesson is shortened by half this way.

class MeasurementSeries {
  constructor(sensor, values, offset = 0) {
    this.sensor = sensor;
    this.values = values;
    this.offset = offset;
  }

  *[Symbol.iterator]() {
    for (const raw of this.values) {
      yield Number((raw + this.offset).toFixed(2));
    }
  }

  *labeledPairs() {
    let index = 0;
    for (const value of this) {
      yield [`${this.sensor}#${index}`, value];
      index += 1;
    }
  }
}

const series = new MeasurementSeries("S-01", [21.4, 19.8, 25.1], -0.4);

console.log([...series].join(","));
console.log([...series].join(","));

for (const [label, value] of series.labeledPairs()) {
  console.log(`${label} -> ${value}`);
}

console.log(Object.getOwnPropertyNames(MeasurementSeries.prototype).join(","));
console.log(Object.hasOwn(MeasurementSeries.prototype, Symbol.iterator));
console.log(new Map(series.labeledPairs()).get("S-01#1"));
21,19.4,24.7
21,19.4,24.7
S-01#0 -> 21
S-01#1 -> 19.4
S-01#2 -> 24.7
constructor,labeledPairs
true
19.4

The series can be traversed twice, because what is being traversed is the series itself and every traversal produces a new generator object. The labeledPairs method writes for (const value of this) and benefits from its own class’s iterability; it does not access the raw data a second time.

The seventh and eighth lines repeat the tie to the chain: generator methods also stand on the prototype, and being symbol-keyed, they do not enter Object.keys output. The last line shows the protocol’s unifying power — any iterable object giving key–value pairs can be handed directly to the Map constructor.

Endless Sequences and Delegation

Because a generator only runs when requested, defining an endless sequence is no problem. yield* lets a generator delegate its work to another iterable object.

function* increasingTime(start, step) {
  let time = start;
  while (true) {
    yield time;
    time += step;
  }
}

function* firstN(iterable, count) {
  if (count <= 0) return;
  let remaining = count;
  for (const value of iterable) {
    yield value;
    remaining -= 1;
    if (remaining <= 0) return;
  }
}

console.log([...firstN(increasingTime(1000, 60), 4)].join(","));

function* frontSeries() {
  yield 21.4;
  yield 19.8;
}

function* backSeries() {
  yield 25.1;
}

function* allSeries() {
  yield* frontSeries();
  yield* backSeries();
  yield* [18.2, 30.0];
  return "done";
}

const combined = allSeries();
console.log([...allSeries()].join(","));

let step = combined.next();
const values = [];
while (!step.done) {
  values.push(step.value);
  step = combined.next();
}
console.log(values.length);
console.log(step.value);
1000,1060,1120,1180
21.4,19.8,25.1,18.2,30
5
done

increasingTime contains an infinite loop, but the program does not hang: values are produced only when requested, and firstN stops after the fourth value. The infinite loop being harmless is a direct result of laziness; producing the same measurement times in an array would be impossible.

yield* accepts any kind of iterable object — another generator or an ordinary array. The value of the return statement is skipped by for...of and spread; only code that manually continues the next calls can see it, in the finishing step’s value field. This is why generators are used to stream values, not to return a result.

Two-Way Communication

The argument given to a next call becomes the value of the yield expression the generator is paused at. This way, a generator does not just produce values, it also receives values from outside.

function* thresholdMonitor(startThreshold) {
  let threshold = startThreshold;
  const exceeding = [];
  try {
    while (true) {
      const incoming = yield `threshold=${threshold}, exceeding=${exceeding.length}`;
      if (typeof incoming === "number") {
        if (incoming > threshold) exceeding.push(incoming);
      } else if (typeof incoming === "object" && incoming !== null) {
        threshold = incoming.newThreshold;
      }
    }
  } finally {
    console.log(`closing: total exceeding=${exceeding.length}`);
  }
}

const monitor = thresholdMonitor(20);

console.log(monitor.next("this value is ignored").value);
console.log(monitor.next(21.4).value);
console.log(monitor.next(19.8).value);
console.log(monitor.next({ newThreshold: 25 }).value);
console.log(monitor.next(25.1).value);
console.log(JSON.stringify(monitor.return("manually stopped")));
console.log(JSON.stringify(monitor.next(30)));
threshold=20, exceeding=0
threshold=20, exceeding=1
threshold=20, exceeding=1
threshold=25, exceeding=1
threshold=25, exceeding=2
closing: total exceeding=2
{"value":"manually stopped","done":true}
{"done":true}

The first next call’s argument is ignored: the body has not yet paused at any yield expression, so there is no place to receive a value. In subsequent calls, the value sent falls into the incoming variable.

The return method ends the generator from outside and runs the finally block — this is the generator counterpart of the previous lesson’s cleanup point. A finished generator answers as done on subsequent next calls. The throw method similarly raises an error at the point the generator is paused.

Compared with the counter in the Closures lesson, the difference is clear: a closure holds state but does not hold execution. A generator, alongside state, also stores where in the program it left off.

A Lazy Pipeline

Generators’ most concrete gain is that transformation chains can be built without producing intermediate arrays. When filtering and mapping are each written as a generator, every value passes through the entire pipeline one at a time.

let readCount = 0;
let transformCount = 0;

function* rawSource(values) {
  for (const value of values) {
    readCount += 1;
    yield value;
  }
}

function* filterGen(iterable, predicate) {
  for (const value of iterable) {
    if (predicate(value)) yield value;
  }
}

function* mapGen(iterable, transform) {
  for (const value of iterable) {
    transformCount += 1;
    yield transform(value);
  }
}

function* firstN(iterable, count) {
  if (count <= 0) return;
  let remaining = count;
  for (const value of iterable) {
    yield value;
    remaining -= 1;
    if (remaining <= 0) return;
  }
}

const raw = [21.4, 19.8, 25.1, 18.2, 30.0, 27.3, 15.0];
const pipeline = firstN(
  mapGen(
    filterGen(rawSource(raw), (d) => d > 20),
    (d) => Number((d * 1.8 + 32).toFixed(1)),
  ),
  2,
);

console.log(readCount);
console.log([...pipeline].join(","));
console.log(readCount);
console.log(transformCount);

const withArrays = raw
  .filter((d) => d > 20)
  .map((d) => Number((d * 1.8 + 32).toFixed(1)))
  .slice(0, 2);
console.log(withArrays.join(","));
0
70.5,77.2
3
2
70.5,77.2

The two paths give the same result; the work they do is different. The generator pipeline reads only three of seven raw values and does two transforms; it stops the moment the two needed results are obtained. The version written with array methods first filters all five values, then transforms all five, then takes the first two — and produces two intermediate arrays along the way.

The difference sharpens as the data source grows or the transform gets more expensive. The generator setup, in exchange, has a fixed cost: several execution passes happen per value. On small arrays, array methods are both shorter and faster; the criterion is the number of unprocessed elements.

Summary

  • A generator function’s body does not run when called; it returns a generator object that is both iterator and iterable.
  • Every next call advances to the next yield statement; execution pauses there and state is preserved.
  • A starred method can be defined in a class body; generator methods stand on the prototype too.
  • yield* delegates work to another iterable object; the return statement’s value is visible only in manually continued next calls.
  • The next argument becomes the value of the yield expression the generator is paused at; the return method performs cleanup by running the finally block.
  • A pipeline built from generators produces no intermediate array and processes only as many values as requested.

Next Step

All the collections in this topic have held arbitrary JavaScript values: numbers, strings, objects. When a measurement stream needs to be written to a file or sent over a network, though, the data has to be represented as a byte sequence. The next lesson takes up fixed-size binary buffers and the typed views over them; byte order and overflow behavior will be tested there too.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close