Iterating Without Exposing Implementation
If you've ever needed to traverse a custom data structure without revealing its internals or wanted to provide multiple ways to walk the same collection, you've hit the limits of direct array access. Exposing your internal storage couples callers to your implementation and makes refactoring dangerous.
The Iterator pattern lets you provide sequential access to elements without exposing how they're stored. Callers use foreach or LINQ without knowing if you're using an array, tree, linked list, or generating values on the fly. This keeps your structure's internals private while offering clean traversal.
You'll build a custom collection that generates Fibonacci numbers lazily and supports multiple traversal strategies. By the end, you'll know when to use yield return versus building full collections upfront.
IEnumerable Gives You Foreach
Implementing IEnumerable<T> tells C# that your type can be iterated. The foreach loop works with anything that implements this interface. Before yield, you had to manually create an enumerator class with state tracking.
namespace IteratorDemo;
public class SimpleCollection : IEnumerable<int>
{
private readonly int[] _items = { 1, 2, 3, 4, 5 };
public IEnumerator<int> GetEnumerator()
{
foreach (var item in _items)
{
yield return item;
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
The yield return keyword does the heavy lifting. The compiler generates a state machine that tracks where you are in the iteration. Each call to MoveNext executes code until the next yield, then pauses and returns the value.
Lazy Evaluation with Yield
Yield return produces values on demand rather than building a full collection upfront. This saves memory when working with large or infinite sequences. The method only executes when someone actually requests the next item.
namespace IteratorDemo;
public class FibonacciSequence
{
public IEnumerable<long> Generate(int count)
{
long current = 0, next = 1;
for (int i = 0; i < count; i++)
{
yield return current;
var temp = current;
current = next;
next = temp + next;
}
}
public IEnumerable<long> GenerateInfinite()
{
long current = 0, next = 1;
while (true)
{
yield return current;
var temp = current;
current = next;
next = temp + next;
}
}
}
The GenerateInfinite method looks like it runs forever, but it's lazy. Calling it doesn't execute the loop. Only when you iterate and call MoveNext does it produce the next Fibonacci number. Combine it with Take(10) and you'll get exactly ten numbers without the method ever knowing it stopped.
Multiple Traversal Strategies
A collection can offer different iteration orders by providing multiple methods that yield values differently. This lets callers choose how to walk your data without exposing the structure.
namespace IteratorDemo;
public class TreeNode<T>
{
public T Value { get; init; }
public List<TreeNode<T>> Children { get; } = new();
}
public class Tree<T>
{
public TreeNode<T> Root { get; init; }
public IEnumerable<T> TraverseDepthFirst()
{
if (Root == null) yield break;
var stack = new Stack<TreeNode<T>>();
stack.Push(Root);
while (stack.Count > 0)
{
var node = stack.Pop();
yield return node.Value;
foreach (var child in node.Children.AsEnumerable().Reverse())
{
stack.Push(child);
}
}
}
public IEnumerable<T> TraverseBreadthFirst()
{
if (Root == null) yield break;
var queue = new Queue<TreeNode<T>>();
queue.Enqueue(Root);
while (queue.Count > 0)
{
var node = queue.Dequeue();
yield return node.Value;
foreach (var child in node.Children)
{
queue.Enqueue(child);
}
}
}
}
Both methods traverse the same tree but in different orders. Callers pick the strategy they need without knowing how the tree is stored. The yield break statement exits the iterator early when there's no root.
Try It Yourself
Build a simple iterator that generates prime numbers lazily. This demonstrates how yield return defers computation until values are actually needed.
Steps
- Create the project:
dotnet new console -n IteratorLab
- Open it:
cd IteratorLab
- Replace Program.cs below
- Configure the .csproj
- Run it:
dotnet run
var primes = GeneratePrimes().Take(10);
foreach (var prime in primes)
{
Console.WriteLine(prime);
}
IEnumerable<int> GeneratePrimes()
{
yield return 2;
var candidate = 3;
while (true)
{
if (IsPrime(candidate))
{
yield return candidate;
}
candidate += 2;
}
}
bool IsPrime(int n)
{
if (n < 2) return false;
for (int i = 2; i * i <= n; i++)
{
if (n % i == 0) return false;
}
return true;
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Console
2
3
5
7
11
13
17
19
23
29
Testing Strategies
Test iterators by converting them to lists or arrays with ToList() or ToArray(). This forces evaluation and lets you assert on the full sequence. For infinite sequences, use Take() to limit the output before collecting.
Check edge cases like empty collections, single items, and collections that change during iteration. Verify that your iterator doesn't execute until enumeration starts. You can test this by checking that expensive operations in the iterator body don't run until you call MoveNext or foreach.
When testing lazy iterators, ensure that side effects happen at the right time. If your iterator logs or mutates state, verify those actions occur during enumeration, not during the initial method call. Use mocks or counters to track when your yield return blocks actually execute.