Deferred execution, iterators and IQueryable
How a LINQ pipeline pulls one element at a time, why enumerating twice runs everything twice, and what changes when the source is an IQueryable.
After this lesson you can
- Trace the order in which a yield iterator and a Where/Select pipeline run
- Recognise multiple enumeration and fix it with one ToList
- Explain the difference between IEnumerable<T> with Func and IQueryable<T> with expression trees
Lesson 3 showed that a query does not run until it is enumerated. This lesson looks at how it runs, because that is what interview questions about LINQ are really probing.
yield return: a method that pauses
A method containing yield return is an iterator. Calling it runs
none of its body; it returns an object that runs the body only as far
as the next yield return each time the consumer asks for an element
(MoveNext), then pauses there until asked again.
LINQ operators such as Where, Select and Take are built the same
way, so a pipeline streams: one element travels through every stage
before the next element is even produced.
public static class Solution{ static IEnumerable<int> Numbers(List<string> log) { for (int i = 1; i <= 4; i++) { log.Add("yield " + i); yield return i; } } public static string Describe() { var log = new List<string>(); var query = Numbers(log) .Where(n => { log.Add("where " + n); return n % 2 == 0; }) .Select(n => { log.Add("select " + n); return n * 10; }); log.Add("query built"); foreach (var x in query.Take(1)) log.Add("got " + x); return string.Join(" | ", log); }}Read the log left to right. Building the query logged nothing. Then 1
was produced and rejected by Where; 2 was produced, passed Where,
was projected by Select and handed out; and because Take(1) had
what it needed, 3 and 4 were never produced at all. Not every operator
streams: OrderBy, GroupBy and Reverse must read the whole input
before yielding their first element.
Every enumeration starts again
A query is a recipe, not a result. Enumerate it twice and the whole pipeline runs twice:
using System.Linq.Expressions; public static class Solution{ public static string Describe() { int evaluations = 0; var source = new List<int> { 1, 2, 3, 4 }; IEnumerable<int> evens = source.Where(n => { evaluations++; return n % 2 == 0; }); int count = evens.Count(); var list = evens.ToList(); int limit = 2; var above = source.Where(n => n > limit); limit = 3; // captured variable, read at enumeration time string aboveResult = string.Join(",", above); var bad = source.Select(n => 10 / (n - 3)); // no exception yet string failure; try { bad.ToList(); failure = "none"; } catch (DivideByZeroException) { failure = "DivideByZeroException at ToList"; } Func<int, bool> compiled = n => n > 2; Expression<Func<int, bool>> tree = n => n > 2; var queryable = source.AsQueryable().Where(n => n > 2); return $"evaluations after Count+ToList={evaluations} | above limit={aboveResult} | {failure} | " + $"Func={compiled.GetType().Name} Expression body={tree.Body} | IQueryable expression={queryable.Expression}"; }}Count()and thenToList()ran theWherelambda over four elements each time: 8 evaluations.limitwas changed after the query was built, and the query used the new value, because the lambda captured the variable (lesson 10).- The division by zero did not happen where
Selectwas written, but insideToList(), which is where its stack trace will point.
IEnumerable versus IQueryable
Enumerable.Where takes a Func<T, bool>: compiled code that can only
be called. Queryable.Where takes an Expression<Func<T, bool>>: the
same lambda written as data, a tree that a provider can inspect and
translate. The last two parts of the output above show the difference:
the Func is just a delegate type, while the expression tree prints
as (n > 2) and an IQueryable carries the whole query as a tree.
That tree is what lets Entity Framework Core turn
db.Orders.Where(o => o.Total > 100) into a SQL WHERE clause. Once
you call AsEnumerable() or ToList(), the remaining operators run in
memory, in C#, on whatever rows came back.
Exercise · Pro
Prove the source is pulled lazily
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade