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.