Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Chain iteration steps with iter

iter(source) starts a pipeline: a chain of iteration steps that runs only when a loop or a final method such as count() consumes it. No intermediate collection is built.

def main() -> Result[(), AllocError | IoError]:
    rolls = [3, 6, 2, 6, 6, 1]?
    print(iter(&rolls).filter_value(6).count())?
    for turn, roll in iter(&rolls).enumerate(1).skip(2).take(3):
        print(turn, *roll)?
    print(len(rolls))?
    Ok(())
3
3 2
4 6
5 6
6

filter_value(6) keeps the items equal to 6, and count() counts them without building a list. enumerate(1) numbers the items from one, skip(2) discards the first two, and take(3) stops after three more. Steps apply in the order written: enumerate comes first, so the turn numbers count every roll, including skipped ones.

The source follows the ordinary iteration rules. Borrowing rolls keeps the list, and each item is a reference, so the loop reads *roll. filter_value still takes a plain i64: a borrowed item is compared by value. It also accepts a borrowed value, such as filter_value(&wanted), so a string can be matched without copying it; wanted then stays borrowed until the pipeline ends.

A pipeline is single-use. Consuming it again, or using an owned source after the pipeline has taken it, is an ownership error.

Add up and find extremes

sum(), min(), and max() are final methods that need no function either:

def main() -> Result[(), AllocError | IoError | OverflowError[i64]]:
    rolls = [3, 6, 2, 6, 6, 1]?
    print(iter(&rolls).sum()?, iter(&rolls).min(), iter(&rolls).skip(4).max())?
    none: list[i64] = []?
    print(iter(&none).sum()?, iter(&none).max().unwrap_or(0))?
    Ok(())
24 Option[i64].Some(1) Option[i64].Some(6)
0 0

A borrowed item is read by value, so the results are i64, not references. sum() gives zero for no items. Integer overflow returns OverflowError[i64], handled with ? here; floating-point sums retain IEEE behavior. min() and max() return an Option, Nothing for no items. They accept numbers and str and keep the first of equal items. Floats follow the IEEE 754 rules for NaN and signed zero, as std.math.min_all and max_all do; the pipeline reference gives them.

Test boolean items

any() reports whether at least one item is True, and all() whether every item is. Both stop as soon as they know the answer:

def positive(values: list[i64]) -> Generator[bool]:
    for value in values:
        yield value > 0

def main() -> Result[(), AllocError | IoError]:
    present = [True, False, True]?
    print(iter(&present).any(), iter(reversed(&present)).all())?
    print(positive([2, -1, 3]?).any(), positive([2, -1, 3]?).all())?
    none: list[bool] = []?
    print(iter(&none).any(), iter(&none).all())?
    Ok(())
True False
True False
False True

The generator computes each comparison only when advanced, so all() stops at -1 and never tests 3; its frame is cleaned up when the method returns. For no items, any() gives False and all() gives True.

Without an argument, these methods need bool items: numbers and text have no implicit truth value. To test other items, pass a predicate function; that form is taught in Reduce a borrowed collection with a pipeline.

Pair, join, and collect

zip walks two sources together, and chain continues with a second source of the same item type. collect() builds the collection named by the binding’s annotation and returns a Result for allocation failure:

def main() -> Result[(), AllocError | IoError]:
    names = ["Ada", "Bob", "Cal"]?
    present = [True, False, True]?
    for name, here in iter(&names).zip(&present):
        print(name, *here)?
    print(iter(&present).filter_value().count())?
    morning = [3, 1]?
    evening = [4, 3]?
    readings: list[i64] = iter(morning).chain(evening).collect()?
    print(readings)?
    Ok(())
Ada True
Bob False
Cal True
2
[3, 1, 4, 3]

Without an argument, filter_value() keeps True items. The two owned lists move into the chain, so their numbers move into readings without copying.

The value must have the item’s type. There is no implicit conversion, and the no-argument form works only for bool items:

def main() -> Result[(), AllocError | IoError]:
    rolls = [3, 6]?
    print(iter(&rolls).filter_value("6").count())?
    Ok(())
filter_value compares &i64 items with a str value; it requires i64 or &i64

Take one item, then continue

next takes one item from a pipeline, as it does from a generator. The pipeline keeps its place, so a loop or final method afterwards continues with the remaining items:

def main() -> Result[(), AllocError | IoError]:
    lines = ["name,score", "ada,3", "bob,5"]?
    mut rows = iter(lines)
    match next(rows):
        case Some(header):
            print("columns:", header)?
        case Nothing:
            print("no header")?
    for number, row in rows.enumerate(1):
        print(number, row)?
    Ok(())
columns: name,score
1 ada,3
2 bob,5

The binding needs mut because next advances it. next returns an owned Option, so it needs owned items: here iter(lines) moves the list into the pipeline. For a borrowed source, first map each item to an owned value. Once next returns Nothing, the pipeline stays empty.

Lend a pipeline to a function

A function can take a pipeline as &mut Iter[T] and consume part of it. The caller lends it with &mut and continues after the items the function took:

def print_header(rows: &mut Iter[str]) -> Result[(), AllocError | IoError]:
    match next(rows):
        case Some(header):
            print("columns:", header)?
        case Nothing:
            print("no header")?
    Ok(())

def main() -> Result[(), AllocError | IoError]:
    lines = ["name,score", "", "ada,3", "bob,5"]?
    mut rows = iter(lines).filter(def(line: &str) -> bool: line != "")
    print_header(&mut rows)?
    print(rows.count(), "rows")?
    Ok(())
columns: name,score
2 rows

The function can also loop over rows and break or return early; the rest stays with the caller. It cannot keep or return the pipeline.

A pipeline over a borrowed list yields references, written &mut Iter[&str] here. The function can use each item while it runs, but cannot return one, because the caller lent the list only for the call:

def greet_first(names: &mut Iter[&str]) -> Result[(), IoError]:
    for name in names:
        print("hello", name)?
        break
    Ok(())

def main() -> Result[(), AllocError | IoError]:
    names = ["ada", "bob", "ada", "cal"]?
    mut adas = iter(&names).filter_value("ada")
    greet_first(&mut adas)?
    print(adas.count(), "more")?
    print(len(names))?
    Ok(())
hello ada
1 more
4

A lent pipeline whose callbacks borrow local values needs those closures written inside the adapter calls; see Lend a pipeline whose callbacks borrow.

Generators have the same methods without iter:

def countdown(start: i64) -> Generator[i64]:
    mut remaining = start
    while remaining > 0:
        yield remaining
        # The loop proves 1 <= remaining <= i64.MAX.
        unchecked:
            remaining -= 1

def main() -> Result[(), AllocError | IoError]:
    last: list[i64] = countdown(10).skip(7).collect()?
    print(last)?
    Ok(())
[3, 2, 1]

skip still advances the generator past the first seven values; it only discards them.

Prefer a pipeline when a chain of these steps states the task directly, and a plain loop or comprehension when the work needs its own expression or several statements. Most other pipeline steps, such as map, filter, and fold, take a function. They are taught after functions as values, starting with Reduce a borrowed collection with a pipeline. The pipeline reference lists every step and its cost.