Tutorial 7 min read

Lazy Iterators in Zolo: Infinite Sequences Done Right

How Zolo's lazy iterator system lets you work with infinite sequences without running out of memory — and why it changes how you think about data.


Lazy Iterators in Zolo: Infinite Sequences Done Right

One of Zolo's most powerful features is its lazy iterator system. Unlike eager collections that compute everything upfront, lazy iterators only compute values when asked. This means you can work with infinite sequences without running out of memory.

What Is Lazy Evaluation? #

In an eager language:

[1, 2, 3, 4, 5, ...∞]  → mapped → filtered → first 5

The entire sequence is computed before you can take the first 5 elements. With infinite sequences, this crashes.

In a lazy system:

take 5 ← filter ← map ← [1, 2, 3, ...]

Values are pulled from the source only when the consumer needs them. No intermediate arrays are allocated.

The 0.. Infinite Range

Zolo's 0.. syntax creates an infinite integer range:

let naturals = 0..  // lazy, no memory allocated

By itself, this does nothing. You need to consume it:

let first_ten = 0..
    |> Iter.take(10)
    |> Iter.collect()

print(first_ten) // [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]

Core Iterator Operations #

Iter.map — Transform Each Element

let squares = 0..
    |> Iter.map(|x| x * x)
    |> Iter.take(6)
    |> Iter.collect()

print(squares) // [0, 1, 4, 9, 16, 25]

Iter.filter — Keep Matching Elements

let evens = 0..
    |> Iter.filter(|x| x % 2 == 0)
    |> Iter.take(5)
    |> Iter.collect()

print(evens) // [0, 2, 4, 6, 8]

Iter.take_while — Stop When Condition Fails

let small_squares = 0..
    |> Iter.map(|x| x * x)
    |> Iter.take_while(|x| x < 100)
    |> Iter.collect()

print(small_squares) // [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]

Iter.fold — Aggregate Without Collecting

// Sum of first 100 natural numbers
let sum = 0..
    |> Iter.take(100)
    |> Iter.fold(0, |acc, x| acc + x)

print(sum) // 4950

Iter.zip — Pair Two Iterators

let a = 0..
let b = 0.. |> Iter.map(|x| x * x)

let pairs = Iter.zip(a, b)
    |> Iter.take(5)
    |> Iter.collect()

// [(0,0), (1,1), (2,4), (3,9), (4,16)]
print(pairs)

Custom Iterators with Generators #

The fn* syntax creates generator functions — they use yield to emit values one at a time:

fn* fibonacci() {
    let mut a = 0
    let mut b = 1
    loop {
        yield a
        let next = a + b
        a = b
        b = next
    }
}

let fibs = fibonacci()
    |> Iter.take(12)
    |> Iter.collect()

print(fibs) // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89]

Iter.from_fn — Iterator from a Closure

fn counter(start: int, step: int) {
    let mut n = start
    return Iter.from_fn(|| {
        let val = n
        n = n + step
        return val
    })
}

let by_threes = counter(0, 3)
    |> Iter.take(5)
    |> Iter.collect()

print(by_threes) // [0, 3, 6, 9, 12]

Chaining Complex Pipelines #

The real power comes from chaining multiple operations. The entire chain is lazy — memory stays flat:

// Find the 10th prime number
fn is_prime(n: int) -> bool {
    if n < 2 { return false }
    let mut i = 2
    while i * i <= n {
        if n % i == 0 { return false }
        i = i + 1
    }
    return true
}

let tenth_prime = 2..
    |> Iter.filter(is_prime)
    |> Iter.nth(9)   // 0-indexed

print(tenth_prime) // 29

Performance: Why Laziness Wins #

// Eager: allocates [0..999999], then [0..499999] filtered
// Lazy: allocates NOTHING until collect()

let result = 0..1_000_000
    |> Iter.filter(|x| x % 2 == 0)
    |> Iter.map(|x| x * x)
    |> Iter.take(5)
    |> Iter.collect()

print(result) // [0, 4, 16, 36, 64]
// Only 5 elements ever computed

Conclusion #

Lazy iterators change how you think about data transformation. Instead of "allocate, compute, discard", you think in terms of pipelines that flow data on demand.

Combined with Zolo's pipe operator |>, iterators become a natural, composable way to express complex data processing with minimal overhead.

Explore all iterator functions in the stdlib docs.

enespt-br