Skip to main content

📝 Lesson 1.3: Iterators and Lazy Sequences

What if a sequence could produce its values one at a time, on demand — even an infinite one? The yield keyword lets you write iterators that generate items lazily, the same mechanism that powers LINQ's efficiency.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain the pull-based model behind IEnumerable/IEnumerator
  • Write iterator methods with yield return and yield break
  • Explain lazy (deferred) evaluation and its benefits
  • Create infinite sequences and consume them safely
  • Avoid the common pitfalls of deferred iterators

Estimated Time: 75 minutes

Project: Build lazy, composable sequences with custom iterators.

In This Lesson

The Pull Model

A foreach loop doesn't receive a whole collection at once — it pulls items one at a time. Under the hood, IEnumerable<T> hands out an IEnumerator<T>, and the loop repeatedly calls MoveNext() and reads Current until there's nothing left.

graph LR A["foreach"] -->|"MoveNext()"| B["Enumerator"] B -->|"true + Current"| A B -->|"false (done)"| C["loop ends"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style C fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

Historically, implementing that enumerator by hand was tedious boilerplate. The yield keyword lets the compiler write it for you — you just describe what to produce, and in what order.

📖 Definition

Iterator method: a method that returns IEnumerable<T> (or IEnumerator<T>) and uses yield return to produce values one at a time. The compiler turns it into a lazy state machine.

yield return

An iterator method uses yield return to hand back the next value. Execution pauses there and resumes on the following pull:

public static IEnumerable<int> Countdown(int from)
{
    for (int i = from; i >= 1; i--)
    {
        yield return i;      // produce i, then pause here until the next pull
    }
    yield return 0;          // and finally 0
}
foreach (int n in Countdown(3))
{
    Console.Write($"{n} ");
}
// 3 2 1 0

Use yield break to stop the sequence early — it ends the iteration, like return in a normal method:

public static IEnumerable<int> TakeWhilePositive(IEnumerable<int> source)
{
    foreach (int n in source)
    {
        if (n <= 0) yield break;   // stop entirely at the first non-positive
        yield return n;
    }
}

// From { 3, 7, 2, -1, 9 } → yields 3, 7, 2 then stops

💡 What you can't do in an iterator

An iterator method can't use return someValue; (only yield return/yield break), and can't have yield inside a try with a catch. Also, ref/out parameters aren't allowed. These constraints exist because the method is rewritten into a resumable state machine.

Lazy Evaluation

The defining feature of iterators is laziness: none of the method body runs until you start enumerating, and each item is produced only when pulled. This is the same deferred execution you saw with LINQ in Intermediate — now you understand its source.

public static IEnumerable<int> Numbers()
{
    Console.WriteLine("  [producing 1]"); yield return 1;
    Console.WriteLine("  [producing 2]"); yield return 2;
    Console.WriteLine("  [producing 3]"); yield return 3;
}

Console.WriteLine("Before foreach");
var seq = Numbers();               // nothing printed yet — body hasn't run
Console.WriteLine("Created; now iterating");
foreach (int n in seq)
{
    Console.WriteLine($"Got {n}");
}

Output — note the interleaving:

Before foreach
Created; now iterating
  [producing 1]
Got 1
  [producing 2]
Got 2
  [producing 3]
Got 3

✅ Why laziness is powerful

  • Memory: you never hold the whole sequence at once — great for huge or streamed data.
  • Short-circuiting: operators like First or Take(5) stop pulling early, so unneeded items are never computed.
  • Composability: chained lazy operators form a pipeline that runs in a single pass, item by item.

The Hidden State Machine

How can a method "pause" and "resume"? The compiler rewrites your iterator into a hidden class — a state machine — that remembers where it left off (which line, and the values of local variables) between pulls.

// You write this...
public static IEnumerable<int> TwoValues()
{
    yield return 10;
    yield return 20;
}

// ...the compiler generates (conceptually) a class that tracks a 'state'
// field: state 0 → return 10 and remember "next time, go to state 1";
// state 1 → return 20; state 2 → done. MoveNext() advances the state.
💡 The mental model: each yield return is a bookmark. When the enumerator's MoveNext() is called, the state machine jumps back to the last bookmark, runs until the next yield, and pauses again — carrying your local variables along.

💡 This is exactly how LINQ works internally

Operators like Where and Select are implemented as iterator methods using yield return. That's why a LINQ chain is lazy and single-pass — each operator pulls one item from the previous one, transforms or filters it, and yields it onward.

Infinite Sequences

Because items are produced on demand, an iterator can describe a sequence with no end — you just take as many as you need. A finite List could never do this:

public static IEnumerable<int> Naturals()
{
    int n = 1;
    while (true)          // infinite — but that's fine, it's lazy!
    {
        yield return n++;
    }
}
// Take just the first 5 — the loop only runs 5 times
foreach (int n in Naturals().Take(5))
{
    Console.Write($"{n} ");
}
// 1 2 3 4 5

A classic example — an infinite Fibonacci sequence, consumed lazily with LINQ:

public static IEnumerable<long> Fibonacci()
{
    long a = 0, b = 1;
    while (true)
    {
        yield return a;
        (a, b) = (b, a + b);   // tuple deconstruction to advance
    }
}

var firstTen = Fibonacci().Take(10).ToList();
Console.WriteLine(string.Join(", ", firstTen));
// 0, 1, 1, 2, 3, 5, 8, 13, 21, 34

⚠️ Never fully enumerate an infinite sequence

Calling Fibonacci().ToList() or foreach-ing it without a Take/First/other limit will loop forever (or until it overflows). Always bound an infinite iterator with a limiting operator.

Pitfalls of Deferred Iterators

⚠️ Re-enumeration re-runs everything

Each time you iterate a lazy sequence, the iterator runs again from the start. If producing items is expensive (a query, a web call), iterating twice does the work twice:

var results = ExpensiveQuery();   // deferred
var count = results.Count();      // runs the query once
var first = results.First();      // runs it AGAIN

// Fix: materialize once
var list = ExpensiveQuery().ToList();
var count2 = list.Count;          // no re-run
var first2 = list[0];

⚠️ Deferred exceptions and captured state

Because the body doesn't run until enumeration, an argument-validation exception thrown inside an iterator won't fire when the method is called — only when it's first iterated. A common pattern is to split validation into a non-iterator wrapper that calls a private iterator, so bad arguments fail immediately.

💡 When to prefer eager over lazy

Laziness is great for large/streamed/short-circuited data. But if the sequence is small, iterated multiple times, or you want side effects to happen once and immediately, materialize it with ToList()/ToArray(). Choose deliberately — the whole point of understanding iterators is knowing which behavior you're getting.

Exercise & Quiz

🏋️ Exercise: Build Lazy Sequences

Objective: Write iterator methods and a custom lazy operator.

Instructions:

  1. Create a new project called Iterators.
  2. Write IEnumerable<int> Range(int start, int count) using yield return (don't use the built-in Enumerable.Range).
  3. Write an infinite IEnumerable<int> Powers(int baseValue) that yields baseValue^0, baseValue^1, baseValue^2, .... Print the first 6 powers of 2 with .Take(6).
  4. Write a custom lazy extension method IEnumerable<T> EveryOther<T>(this IEnumerable<T> source) that yields every second item (indices 0, 2, 4, …). Test it on your Range.

Starter Code:

foreach (var n in Powers(2).Take(6)) Console.Write($"{n} ");   // 1 2 4 8 16 32
Console.WriteLine();
Console.WriteLine(string.Join(", ", Range(1, 10).EveryOther())); // 1, 3, 5, 7, 9

static IEnumerable<int> Range(int start, int count)
{
    // TODO: yield count values starting at start
}

static IEnumerable<int> Powers(int baseValue)
{
    // TODO: infinite — yield baseValue^0, ^1, ^2, ...
}

public static class SeqExtensions
{
    public static IEnumerable<T> EveryOther<T>(this IEnumerable<T> source)
    {
        // TODO: yield items at index 0, 2, 4, ...
    }
}
💡 Hint

Range: a for loop yield return start + i;. Powers: keep a running long current = 1;, yield return it, then current *= baseValue;, forever. EveryOther: track a bool toggle or an index counter, yield only when it's even.

✅ Solution
foreach (var n in Powers(2).Take(6)) Console.Write($"{n} ");   // 1 2 4 8 16 32
Console.WriteLine();
Console.WriteLine(string.Join(", ", Range(1, 10).EveryOther())); // 1, 3, 5, 7, 9

static IEnumerable<int> Range(int start, int count)
{
    for (int i = 0; i < count; i++)
    {
        yield return start + i;
    }
}

static IEnumerable<int> Powers(int baseValue)
{
    long current = 1;
    while (true)
    {
        yield return (int)current;
        current *= baseValue;
    }
}

public static class SeqExtensions
{
    public static IEnumerable<T> EveryOther<T>(this IEnumerable<T> source)
    {
        int index = 0;
        foreach (T item in source)
        {
            if (index % 2 == 0)
            {
                yield return item;
            }
            index++;
        }
    }
}

🎯 Quick Quiz

Question 1: What does yield return do?

Question 2: When does the body of an iterator method start running?

Question 3: How can an infinite iterator like Naturals() be safe to use?

Summary

🎉 Key Takeaways

  • IEnumerable is a pull model: foreach repeatedly calls MoveNext() and reads Current.
  • yield return produces a value and pauses; yield break ends the sequence. The compiler builds a resumable state machine.
  • Iterators are lazy — the body runs on enumeration, one item at a time — saving memory and enabling short-circuiting (this is how LINQ works).
  • Iterators can describe infinite sequences; bound them with Take/First — never fully enumerate them.
  • Beware re-enumeration (repeats the work) and deferred exceptions; materialize with ToList() when eager is what you want.

📚 Additional Resources

🚀 What's Next?

That completes Module 1! You've mastered advanced language features. In Module 2, we tackle running code on many threads. First up: Lesson 2.1: Threads, the Thread Pool, and Tasks — the foundation of concurrency.

🎉 Module 1 complete!

Variance, extensions, and iterators — you command C#'s advanced language features. Next: concurrency.