24 pipe — one line for each thing you mean to do
What to know first
for walks slices and counting loops use whilemut sliceLooking back
When chapter 7 asked “how do you write a loop counting from 0 to n with for?”, what did it say takes care of filtering, counting and collecting over a slice?
A. pipe. Counting loops are written with while and var, and filtering, transforming and collecting over a slice with pipe. This chapter covers that pipe.
The need for this chapter, and its context
pipe hands the skeleton to the language and lets people write only what they mean, one word per line. And it still walks once, just like the hand-written loop. As the last chapter of Part VI on abstraction, it shows an abstraction that hides no cost.By the end of this chapter
pipe <source> do <stages…> <terminal> end and its seven stages and five terminals. You will pick up how to pass named ops to stages, how to collect into the caller’s buffer with collect into, and that fold, any, all and take read only as much as needed. You will also understand that walking once without intermediate arrays is not an optimisation but the definition of pipe.The questions this chapter answers
- An op that uses
collect intohaseffects nonein its head. It changes the caller’s buffer — is that not an effect?
24.1 The same work, two shapes#
examples/ch24/digits.low
module digits .
rem run: digits_loop [97,49,50,98,51]
rem run: digits_pipe [97,49,50,98,51]
fn is_digit input c u8 . output bool .
do
return and (ge c 48) (le c 57) .
end
fn digits_loop input s slice u8 . output u64 .
do
var n u64 be 0 .
var i u64 be 0 .
while lt i (len s) . do
if is_digit (index s i) . do
set n (add n 1) .
end
set i (add i 1) .
end
return n .
end
fn digits_pipe input s slice u8 . output u64 .
do
return pipe s do
filter is_digit .
count .
end .
end
Output
$ lowentc --run digits_loop digits.low [97,49,50,98,51]
digits_loop([97,49,50,98,51]) = 3
arg0 (written) = [97,49,50,98,51]
$ lowentc --run digits_pipe digits.low [97,49,50,98,51]
digits_pipe([97,49,50,98,51]) = 3
arg0 (written) = [97,49,50,98,51]
The two ops give the same answer. digits_loop has a person line up the counter n, the index i, the end condition and the increments. digits_pipe is two lines.
filter is_digit .— keeps only elements for whichis_digitis true.count .— counts what is left. The word that ends the flow (the terminal) is exactly one.
pipe is a statement, but when it ends with a value-producing terminal (count, fold, any, all) it can be used like an expression, as in return pipe s do … end . — the final stop belongs to the return statement (end closes only the pipe block).
Drawn as a tube that elements flow through:
s = "a1b22"
┌─────────────────┐ ┌───────┐
a 1 b 2 2 ─▶ │ filter is_digit │ ─▶ 1 2 2 ─▶ │ count │ ─▶ 3
└─────────────────┘ └───────┘
stage: filters terminal: ends the flow and gives a valueElements pass through the tube one at a time. No separate “array of just the digits” is built in between.
24.2 Stages and terminals#
| Word | Shape of the op | What it does | End · empty flow |
|---|---|---|---|
filter <op> | t → bool | Passes only elements for which the op is true | — |
map <op> | t → u | Passes the op’s answer for each element; the type may become u | — |
take <n> | — | Passes the first n | stops walking after n |
skip <n> | — | Drops the first n | — |
enumerate <op> | (u64, t) → u | Gives the position (from 0) and the element to the op | — |
zip <slice> <op> | (t, t2) → u | Gives the element and the other slice’s element at the same place to the op | ends when the shorter side ends |
scan <init> <op> | (a, t) → a | Passes the accumulator after each step (the initial value is not passed) | — |
collect into <buffer> | — | Terminal. Fills the buffer from the front and gives how many it stored (u64) | a buffer too short is refused at translation when the lengths are known (E-COLLECT-FULL), otherwise the run stops |
fold <init> <op> | (a, t) → a | Terminal. Gives the last accumulator (a) | empty: the initial value |
count | — | Terminal. Gives the number of elements (u64) | empty: 0 |
any <op> | t → bool | Terminal. Is any element true | stops at the first true · empty: false |
all <op> | t → bool | Terminal. Are all elements true | stops at the first false · empty: true |
Table 24.1 — The words of pipe — element type t, accumulator type a
The op given to filter·any·all is a predicate and must answer bool. An op that answers a number is refused with E-PIPE-PRED — there is no rule here that reads a nonzero number as true (the same reason as cast in chapter 13).
Used as a value — return pipe … end . — a pipe is worth what its terminal gives. Each stage hands elements on to the next line, and the terminal takes them last and makes one value.
xs = [1, 5, 2, 7]
│
▼ filter big (big = gt x 2) 5, 7 1 and 2 are dropped here
▼ map dbl (dbl = x + x) 10, 14
▼ collect into out out = [10, 14, …] answer = 2 (how many stored)examples/ch24/stages.low
module stages .
rem run: big_doubled [1,2,3,4,5] [0,0,0,0,0]
rem run: sum_big [1,2,3,4,5]
rem run: has_zero [4,0,9]
rem run: all_small [1,2,3]
rem run: middle [10,20,30,40] [0,0]
fn over2 input a u8 . output bool .
do
return gt a 2 .
end
fn dbl input a u8 . output u8 .
do
return wrap_add a a .
end
fn addu input acc u64 . input x u8 . output u64 .
do
return wrap_add acc (widen u64 x) .
end
fn is_zero input a u8 . output bool .
do
return eq a 0 .
end
fn under10 input a u8 . output bool .
do
return lt a 10 .
end
proc big_doubled input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
filter over2 .
map dbl .
collect into out .
end
return len out .
end
fn sum_big input xs slice u8 . output u64 .
do
return pipe xs do
filter over2 .
fold 0 addu .
end .
end
fn has_zero input xs slice u8 . output bool .
do
return pipe xs do
any is_zero .
end .
end
fn all_small input xs slice u8 . output bool .
do
return pipe xs do
all under10 .
end .
end
proc middle input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
skip 1 .
take 2 .
collect into out .
end
return len out .
end
Output
$ lowentc --run big_doubled stages.low [1,2,3,4,5] [0,0,0,0,0]
big_doubled([1,2,3,4,5], [6,8,10,0,0]) = 5
arg0 (written) = [1,2,3,4,5]
arg1 (written) = [6,8,10,0,0]
$ lowentc --run sum_big stages.low [1,2,3,4,5]
sum_big([1,2,3,4,5]) = 12
arg0 (written) = [1,2,3,4,5]
$ lowentc --run has_zero stages.low [4,0,9]
has_zero([4,0,9]) = 1
arg0 (written) = [4,0,9]
$ lowentc --run all_small stages.low [1,2,3]
all_small([1,2,3]) = 1
arg0 (written) = [1,2,3]
$ lowentc --run middle stages.low [10,20,30,40] [0,0]
middle([10,20,30,40], [20,30]) = 2
arg0 (written) = [10,20,30,40]
arg1 (written) = [20,30]
big_doubledkeeps only elements greater than 2 (filter), doubles them (map) and puts them intoout(collect into). In the result[6,8,10,0,0], positions not filled stay as they were. The buffer is given by the caller;pipedoes not allocate.fold 0 adduinsum_bigstarts from 0 and accumulates withaddu.has_zeroisany is_zero, andall_smallisall under10.middleputs the middle two elements intooutwithskip 1andtake 2.
A stage is given a named op. This language has no anonymous functions (lambdas). A stage op takes one element and receives no capabilities. The ops of fold and scan take the accumulator and an element.
Without tuples, how are positions or pairs handled? The pair is not built but passed to the op as arguments.
examples/ch24/pairs.low
module pairs .
rem run: with_index [10,10,10] [0,0,0]
rem run: added [1,2,3] [10,20,30] [0,0,0]
rem run: running [1,2,3,4] [0,0,0,0]
fn idxadd input i u64 . input e u8 . output u8 .
do
return wrap_add e (narrow_wrap u8 i) .
end
fn addb input a u8 . input b u8 . output u8 .
do
return wrap_add a b .
end
proc with_index input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
enumerate idxadd .
collect into out .
end
return 0 .
end
proc added input xs slice u8 . input ys slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
zip ys addb .
collect into out .
end
return 0 .
end
proc running input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
scan 0 addb .
collect into out .
end
return 0 .
end
Output
$ lowentc --run with_index pairs.low [10,10,10] [0,0,0]
with_index([10,10,10], [10,11,12]) = 0
arg0 (written) = [10,10,10]
arg1 (written) = [10,11,12]
$ lowentc --run added pairs.low [1,2,3] [10,20,30] [0,0,0]
added([1,2,3], [10,20,30], [11,22,33]) = 0
arg0 (written) = [1,2,3]
arg1 (written) = [10,20,30]
arg2 (written) = [11,22,33]
$ lowentc --run running pairs.low [1,2,3,4] [0,0,0,0]
running([1,2,3,4], [1,3,6,10]) = 0
arg0 (written) = [1,2,3,4]
arg1 (written) = [1,3,6,10]
enumerate idxadd gives idxadd the position and the element. zip ys addb gives addb an element of xs and the element of ys at the same position; when the shorter one ends the whole flow ends, so pairs always match. scan 0 addb passes the running sum on as the element. Since pairs are never built and immediately taken apart, there is no hidden allocation.
Q. An op that uses collect into has effects none in its head. It changes the caller’s buffer — is that not an effect?
A. The specification’s examples also write effects none. collect into does not require effects state, but if you write it the processor counts the write to caller storage, so no “declared but not performed” warning appears — the same judgement it makes for writes through a mut or mut_ref parameter. Either way, the caller knows writes may happen where it hands over a mut slice (chapter 12).
24.3 Walking once is the definition#
A single pipe runs in one pass, and no intermediate arrays are created between stages. This is not an optimisation the processor may try but the definition of pipe, and a processor that creates intermediate arrays does not conform.
Why make it the definition? If fusion were an optimisation, “how far fusion goes” would differ between processors, and users would hit cliffs — change one line and suddenly an intermediate array appears and things slow down. That cliff is invisible in the source. Lowent left operations that cannot be fused out of the stage words entirely. That is why operations needing to see everything, such as sorting, are not stages.
examples/ch24/bad_stage.low
module bad_stage .
rem expect: E-PIPE-STAGE
fn is_digit input c u8 . output bool .
do
return and (ge c 48) (le c 57) .
end
fn broken input s slice u8 . output u64 .
do
return pipe s do
sort .
count .
end .
end
Output
$ lowentc --check bad_stage.low
bad_stage.low:12:0 E-PIPE-STAGE: this word is not a `pipe` stage. The stage vocabulary is CLOSED (RFC-0010 G5/D-A): stages are `filter <op> .`, `map <op> .`, `take <n> .`, `skip <n> .`, `enumerate <op> .`, `zip <other> <op> .` and `scan <init> <op> .`, and the pipeline ends with exactly one terminal — `collect into <mut slice> .`, `fold <init> <op> .`, `count .`, `any <op> .` or `all <op> .`. Fusion here is the MEANING, not an optimization, so anything that could not fuse into the single loop is not writable as a stage — use an explicit loop, or break the pipeline with an explicit intermediate collect
The list of stages is closed. pipe being a statement rather than an expression has the same reason: as an expression, stages could be detached and passed as values, and where one stream ends would not be visible in the source. do … end makes that boundary visible.
24.4 Reading only as much as needed#
any stops at the first element giving true, all at the first giving false, and take n after passing n elements. Elements after the stop are not read, and stages are not run for them. has_zero [4,0,9] never looks at 9. Only because this property is written into the meaning can pipe handle sources without an end.
A terminal ends the flow. A stage after the terminal is rejected.
examples/ch24/no_terminal.low
module no_terminal .
rem expect: E-PIPE-NO-TERMINAL
fn is_digit input c u8 . output bool .
do
return and (ge c 48) (le c 57) .
end
fn broken input s slice u8 . output u64 .
do
return pipe s do
count .
filter is_digit .
end .
end
Output
$ lowentc --check no_terminal.low
no_terminal.low:13:0 E-PIPE-NO-TERMINAL: a `pipe` stage may not come AFTER the terminal — the terminal ends the pipeline (RFC-0010 G1)
A common misconception. pipe is convenient but slower than a hand-written loop
pipe, walking once is the definition, and stage ops are monomorphised direct calls. One pipe lowers to one loop, and its costs arise at the same places as in a hand-written loop. The words were chosen so that the convenient way and the fast way do not diverge.24.5 The built-in map · filter with the same names#
map and filter also exist outside pipe. These are not stages but built-in operations that copy one slice into another in a single statement, and they take three arguments — map <sink> <op> <source> . · filter <sink> <op> <source> ..
module sink_map .
fn dbl input a u8 . output u8 . do return wrap_add a a . end
proc doubled input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
map out dbl xs .
return 0 .
endPutting [1,2] into a three-slot out gives [2,4,0]. The other way round, putting [1,2,3] into two slots stops the run as the third is written — a full sink never drops the rest in silence (the same as collect into). The sink must be a mut slice (E-MAP-SINK) and the element a scalar (E-MAP-ELEM). The word is the same, but inside pipe it takes one op — the number of arguments tells which is meant.
24.6 Common mistakes#
Counter-example. Writing an expression in a stage to imitate a lambda
examples/ch24/mistake_lambda.low
module mistake_lambda .
rem expect: E-FOLD-OP
fn count_big input xs slice u8 . output u64 .
do
return pipe xs do
rem ✘ imitates an anonymous function --- a stage takes one named op
filter gt 2 .
count .
end .
end
Output
$ lowentc --check mistake_lambda.low
mistake_lambda.low:8:0 E-FOLD-OP: this `pipe` stage names an op that does not exist
Carrying over filter(x => x > 2) from another language easily gives filter gt 2. A stage takes one named op, so this is E-FOLD-OP (the diagnostic only says “no such op”). Naming a condition may feel like extra work, but the name over2 becomes the explanation of that line, and the same condition can be reused in other pipes.
examples/ch24/lambda_fixed.low
module lambda_fixed .
rem run: count_big [1,2,3,4,5]
rem give the condition a name --- the name becomes its explanation
fn over2 input a u8 . output bool .
do
return gt a 2 .
end
fn count_big input xs slice u8 . output u64 .
do
return pipe xs do
filter over2 .
count .
end .
end
Output
$ lowentc --run count_big lambda_fixed.low [1,2,3,4,5]
count_big([1,2,3,4,5]) = 3
arg0 (written) = [1,2,3,4,5]
Counter-example. Collecting a map that produces wide values into a narrow buffer
examples/ch24/mistake_widecollect.low
module mistake_widecollect .
rem expect: E-TYPE-COLLECT
fn times1000 input a u8 . output u64 .
do
return mul (widen u64 a) 1000 .
end
proc scale input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
rem ✘ a `map` producing `u64` is collected into a `u8` buffer --- 1000 becomes 232
map times1000 .
collect into out .
end
return 0 .
end
Output
$ lowentc --check mistake_widecollect.low
mistake_widecollect.low:14:0 E-TYPE-COLLECT: this `collect into` would put a WIDER value into a narrower buffer, and that loses bits silently (measured: 1000 became 232). A conversion that loses value never happens implicitly here (§6.2.5) — put a `map` in front that says which narrowing you mean (`narrow`, `narrow_wrap`, `narrow_sat`, `narrow_try`), or collect into a buffer of the produced width
times1000 produces u64, but out is a u8 buffer. A narrowing that loses value happens only where it is written (§6.2.5), so this is refused with E-TYPE-COLLECT. Until 2026-09-16 it passed and 1000 and 2000 were stored silently as 232 and 208. If narrowing is needed, write narrow, narrow_wrap or narrow_sat inside the op given to map, so the place where it may stop is visible.
Counter-example. Swapping the accumulator and the element in a fold op
examples/ch24/mistake_foldorder.low
module mistake_foldorder .
rem expect: E-FOLD-ORDER
rem ✘ takes the element first --- the accumulator arrives as `x`, the element as `acc`
fn add_small_swapped input x u8 . input acc u64 . output u64 .
requires le acc 1000000 .
do
guard lt x 100 . else return acc .
return add acc (widen u64 x) .
end
fn sum_small_wrong input xs slice u8 . output u64 .
do
return pipe xs do
fold 0 add_small_swapped .
end .
end
Output
$ lowentc --check mistake_foldorder.low
mistake_foldorder.low:15:0 E-FOLD-ORDER: the accumulator of a `fold`/`scan` is the FIRST input of its op and also its output, because each step computes `acc = op(acc, element)`. Here the first input and the output are declared with different types, so the running total is handed to a parameter that cannot hold it — a `u8` first input takes a total of 600 as 88 and the answer is silently wrong. Write the op as `input acc <out-type> . input x <element-type> .`
fold gives its op the accumulator first and the element second. add_small_swapped takes them the other way round, so x receives the accumulator and acc receives the element. Each step is acc = op(acc, element), so the first input must have the same type as the output; here they are u8 and u64, and the program is refused with E-FOLD-ORDER. Always write the op of fold or scan in the order input acc … . input x … ..
Counter-example. Using a stage op that can stop, in a fn
examples/ch24/mistake_stageeffect.low
module mistake_stageeffect .
rem expect: E-EFFECT-CALC
proc nonzero input a u8 . output bool . effects panic .
do
if eq a 0 . do panic "zero in input" . end
return true .
end
rem ✘ the stage op can stop, yet the op using the `pipe` is written as a `fn`
fn count_checked input xs slice u8 . output u64 .
do
return pipe xs do
filter nonzero .
count .
end .
end
Output
$ lowentc --check mistake_stageeffect.low
mistake_stageeffect.low:12:1 E-EFFECT-CALC: this fn is declared pure but performs `panic` — make it a `proc` with `effects …`, or remove the effect
A pipe is a loop that calls its stage ops, so the effects of a stage op spread to the op containing the pipe. nonzero may panic, so count_checked performs panic too, and as a fn it is E-EFFECT-CALC. Write it as proc … effects panic ., or instead of stopping, use a pure op that filters out the elements that do not meet the condition.
A common misconception. collect into drops the rest when the buffer is full
examples/ch24/short_buffer.low
module short_buffer .
rem run: big_doubled [1,3,4] [0,0]
rem trap: big_doubled [3,4,5,6] [0,0]
fn over2 input a u8 . output bool .
do
return gt a 2 .
end
fn dbl input a u8 . output u8 .
do
return wrap_add a a .
end
rem the buffer has two slots --- two remaining elements fit; with four, the run stops as the third is stored
proc big_doubled input xs slice u8 . input out mut slice u8 . output u64 . effects none .
do
pipe xs do
filter over2 .
map dbl .
collect into out .
end
return len out .
end
Output
$ lowentc --run big_doubled short_buffer.low [1,3,4] [0,0]
big_doubled([1,3,4], [6,8]) = 2
arg0 (written) = [1,3,4]
arg1 (written) = [6,8]
$ lowentc --run big_doubled short_buffer.low [3,4,5,6] [0,0]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds on write (panic)
From [1,3,4] two elements remain (3, 4), and they fit in two slots. [3,4,5,6] leaves four, so the run stops as the third is stored. It used to end quietly after two, and nobody learned that the other two were gone. When both lengths are known at translation (an array 5 u8 input, say), there is nothing to stop: it is refused with E-COLLECT-FULL. A pipe does not allocate, so it does not grow the buffer either. To keep only what fits, write take 2.
24.7 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
pipe xs do … end | scan the source xs once | the language owns the skeleton (index, end test) |
filter over2 . · map dbl . | keep · transform — pass a named op | no lambdas — the name is the explanation |
take 2 . · skip 1 . | only the first few · drop the first few | read only as much as needed |
enumerate idxadd . · zip ys addb . | pass the index or partner as op arguments | no tuples are built |
scan 0 addb . · fold 0 addu . | emit running values · accumulate into one value | the op takes the accumulator first, then the element |
count . · any is_zero . · all under10 . | terminators that produce a value | usable as return pipe … end |
collect into out . | store into the caller’s buffer | a pipe never allocates — a buffer too short is refused or stops the run |
| exactly one terminator, at the end | a stage after it is E-PIPE-NO-TERMINAL | the end of the flow is in one place |
a word like sort | does not exist — E-PIPE-STAGE | operations that cannot fuse were left out |
Table 24.2 — pipe syntax — shape · meaning · why it looks this way
Recap
pipe <source> do … end passes through stages (filter, map, take, skip, enumerate, zip, scan) and ends with one terminal (collect into, fold, count, any, all). Stages take named ops, and the buffer to collect into is given by the caller. Walking once with no intermediate arrays is the definition, and operations that cannot be fused are not in the vocabulary. any, all and take read only as much as needed.