Why Custom Iterators Matter
Custom iterators let you control how code loops through your data structures. Instead of forcing callers to understand your internal implementation, you provide a clean foreach-compatible interface that works like any standard collection.
The iterator pattern separates collection traversal from the collection structure itself. You can change how iteration works without breaking code that uses your classes. This flexibility is essential when building data structures, wrapping external data sources, or creating complex filtering and transformation pipelines.
You'll learn how IEnumerable and IEnumerator work together, use the yield keyword to simplify iterator creation, and build practical iterators for real-world scenarios.
Understanding IEnumerable and IEnumerator
The foreach loop works with any type that implements IEnumerable<T>. This interface has one method: GetEnumerator(), which returns an IEnumerator<T> that does the actual iteration.
public class SimpleCollection<T> : IEnumerable<T>
{
private T[] items;
public SimpleCollection(params T[] values)
{
items = values;
}
public IEnumerator<T> GetEnumerator()
{
return new SimpleEnumerator(items);
}
IEnumerator IEnumerable.GetEnumerator()
{
return GetEnumerator();
}
private class SimpleEnumerator : IEnumerator<T>
{
private T[] items;
private int position = -1;
public SimpleEnumerator(T[] items)
{
this.items = items;
}
public T Current => items[position];
object IEnumerator.Current => Current;
public bool MoveNext()
{
position++;
return position < items.Length;
}
public void Reset() => position = -1;
public void Dispose() { }
}
}
// Usage
var collection = new SimpleCollection<int>(1, 2, 3, 4, 5);
foreach (var item in collection)
{
Console.Write($"{item} "); // Output: 1 2 3 4 5
}
The enumerator maintains state through the position field. MoveNext() advances to the next item and returns true if more items exist. Current provides the item at the current position.
Simplifying Iterators with Yield
The yield keyword eliminates the need to manually implement IEnumerator. The compiler generates all the state management code automatically, making iterators much simpler to write.
public static IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
yield return 3;
yield return 4;
yield return 5;
}
// Usage
foreach (var number in GetNumbers())
{
Console.Write($"{number} "); // Output: 1 2 3 4 5
}
// With a loop
public static IEnumerable<int> GetRange(int start, int count)
{
for (int i = 0; i < count; i++)
{
yield return start + i;
}
}
foreach (var num in GetRange(10, 5))
{
Console.Write($"{num} "); // Output: 10 11 12 13 14
}
When you call GetNumbers(), it doesn't execute the method body immediately. Instead, it returns an iterator object. Each time foreach requests the next item, the method executes until it hits yield return, pauses, and returns that value.
public static IEnumerable<int> GetPositiveNumbers(IEnumerable<int> numbers)
{
foreach (var num in numbers)
{
if (num < 0)
{
yield break; // Stop iteration completely
}
yield return num;
}
}
var mixed = new[] { 5, 3, 8, -2, 1, 9 };
foreach (var num in GetPositiveNumbers(mixed))
{
Console.Write($"{num} "); // Output: 5 3 8
}
The yield break statement stops iteration immediately, similar to how return exits a regular method. This gives you fine control over when iteration should end.
Lazy Evaluation and Memory Efficiency
Iterators with yield enable lazy evaluation where items are generated only when requested. This approach saves memory and improves performance when working with large or infinite sequences.
// Eager: creates entire list in memory
public static List<int> GetSquaresEager(int count)
{
Console.WriteLine("Creating list eagerly...");
var result = new List<int>();
for (int i = 1; i <= count; i++)
{
result.Add(i * i);
}
return result;
}
// Lazy: generates items on demand
public static IEnumerable<int> GetSquaresLazy(int count)
{
Console.WriteLine("Starting lazy enumeration...");
for (int i = 1; i <= count; i++)
{
Console.WriteLine($"Generating {i}");
yield return i * i;
}
}
// Eager loads everything immediately
var eagerSquares = GetSquaresEager(5);
Console.WriteLine("List created");
Console.WriteLine(eagerSquares[0]);
// Lazy generates items only when accessed
var lazySquares = GetSquaresLazy(5);
Console.WriteLine("Iterator created");
Console.WriteLine(lazySquares.First()); // Only generates first item
The eager version allocates memory for all items upfront. The lazy version creates items one at a time as foreach requests them, using significantly less memory for large sequences.
Practical Iterator Examples
Real-world iterators handle diverse scenarios from file processing to infinite sequences. Here are patterns you'll use frequently.
public static IEnumerable<string> ReadLines(string filePath)
{
using (var reader = new StreamReader(filePath))
{
string line;
while ((line = reader.ReadLine()) != null)
{
yield return line;
}
}
}
// Process large files without loading everything into memory
foreach (var line in ReadLines("large-file.txt"))
{
if (line.StartsWith("ERROR"))
{
Console.WriteLine(line);
}
}
public static IEnumerable<int> Fibonacci()
{
int prev = 0, curr = 1;
while (true) // Infinite loop
{
yield return prev;
int next = prev + curr;
prev = curr;
curr = next;
}
}
// Take only what you need
foreach (var fib in Fibonacci().Take(10))
{
Console.Write($"{fib} "); // Output: 0 1 1 2 3 5 8 13 21 34
}
public static IEnumerable<List<T>> Batch<T>(IEnumerable<T> source, int batchSize)
{
var batch = new List<T>(batchSize);
foreach (var item in source)
{
batch.Add(item);
if (batch.Count == batchSize)
{
yield return batch;
batch = new List<T>(batchSize);
}
}
if (batch.Count > 0)
{
yield return batch; // Return remaining items
}
}
var numbers = Enumerable.Range(1, 15);
foreach (var batch in Batch(numbers, 4))
{
Console.WriteLine($"Batch: {string.Join(", ", batch)}");
}
// Output:
// Batch: 1, 2, 3, 4
// Batch: 5, 6, 7, 8
// Batch: 9, 10, 11, 12
// Batch: 13, 14, 15
Batching is useful when you need to process items in groups, such as when inserting records into a database in chunks or sending data in batches to an API.
Creating Custom Collection Classes
When building your own collection types, implementing IEnumerable<T> with yield makes them work seamlessly with foreach and LINQ.
public class CircularBuffer<T> : IEnumerable<T>
{
private T[] buffer;
private int start;
private int end;
private int count;
public CircularBuffer(int capacity)
{
buffer = new T[capacity];
}
public void Add(T item)
{
buffer[end] = item;
end = (end + 1) % buffer.Length;
if (count < buffer.Length)
{
count++;
}
else
{
start = (start + 1) % buffer.Length;
}
}
public IEnumerator<T> GetEnumerator()
{
for (int i = 0; i < count; i++)
{
int index = (start + i) % buffer.Length;
yield return buffer[index];
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
// Usage
var buffer = new CircularBuffer<int>(5);
for (int i = 1; i <= 7; i++)
{
buffer.Add(i);
}
foreach (var item in buffer)
{
Console.Write($"{item} "); // Output: 3 4 5 6 7
}
The yield keyword handles the complex logic of iterating through the circular buffer's wrapped indices. Your iterator code stays simple and readable.