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:
- Iterates over buffer with indices
- For each position, zips coefficients with indices
- Maps and sums to compute a weighted value
- 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.