Implementing IEnumerable and IEnumerator in C#: Building Custom Collections

Every time you use a foreach loop in C#, you're working with the iterator pattern through IEnumerable<T> and IEnumerator<T>. These interfaces separate data storage from data traversal, enabling powerful patterns like lazy evaluation and LINQ. Understanding them unlocks the ability to create custom collections that integrate seamlessly with C#'s language features.

In this guide, we'll implement these interfaces from scratch, explore the yield return compiler magic, and build collections that work with LINQ. You'll see how these fundamental interfaces power everything from simple lists to complex data pipelines.

The Core Interfaces

IEnumerable<T> and IEnumerator<T> work together: the enumerable creates enumerators, and the enumerator does the actual iteration. This separation enables multiple simultaneous iterations over the same data.

public interface IEnumerable
{
    IEnumerator GetEnumerator();
}

public interface IEnumerator
{
    T Current { get; }
    bool MoveNext();
    void Reset();
}

So these are the two key interfaces that make foreach loops work.

IEnumerable<T> is what you implement on your collection - it just has one method, GetEnumerator(), which creates a fresh enumerator for traversing your data.

The IEnumerator<T> is the actual iterator - it has Current (the current item), MoveNext() (move to the next item, returns false when done), and Reset() (go back to the beginning).

The cool thing is that each call to GetEnumerator() gives you a separate enumerator, so you can have multiple iterations happening at the same time on the same collection.

The collection implements IEnumerable<T> to provide enumerators. The enumerator tracks position and returns elements one by one.

Your First Custom Collection

Let's implement a simple range collection that generates numbers from a start to end value. This demonstrates the basic pattern without complex storage logic.

public class NumberRange : IEnumerable
{
    private readonly int _start;
    private readonly int _end;

    public NumberRange(int start, int end)
    {
        _start = start;
        _end = end;
    }

