DEV Community

Cover image for Why LINQ is lazy: IEnumerable and yield in C#, explained by running it
Naze Code
Naze Code

Posted on

Why LINQ is lazy: IEnumerable and yield in C#, explained by running it

LINQ is lazy: Where and Select don't do anything until you loop over the result. Most people learn that as a rule. But it isn't LINQ magic. It comes straight from yield return, and once you see how yield works, LINQ's behavior stops being surprising.

Four small experiments, run for real on .NET 10 (Release build).

1. Nothing runs until someone asks

static IEnumerable<int> Numbers()
{
    for (var i = 1; i <= 3; i++)
    {
        Console.WriteLine($"  producing {i}");
        yield return i;
    }
}

var numbers = Numbers();
Console.WriteLine("Numbers() called");

foreach (var n in numbers)
    Console.WriteLine($"got {n}");
Enter fullscreen mode Exit fullscreen mode
Numbers() called
  producing 1
got 1
  producing 2
got 2
  producing 3
got 3
Enter fullscreen mode Exit fullscreen mode

Two things to notice:

  • Calling Numbers() ran none of its body. "Numbers() called" printed first.
  • After that, producing and consuming take turns. The loop asks for one item, the method runs until the next yield return, hands it over, and pauses there until the loop asks again.

That's exactly why a LINQ query runs when it's read, not when it's written: Where, Select and friends are built the same way.

2. What the compiler turns yield into

A method can't really "pause" halfway. So the compiler rewrites an iterator method into a hidden class that remembers where it was. Reflection can find it:

var machine = typeof(Demo).GetNestedTypes(BindingFlags.NonPublic)
    .Single(t => t.Name.Contains("Numbers"));

Console.WriteLine(machine.Name);
foreach (var i in machine.GetInterfaces())
    Console.WriteLine($"  implements {i.Name}");
foreach (var f in machine.GetFields(BindingFlags.Instance | BindingFlags.NonPublic))
    Console.WriteLine($"  {f.FieldType.Name,-8} {f.Name}");
Enter fullscreen mode Exit fullscreen mode
<Numbers>d__0
  implements IEnumerable`1
  implements IEnumerable
  implements IEnumerator`1
  implements IDisposable
  implements IEnumerator
  Int32    <>1__state
  Int32    <>2__current
  Int32    <>l__initialThreadId
  Int32    <i>5__2
Enter fullscreen mode Exit fullscreen mode
  • <>1__state is where it paused: which yield return to continue after.
  • <>2__current is the value just yielded (what Current returns).
  • <i>5__2 is your loop variable i, moved into a field so it survives between calls.
  • <>l__initialThreadId is a small optimization: the first foreach on the thread that created the sequence can reuse this same object as its enumerator instead of allocating a new one.

The class is both the sequence (IEnumerable) and the cursor (IEnumerator). Each MoveNext() call runs your code up to the next yield return. It also implements IDisposable, which matters in the next part.

3. Infinite sequences and early stops

static IEnumerable<int> Naturals()
{
    try
    {
        for (var n = 1; ; n++) { produced++; yield return n; }   // no exit
    }
    finally { Console.WriteLine("  cleanup ran"); }
}

var firstEvens = Naturals().Where(n => n % 2 == 0).Take(3);

Console.WriteLine(string.Join(", ", firstEvens));
Console.WriteLine($"produced: {produced}");
Enter fullscreen mode Exit fullscreen mode
  cleanup ran
2, 4, 6
produced: 6
Enter fullscreen mode Exit fullscreen mode

The loop has no exit, and it still finished, after producing exactly 6 numbers. Each item is pulled through the chain one at a time, and once Take(3) has three, it stops asking.

And the finally still ran: when the consumer stops early, it disposes the enumerator, and disposing a paused iterator runs its pending finally blocks. ("cleanup ran" prints first because string.Join reads the whole sequence, which disposes it, before the result is printed.) That's what makes it safe to yield from inside a using block that holds a file or a connection.

4. The argument check that runs too late

static IEnumerable<string> ReadLines(string path)
{
    if (path is null) throw new ArgumentNullException(nameof(path));
    foreach (var line in File.ReadLines(path))
        yield return line;
}

IEnumerable<string> lines;
try { lines = ReadLines(null!); }
catch (ArgumentNullException) { Console.WriteLine("caught at the call"); return; }

Console.WriteLine("no exception yet...");
Console.WriteLine(lines.Count());
Enter fullscreen mode Exit fullscreen mode
no exception yet...
Unhandled exception. System.ArgumentNullException: Value cannot be null. (Parameter 'path')
   at P4.Bad.ReadLines(String path)+MoveNext() in ...\Parts.cs:line 92
   at System.Linq.Enumerable.Count[TSource](IEnumerable`1 source)
Enter fullscreen mode Exit fullscreen mode

(Path shortened.) The null check is part of the iterator body, so like everything else in it, it doesn't run until the first MoveNext(). The try around the call caught nothing. The exception came out of Count(), which could be in a completely different part of the program, long after the bad call.

Fix: split the method. Validate in a normal method, then return a local iterator function.

static IEnumerable<string> ReadLines(string path)
{
    ArgumentNullException.ThrowIfNull(path);   // runs now
    return Iterate(path);

    static IEnumerable<string> Iterate(string path)
    {
        foreach (var line in File.ReadLines(path))
            yield return line;
    }
}
Enter fullscreen mode Exit fullscreen mode

Same calling code:

caught at the call
Enter fullscreen mode Exit fullscreen mode

Recap

  1. Calling an iterator runs nothing. Each MoveNext() runs to the next yield return.
  2. The compiler turns it into a class with a state field, the current value and your locals.
  3. Consumers that stop early dispose the iterator, so finally and using still run.
  4. Validate arguments in a wrapper method, not inside the iterator.

Did part 4 ever bite you? Tell me in the comments.

I make short, tested videos about C# and what's really going on under the hood. More on the Naze Code YouTube channel.


Sources: Microsoft Learn, yield statement, Iterators, Deferred execution and lazy evaluation. Tested on .NET SDK 10.0.302, Release build.

Top comments (0)