Loops, Iterators, and Laziness: how each language walks a sequence
Eight languages, one question: when you ask for the next element, who computes it, and when?
Every program eventually walks a sequence. The interesting differences between languages are not in the syntax of the loop but in the answer to a quieter question: when you ask for the next element, who computes it, and when? That single axis - eager versus lazy, pull versus push - sorts these eight languages into surprisingly distinct camps.
The lazy purists: Haskell and OCaml
Haskell makes laziness the default rather than an opt-in. Every list is conceptually a thunk that produces its head and a thunk for its tail. So an "infinite" list is not a paradox, it is the normal case:
squares = [ n*n | n <- [1..] ]
firstFive = take 5 squares -- [1,4,9,16,25]
Nothing past the fifth square is ever evaluated, because nothing demands it. take pulls exactly five tails into existence. This is why Haskell idioms freely compose map, filter, and takeWhile over unbounded streams: the consumer governs how far the producer runs, and the famous fusion rules let GHC often erase the intermediate lists entirely, so sum (map (*2) (filter even [1..n])) allocates no list at all.
OCaml takes the opposite default - it is strict, lists are fully materialized - but it carved out laziness in two deliberate places. The lazy keyword with Lazy.force gives you single thunks, and the standard Seq.t type is a pull-based sequence defined as unit -> node, the same head-and-thunk shape Haskell uses, just made explicit:
let rec from n = fun () -> Seq.Cons (n, from (n + 1))
let first5 = from 1 |> Seq.map (fun n -> n*n) |> Seq.take 5 |> List.of_seq
The lesson OCaml teaches is that laziness is a data structure, not a language mystery. Haskell bakes that structure into every value; OCaml hands it to you as a library you reach for when streaming matters.
The iterator protocols: Rust, Python, Raku, Perl
Most modern languages reach laziness through an iterator protocol: an object with a "give me the next one" method, and adapters that wrap one iterator in another without consuming it.
Rust is the strictest and the fastest. Iterator is a trait with a single next(&mut self) -> Option<Self::Item>. Adapters like map and filter are themselves zero-sized structs wrapping the upstream iterator; nothing runs until a consumer like sum or collect calls next in a loop:
let total: u64 = (1..)
.map(|n| n * n)
.filter(|n| n % 2 == 0)
.take(3)
.sum();
(1..) is an unbounded range, yet this terminates, because take(3) stops pulling. The remarkable part is that the optimizer collapses the whole chain into a single tight loop with no heap allocation - laziness with no runtime tax. The catch Rust enforces and others do not: next takes &mut self, so the borrow checker guarantees you cannot mutate the underlying collection while iterating it.
Python wears the same protocol more loosely. An iterator is anything with __next__; generators write that protocol for you with yield:
def squares():
n = 1
while True:
yield n*n
n += 1
from itertools import islice
list(islice(squares(), 5)) # [1, 4, 9, 16, 25]
Generator expressions are the throwaway form, and they are lazy: sum(x*x for x in range(1,7) if x % 2 == 0) streams one value at a time and yields 56 without ever building a list. The cost is that Python's per-element overhead is real - every next is a bytecode dispatch - which is exactly the price Rust refuses to pay.
Raku, Larry Wall's redesign of Perl, treats laziness as a first-class promise of the language. A range like 1..Inf is lazy, and the gather/take pair builds a lazy sequence imperatively, much as Python's yield does. Sequences interoperate with the same map/grep verbs Perl programmers already know, and the feel is of a language designed so the easy thing - writing (1..*).map(* ** 2) - is also the lazy thing. There is room for more than one way to do it, and Raku quietly arranges for the lazy way to be among them.
Perl itself is the eager elder here. map and grep build full lists immediately:
my @nums = (1..6);
my $sum = 0;
$sum += $_ for grep { $_ % 2 == 0 } @nums; # 12
my @sq = map { $_ * $_ } @nums; # 1 4 9 16 25 36
Laziness in Perl is possible but manual - closures that return one value per call, or the magic of tied variables - so most Perl walks a sequence by materializing it. For the everyday text-munging that Perl was built for, eager and concrete is usually the right default.
The imperative middle: Go
Go long resisted the iterator-adapter style entirely. For years there was no lazy map/filter in the standard library; you wrote a for loop, and you wrote it again next time. The one place Go embraced pull-based streaming was channels, where a goroutine pushes values that a for range consumes:
ch := make(chan int)
go func() {
for n := 1; n <= 5; n++ { ch <- n * n }
close(ch)
}()
for sq := range ch { fmt.Println(sq) }
Go 1.23 finally added range-over-function iterators (func(yield func(V) bool)), giving the language a real, composable lazy protocol at last - but the cultural default remains the explicit loop. Go's bet is that an obvious loop beats a clever chain.
guji: walking sequences three ways
guji (in-house, v0.1-alpha) is a compiled, statically typed, functional-first language, and it splits the problem cleanly. For in-memory collections it is eager and method-chained, in the data-first style where $x.f() is exactly f($x):
sub main() {
@nums = [1, 2, 3, 4, 5, 6]
$r = @nums.filter({ $_ % 2 == 0 }).map({ $_ * 10 }).sum()
print($r) # 120
print(@nums.count()) # 6
}
That chain produces 120, and list length is .count(), not a field. Ranges are iterable with inclusive .. and half-open ..<, and for is reserved for side effects while map/filter/reduce/each express transformation:
sub main() {
@nums = [1, 2, 3, 4, 5, 6]
@nums.filter({ $_ % 2 == 0 }).each({ print($_) }) # 2, 4, 6
$total = [10, 20, 30].reduce(0, sub($acc, $x) { $acc + $x })
print(total $total) # total 60
}
Where guji becomes genuinely lazy is at the stream boundary, and here it borrows Go's CSP rather than Haskell's thunks. A Chan[T] is a real channel; a hatch { } task produces values that a for-over-channel pulls on demand, ending when the channel closes:
sub main() {
$ch: Chan[Int] = channel()
hatch {
for $n in 1 .. 5 { $ch.send($n * $n) }
$ch.close()
}
for $sq in $ch { print("sq $sq") } # sq 1, sq 4, ... sq 25
}
The same shape walks the outside world: guji has real Platform IO, so open($p) returns a Result[Handle, Str] and a handle's .lines() yields a Chan[Str] you iterate line by line, never holding the whole file:
sub main() {
match open("/tmp/words.txt") {
Ok($h) { for $line in $h.lines() { print("L: $line") } }
Err($e) { note("err: $e") }
}
}
Run under the reference interpreter or compiled native with build -o, this prints each line in turn. So guji's answer to "who computes the next element, and when" is two-pronged: collections are computed eagerly and fused by the compiler, while streams - tasks, channels, files, stdin - are pulled one element at a time across a concurrency boundary, with immutability guaranteeing no two tasks ever race over the value in flight.
The shape of the spectrum
Lay them out and a clean gradient appears. Haskell is lazy everywhere by default; OCaml is strict but offers Seq when you ask. Rust and Python share the pull-based iterator protocol, Rust paying nothing at runtime and Python paying per element. Raku makes lazy the easy path; Perl makes eager the easy path. Go prefers the honest loop and pushes through channels. guji keeps collections eager and reserves laziness for the stream - the place where it actually buys you something. The loop keyword looks the same in all of them. The question of who does the work, and when, is where each language shows its hand.