    public IEnumerator GetEnumerator()
    {
        for (int i = _start; i <= _end; i++)
        {
            yield return i;
        }
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

Here's a simple NumberRange class that generates numbers from start to end.

The GetEnumerator() method uses a for loop with yield return - each time it hits yield return, it pauses and gives that value to the caller, then picks up where it left off when the caller asks for the next value.

The compiler automatically creates all the IEnumerator plumbing for you. We also implement the non-generic IEnumerable.GetEnumerator() for backward compatibility.

Super clean and the compiler does most of the work!

The yield return statement makes this simple. The compiler generates the enumerator state machine automatically. Notice we implement both generic and non-generic versions of GetEnumerator().

Usage is straightforward:

var range = new NumberRange(1, 5);
foreach (var number in range)
{
    Console.WriteLine(number); // Prints 1, 2, 3, 4, 5
}

Manual Iterator Implementation

Sometimes you need manual control over iteration. Let's implement the enumerator explicitly to understand the state management.

public class ManualRange : IEnumerable
{
    private readonly int _start;
    private readonly int _end;

    public ManualRange(int start, int end)
    {
        _start = start;
        _end = end;
    }

    public IEnumerator GetEnumerator()
    {
        return new RangeEnumerator(_start, _end);
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }

    private class RangeEnumerator : IEnumerator
    {
        private readonly int _start;
        private readonly int _end;
        private int _current;

        public RangeEnumerator(int start, int end)
        {
            _start = start;
            _end = end;
            _current = start - 1;
        }

        public int Current => _current;

        object IEnumerator.Current => Current;

        public bool MoveNext()
        {
            _current++;
            return _current <= _end;
        }

        public void Reset()
        {
            _current = _start - 1;
        }

        public void Dispose() { }
    }
}

Sometimes you need to manually implement the enumerator for more control.

Here we create a ManualRange that does the same thing as NumberRange, but we build the enumerator ourselves.

The RangeEnumerator is a private nested class that implements IEnumerator<int>. It keeps track of the current position with _current, starting at start-1 (so the first MoveNext() will put us at the first valid value).

MoveNext() increments _current and returns true if we're still in range. Current just returns _current. Reset() puts us back to the beginning.

This gives you complete control but takes more code than using yield return.

The enumerator tracks its position with _current. MoveNext() advances and returns whether more elements exist. Current returns the current value.

Tree Traversal with Custom Iterators

More complex data structures need custom traversal logic. Let's implement a binary tree with different iteration strategies.

public class TreeNode
{
    public T Value { get; }
    public TreeNode Left { get; set; }
    public TreeNode Right { get; set; }

    public TreeNode(T value)
    {
        Value = value;
    }
}

public class BinaryTree : IEnumerable
{
    public TreeNode Root { get; set; }

    public IEnumerator GetEnumerator()
    {
        return InOrderTraversal(Root);
    }

    private IEnumerator InOrderTraversal(TreeNode node)
    {
        if (node == null) yield break;

        foreach (var item in InOrderTraversal(node.Left))
            yield return item;

        yield return node.Value;

        foreach (var item in InOrderTraversal(node.Right))
            yield return item;
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

This BinaryTree shows how to make complex data structures enumerable.

We have a TreeNode class for the nodes, and BinaryTree implements IEnumerable<T>.

The GetEnumerator method calls InOrderTraversal, which is a recursive method that uses yield return to traverse the tree.

It goes left subtree first, then the current node, then right subtree - that's in-order traversal.

Each recursive call creates its own state in the compiler's state machine, so you can have deep recursion without running out of stack space for the state management.

Pretty cool how yield return handles all the complexity of tree traversal!

var tree = new BinaryTree();
tree.Root = new TreeNode(5)
{
    Left = new TreeNode(3),
    Right = new TreeNode(7)
};

foreach (var value in tree)
{
    Console.WriteLine(value); // 3, 5, 7
}

Lazy Evaluation and Infinite Sequences

Iterator pattern enables lazy evaluation - computing values only when needed. This allows infinite sequences that would be impossible to store.

public static class Fibonacci
{
    public static IEnumerable Sequence()
    {
        int a = 0, b = 1;
        while (true)
        {
            yield return a;
            int temp = a;
            a = b;
            b = temp + b;
        }
    }
}

The Fibonacci class is a perfect example of lazy evaluation - it creates an infinite sequence!

The Sequence method uses while(true) and yield return to generate Fibonacci numbers forever.

Each time the caller asks for the next number, it calculates it on the fly. Without lazy evaluation, you'd never be able to represent an infinite sequence.

The method only keeps track of the last two numbers (a and b), so it's super memory efficient no matter how many numbers you generate.

foreach (var number in Fibonacci.Sequence().Take(10))
{
    Console.WriteLine(number); // First 10 Fibonacci numbers
}

Take(10) limits the infinite sequence to 10 elements. The iterator stops after yielding 10 values.

LINQ Integration

Implementing IEnumerable<T> gives you LINQ for free. All LINQ operators work with your custom collections.

var range = new NumberRange(1, 100);
var evenSquares = range
    .Where(n => n % 2 == 0)
    .Select(n => n * n)
    .Take(5);

foreach (var square in evenSquares)
{
    Console.WriteLine(square); // 4, 16, 36, 64, 100
}

LINQ operators compose seamlessly. Each operation returns a new IEnumerable<T>, enabling fluent chaining.

Iterator State and Multiple Enumerations

Each call to GetEnumerator() creates a fresh iterator. This enables multiple simultaneous enumerations.

var numbers = new NumberRange(1, 3);

var enumerator1 = numbers.GetEnumerator();
var enumerator2 = numbers.GetEnumerator();

enumerator1.MoveNext();
Console.WriteLine(enumerator1.Current); // 1

enumerator2.MoveNext();
Console.WriteLine(enumerator2.Current); // 1 (independent)

Here's something cool - each call to GetEnumerator() gives you a completely separate enumerator with its own state.

So you can have multiple iterations happening at the same time on the same collection!

In this example, enumerator1 and enumerator2 are totally independent.

When enumerator1 moves to the first item (1), enumerator2 can still get the first item (1) too.

This is great for thread safety and nested loops where you might be iterating over the same data in different places.

Performance Considerations

yield return is convenient but has overhead. For performance-critical code, manual iterators can be faster.

Also consider memory: lazy evaluation trades CPU for memory efficiency. If you need all results anyway, materializing to a list might be better.

// For small collections, materialization might be faster
var list = range.ToList(); // Materialize once
var results = list.Where(n => n % 2 == 0); // Then filter

Profile your specific use case. The iterator pattern gives you flexibility to choose the right approach.

Common Patterns and Best Practices

Here are key patterns for robust iterator implementations:

  • Always implement both interfaces: Include non-generic IEnumerable for backward compatibility.
  • Use yield return when possible: It handles state machine generation automatically.
  • Consider disposal: If your iterator holds resources, implement IDisposable.
  • Document thread safety: Specify whether your iterators support concurrent access.

The iterator pattern separates storage from traversal, enabling lazy evaluation and composability. Master it to create collections that integrate seamlessly with C#'s ecosystem.

Advanced Example: Database Result Iterator

Let's implement a database-style iterator that loads data in batches for memory efficiency.

public class BatchedIterator : IEnumerable
{
    private readonly Func> _batchLoader;
    private readonly int _batchSize;
    private readonly int _totalCount;

    public BatchedIterator(
        Func> batchLoader,
        int batchSize,
        int totalCount)
    {
        _batchLoader = batchLoader;
        _batchSize = batchSize;
        _totalCount = totalCount;
    }

    public IEnumerator GetEnumerator()
    {
        for (int offset = 0; offset < _totalCount; offset += _batchSize)
        {
            var batch = _batchLoader(offset, _batchSize);
            foreach (var item in batch)
            {
                yield return item;
            }
        }
    }

    IEnumerator IEnumerable.GetEnumerator()
    {
        return GetEnumerator();
    }
}

The BatchedIterator is a really practical example - it loads data in chunks so you can process huge datasets without running out of memory.

You give it a batchLoader function that knows how to get data in pages, plus the total count and batch size.

The GetEnumerator method loops through all the batches, calling the batchLoader for each chunk, then yields each item one by one.

Only one batch is in memory at a time, so you could process millions of records efficiently.

The lazy evaluation means it only loads the next batch when you actually need it.

Summary

IEnumerable<T> and IEnumerator<T> form the foundation of C#'s collection system. They separate data storage from traversal, enabling lazy evaluation, LINQ integration, and flexible iteration patterns. Whether you're building simple ranges or complex data pipelines, these interfaces ensure your collections work seamlessly with the language.

The key insight is that iteration is a separate concern from storage. By implementing these interfaces, you gain access to the entire C# ecosystem - foreach loops, LINQ, and all the language features that expect enumerable objects.