Skip to content
← All Rust articles

Rust explainer

How iterator consumers shape a loop

About 4 min to read. Allow extra time to work through examples.

Understand next, fold and try_fold, then compare iterator performance without assuming a guaranteed speedup.

This article uses Rust. You can make it your practice mode to browse related content across the site.

Iterator syntax tells you how values flow through a calculation. It does not, by itself, tell you how many branches or function calls remain in the compiled program. Start with the behavior you need, then measure if performance matters.

Who drives the iteration?

With external iteration, the caller requests each item through next. A for loop does that after converting its input with IntoIterator. With internal iteration, a consumer such as fold drives the work using your closure.

Here are two ways to join a sequence of string fragments:

fn main() {
    let parts = ["red", "green", "blue"];
    let mut from_loop = String::new();
    for part in parts {
        from_loop.push_str(part);
    }
    let from_fold = parts.into_iter().fold(String::new(), |mut acc, part| {
        acc.push_str(part);
        acc
    });
    assert_eq!(from_loop, "redgreenblue");
    assert_eq!(from_fold, from_loop);
}

Both preserve order and produce the same result. This example establishes behavior, not a timing comparison.

An iterator can specialize its consumer

Iterator provides a default fold implementation, but implementors can override it. For example, Chain's fold processes its first iterator and then its second. That can avoid repeatedly choosing a source in Chain::next. The inner iterators can also use their own fold implementations.

This gives the optimizer a different source-level structure. It does not promise fewer machine instructions: inlining and other optimization may make a for loop and a fold equivalent, or leave meaningful differences. Inspect the source of Chain when the implementation matters, using the version you actually compile with.

Stop early with try_fold

try_fold passes each item and accumulator to a fallible closure. An error can stop iteration without consuming the remaining items. The item that triggers the stop has already been consumed:

fn main() {
    let mut values = [10_u8, 20, 250, 4].into_iter();
    let result = values.try_fold(0_u8, |total, value| total.checked_add(value));
    assert_eq!(result, None);
    assert_eq!(values.next(), Some(4));
}

The first two items produce 30. Adding 250 cannot fit in u8, so checked_add returns None and try_fold stops. The iterator remains available because the method borrows it mutably. A normal successful fold consumes its iterator; passing a mutable reference to an iterator can keep the original handle.

Option, Result and ControlFlow are useful ways to express early exit. You can use try_fold with them on stable Rust without writing a generic Try bound. See the Iterator documentation.

What an adapter chain stores

In a chain such as slice.iter().filter(predicate).map(transform), each adapter holds its input iterator and any closure state it needs. The nested types in compiler messages reflect that structure. Creating these adapters does not itself create a vector of intermediate results.

Generic iterator and closure types often give the compiler known call targets. Inlining may then combine their work. Neither the generic type nor a small closure guarantees that every call and branch disappears. Dynamic dispatch can limit optimization, though a compiler may sometimes resolve its target. There is no fixed rule that each adapter adds one runtime call per element.

Allocation is a separate question. Collecting into a Vec needs storage, and size_hint can help choose a capacity. A weak size hint does not prove repeated reallocation: collection implementations, input size and available capacity also matter. For example, an empty input need not allocate at all. Do not infer allocation counts or speed from an adapter's name alone.

Compare equivalent work

A zip-based dot product stops at the shorter input. An indexed loop over all of a can panic when b is shorter. Those are different contracts. To compare them, first make both use the same length rule:

fn dot_zip(a: &[i64], b: &[i64]) -> i64 {
    a.iter().zip(b).map(|(x, y)| x * y).sum()
}

fn dot_indexed(a: &[i64], b: &[i64]) -> i64 {
    let n = a.len().min(b.len());
    let mut total = 0;
    for i in 0..n {
        total += a[i] * b[i];
    }
    total
}

fn main() {
    let a = [2, 3, 4];
    let b = [5, 6];
    assert_eq!(dot_zip(&a, &b), 28);
    assert_eq!(dot_indexed(&a, &b), 28);
}

The example assumes products and intermediate sums fit in i64. Both functions have bounds-safe shorter-input behavior. Whether their optimized code differs depends on the compiler, target and surrounding program.

Measure a specific workload

Use the same inputs, output contract, compiler version, target and build flags for both versions. An optimized build is usually the relevant comparison for shipping code. Debug timings describe a different build configuration.

Keep the result observable and consider black_box around benchmark inputs and outputs to discourage optimization that removes the work. It is best-effort, not a promise that the benchmark represents production. See the black_box documentation.

Repeat measurements, vary the order, and compare the spread as well as typical values. Include setup and allocation only when they belong to the operation being measured. There is no universal percentage below which a difference is noise. If variation obscures the difference, report that limitation and gather better measurements before choosing a faster-looking version.

step 28 of 28

Closures and Iterators

You've reached the last step

Revisit the track to review its lessons and exercises, or explore another topic.

Explore more tracks