Building Custom Iterators with IEnumerable in C#

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.

Manual IEnumerator implementation
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.

Using yield return
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.

Yield break for early termination
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.

Lazy vs eager evaluation
// 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.

Building Filtering and Transformation Iterators

Custom iterators excel at creating data transformation pipelines. You can chain operations together, and each operation processes items lazily as they flow through.

Filtering iterator
public static IEnumerable<T> Filter<T>(IEnumerable<T> source, Func<T, bool> predicate)
{
    foreach (var item in source)
    {
        if (predicate(item))
        {
            yield return item;
        }
    }
}

public static IEnumerable<TResult> Transform<TSource, TResult>(
    IEnumerable<TSource> source,
    Func<TSource, TResult> selector)
{
    foreach (var item in source)
    {
        yield return selector(item);
    }
}

var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

// Chain operations
var result = Transform(
    Filter(numbers, n => n % 2 == 0),
    n => n * n
);

foreach (var value in result)
{
    Console.Write($"{value} ");  // Output: 4 16 36 64 100
}

These methods look similar to LINQ's Where and Select, and they work the same way. Each operation returns an iterator that processes items lazily, building a pipeline without materializing intermediate results.

Practical Iterator Examples

Real-world iterators handle diverse scenarios from file processing to infinite sequences. Here are patterns you'll use frequently.

Reading file lines lazily
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);
    }
}
Infinite sequence generator
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
}
Batching iterator
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.

Custom collection with iterator
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.

Frequently Asked Questions (FAQ)

What is the difference between IEnumerable and IEnumerator?

IEnumerable represents a collection you can iterate over, while IEnumerator does the actual iteration. IEnumerable has a single method, GetEnumerator(), that returns an IEnumerator. The enumerator tracks the current position and provides methods to move through the collection. When you use foreach, the compiler calls GetEnumerator() and uses the returned IEnumerator to loop through items.

How does the yield keyword simplify iterator creation?

The yield keyword lets the compiler generate the iterator state machine automatically. Instead of manually implementing IEnumerator with state tracking, you write a simple method that yields values one at a time. The compiler handles all the complexity of maintaining state between iterations, making your code cleaner and less error-prone.

Can custom iterators improve memory efficiency?

Yes, iterators with yield enable lazy evaluation, generating items on demand rather than creating entire collections in memory. This matters when processing large datasets or infinite sequences. Instead of building a complete list, the iterator produces each item only when requested, reducing memory usage significantly for data pipelines and streaming scenarios.

Why would you create a custom iterator instead of using List<T>?

Custom iterators give you control over how data is produced and when computation happens. You can create iterators for data that doesn't fit in memory, generate infinite sequences, filter and transform data lazily, or wrap complex data sources with a simple foreach-compatible interface. This flexibility makes code more efficient and expressive than materializing everything into lists.

Can you use multiple yield statements in one method?

Yes, you can have multiple yield return statements throughout your iterator method, and even use yield return inside loops and conditional statements. The compiler tracks where execution paused and resumes from that point when the next item is requested. You can also use yield break to stop iteration early, similar to using return in regular methods.

Back to Articles