Comparing Performance: Loops vs. Iterators

When you see high-level abstractions like iterator chains and closures, you might worry about runtime performance. Won't all those function calls and intermediate values slow things down?

The short answer: no. Oxide's iterators and closures are zero-cost abstractions, meaning you can use them without paying a runtime penalty compared to hand-written low-level code.

Zero-Cost Abstractions

The term "zero-cost abstraction" comes from C++, where Bjarne Stroustrup defined it as:

What you don't use, you don't pay for. And further: What you do use, you couldn't hand code any better.

Oxide (through Rust) follows this principle. Iterators compile down to roughly the same code you would write if you implemented the operations manually with loops.

A Concrete Example

Let's compare two implementations of a search function:

Using a Loop

fn search(query: &str, contents: &str): Vec<&str> {
    var results: Vec<&str> = vec![]

    for line in contents.lines() {
        if line.contains(query) {
            results.push(line)
        }
    }

    results
}

Using Iterators

fn search(query: &str, contents: &str): Vec<&str> {
    contents
        .lines()
        .filter { it.contains(query) }
        .collect()
}

If you benchmark these two implementations, you'll find they perform nearly identically. In fact, the iterator version is sometimes slightly faster because the compiler can optimize it more aggressively.

How the Compiler Optimizes Iterators

The compiler performs several optimizations on iterator chains:

Inlining

Closures and iterator methods are typically small enough to be inlined. This means the function call overhead is eliminated - the code is inserted directly at the call site.

Loop Fusion

When you chain multiple iterator adaptors:

numbers
    .iter()
    .map { it * 2 }
    .filter { it > 10 }
    .map { it + 1 }
    .collect()

The compiler doesn't create intermediate collections. Instead, it fuses these operations into a single loop that processes each element through all transformations in one pass.

Bounds Check Elimination

When iterating over a collection, the compiler can often prove that index bounds checks are unnecessary and eliminate them. This is easier to do with iterators than with manual indexing.

Loop Unrolling

For known-size collections or simple operations, the compiler may unroll loops to eliminate loop overhead:

// The compiler might transform this:
let sum: Int = [1, 2, 3, 4].iter().sum()

// Into something like this:
let sum = 1 + 2 + 3 + 4

A More Complex Example

Let's look at a more complex example - computing audio samples:

fn computeBuffer(buffer: &mut [Float], coefficients: &[Float]) {
    for (i, sample) in buffer.iterMut().enumerate() {
        let decay = 0.99_f64.powi(i as Int)
        let weighted = coefficients
            .iter()
            .zip(0..)
            .map { (coef, j) -> coef * (i + j) as Float }
            .sum<Float>()

        *sample = weighted * decay
    }
}

This code:

  1. Iterates over buffer with indices
  2. For each position, zips coefficients with indices
  3. Maps and sums to compute a weighted value
  4. Applies exponential decay

Despite the nested iterators and multiple closures, this compiles to tight, efficient machine code. The compiler:

  • Inlines all closures
  • Fuses the inner iterator chain
  • Eliminates intermediate allocations
  • May vectorize (use SIMD) for parallel processing

When to Use Iterators vs. Loops

Given that iterators and loops perform similarly, when should you use each?

Prefer Iterators When:

  • Clarity: The operation is a clear transform/filter/reduce
  • Composition: You need to chain multiple operations
  • Parallelism: You might later want to parallelize (with libraries like rayon)
  • Functional style: The logic fits the functional paradigm
// Clear intent: filter and transform
let activeUserEmails: Vec<String> = users
    .iter()
    .filter { it.isActive }
    .map { it.email.clone() }
    .collect()

Prefer Loops When:

  • Complex control flow: Multiple breaks, continues, or early returns
  • Multiple outputs: You need to update several things at once
  • Index-heavy logic: The algorithm heavily depends on indices
  • Readability: A loop is clearer for the specific case
// Complex control flow with early exit
for item in items {
    if shouldSkip(item) {
        continue
    }

    match process(item) {
        Ok(result) -> outputs.push(result),
        Err(e) if e.isRecoverable() -> {
            log(e)
            continue
        },
        Err(e) -> return Err(e),
    }

    if outputs.len() >= maxResults {
        break
    }
}

Common Performance Myths

Myth: "Closures are slow"

Closures in Oxide are not like closures in languages with garbage collection. They don't allocate on the heap (unless you box them), and they're typically inlined away completely.

// This closure is completely inlined
let doubled: Vec<Int> = numbers.iter().map { it * 2 }.collect()

// Compiles to essentially the same code as:
var doubled = Vec.withCapacity(numbers.len())
for n in numbers.iter() {
    doubled.push(n * 2)
}

Myth: "Iterator chains allocate intermediate collections"

Iterator adaptors like map and filter don't allocate. They return new iterator types that wrap the original. Only consuming adaptors like collect allocate.

// No allocations until collect()
let result: Vec<Int> = (0..1_000_000)
    .map { it * 2 }      // No allocation
    .filter { it > 100 } // No allocation
    .take(10)            // No allocation
    .collect()           // Allocates Vec with ~10 elements

Myth: "Functional code is slow in systems languages"

This might be true in languages where functional constructs have runtime overhead, but Oxide (through Rust) is specifically designed to make abstractions zero-cost.

Practical Tips

Use Release Mode for Benchmarks

Always benchmark with optimizations enabled. Debug builds disable most optimizations, making iterator code appear slower than it is.

# Debug build - don't benchmark this
oxide build

# Release build - benchmark this
oxide build --release

Don't Over-Optimize Prematurely

Write clear, idiomatic code first. Use iterators where they make code clearer. Only optimize after profiling shows a bottleneck.

// Good: Clear and efficient
let sum: Int = numbers.iter().filter { it > 0 }.sum()

// Unnecessary: Manual optimization that's not faster
var sum = 0
for n in numbers.iter() {
    if n > 0 {
        sum += n
    }
}

Consider collect() Placement

If you need to iterate multiple times, collecting once can be more efficient than recreating the iterator:

// Inefficient if baseIter() is expensive
let count = baseIter().filter { predicate(it) }.count()
let sum: Int = baseIter().filter { predicate(it) }.sum()

// Better: collect once, iterate twice
let filtered: Vec<Int> = baseIter().filter { predicate(it) }.collect()
let count = filtered.len()
let sum: Int = filtered.iter().sum()

Use Appropriate Iterator Methods

Some methods are more efficient than others for specific tasks:

// Use any() instead of filter().count() > 0
let hasEven = numbers.iter().any { it % 2 == 0 }

// Use find() instead of filter().next()
let firstEven = numbers.iter().find { it % 2 == 0 }

// Use position() instead of enumerate().filter().map()
let evenIndex = numbers.iter().position { it % 2 == 0 }

Summary

Iterators and closures in Oxide are zero-cost abstractions:

  • No runtime overhead: They compile to the same code as manual loops
  • Compiler optimizations: Inlining, fusion, and unrolling
  • Choose for clarity: Use the approach that makes your code clearest
  • Profile before optimizing: Don't sacrifice readability for premature optimization

You can confidently use iterators and closures throughout your Oxide code, knowing that you're not trading performance for expressiveness. This is one of Oxide's (and Rust's) core strengths: high-level abstractions with low-level performance.