Skip to content

9. Streams that do not end

Every collection so far had an end: a size, a last element. A stream is a succession of values whose production need not end — a log, a feed, the natural numbers. HQL types the difference, so a program cannot ask an endless stream a question that needs its end.

A function that yields

A fn whose body is a block, in braces, produces its values with yield, in the order each yield is reached. Its result type says whether production ends: Seq<T> promises an end, Stream<T> does not. These are the natural numbers:

fn naturals(from: Int) : Stream<Int> {
    yield from
    for n in naturals(from + 1) {
        yield n
    }
}
naturals(1) | take(5)
[1, 2, 3, 4, 5]

for name in collection { … } runs its block once per value. A block holds yield, for and bindings, one per line, and is written only as the body of a fn.

Calling naturals(1) evaluates nothing. Its value is a recipe for the numbers, and take(5) asks for five of them and then stops asking — which is what makes an endless producer usable.

One value at a time

map, filter and drop take a stream and give a stream. Each value passes through the whole pipeline before the next is produced, so only as many are made as the end of the pipeline asks for:

naturals(1)
| filter(n => n % 7 == 0)
| map(n => n * n)
| take(3)
[49, 196, 441]

Questions that need the end

sum, size, sort, group and the other whole-input steps need the last value before they can answer. Over a stream there may be none, and the checker says so before anything runs:

fn naturals(from: Int) : Stream<Int> {
    yield from
    for n in naturals(from + 1) {
        yield n
    }
}
naturals(1) | sum
type error: `sum` needs a finite input, and Stream<Int> may not end; bound it first with `take(n)`, or batch it with `collect(n)`
 --> <expression>:7:15
  |
7 | naturals(1) | sum
  |               ^^^

A stream becomes finite through take(n), or is answered in pieces through collect(n), which gathers consecutive values into batches of n:

naturals(1)
| collect(3)
| map(batch => batch | sum)
| take(3)
[6, 15, 24]

emit is the inverse of collect: it passes on every member of every batch, in order. flat_map(f) is map(f) | emit.

[[1, 2], [3]] | emit
[1, 2, 3]

A sequence over the vault

A generator that promises an end is a Seq. This one yields the title of each book, in title order — a sequence the vault's cards produce on demand:

fn titles() : Seq<String> {
    for c in cards | filter(c => c.metadata.category == "book") | sort(by = c => c.title) {
        yield c.title
    }
}
titles() | take(3)
["A Room of One's Own", "Dune", "Frankenstein"]

cards itself is such a sequence: the vault's cards, produced as the walk of the folder reaches each one.

Writing a stream

Formatting an endless stream writes it piece by piece as it arrives: a line of text, a JSON value per line, a CSV row. hql flushes each piece, and a reader that stops reading ends the stream:

naturals(1) | map(n => n * n) | json
$ hql eval '…naturals… | map(n => n * n) | json' | head -3
1
4
9

A diagram format describes a whole, so | mermaid over a stream is refused until take(n) bounds it.

What is next

Chapter 10 is about every format a value can be written in, and who chooses it.