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}");
Numbers() called
producing 1
got 1
producing 2
got 2
producing 3
got 3
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}");
<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
-
<>1__stateis where it paused: whichyield returnto continue after. -
<>2__currentis the value just yielded (whatCurrentreturns). -
<i>5__2is your loop variablei, moved into a field so it survives between calls. -
<>l__initialThreadIdis a small optimization: the firstforeachon 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}");
cleanup ran
2, 4, 6
produced: 6
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());
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)
(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;
}
}
Same calling code:
caught at the call
Recap
- Calling an iterator runs nothing. Each
MoveNext()runs to the nextyield return. - The compiler turns it into a class with a state field, the current value and your locals.
- Consumers that stop early dispose the iterator, so
finallyandusingstill run. - 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)