Reading — step 1 of 7
Learn
C# in Depth treats LINQ as one of the language's signature features. The basics (Where, Select, OrderBy) you know. This lesson covers what makes LINQ tick: deferred execution, iterator state machines, and expression trees.
Two LINQs: LINQ to Objects vs IQueryable
var nums = new[] { 1, 2, 3, 4, 5 };
// LINQ to Objects — runs in-memory
var evens = nums.Where(n => n % 2 == 0);
// LINQ to SQL / EF / etc — translates to SQL
var dbEvens = dbContext.Numbers.Where(n => n.Value % 2 == 0);
First returns IEnumerable<int>. Second returns IQueryable<Number> — the lambda is converted to an expression tree that the provider translates to SQL.
Deferred execution
LINQ operators are LAZY — they don't execute until enumerated:
var results = nums.Where(n => {
Console.WriteLine($"checking {n}");
return n > 2;
});
Console.WriteLine("before iteration");
foreach (var r in results) Console.WriteLine($"got {r}");
// Output:
// before iteration
// checking 1
// checking 2
// checking 3
// got 3
// checking 4
// got 4
// checking 5
// got 5
Where doesn't run when called. It runs when the foreach pulls values.
Implications:
- Side effects in LINQ chains run during iteration, not call time
- Iterating a query TWICE re-runs it twice (often a bug source)
- Storing the query is cheap; iterating it is the cost
Materializing the result:
.ToList()— eager, returns List.ToArray()— eager, returns array.ToDictionary(keySelector)— eager, builds Dictionary.First()/.Single()/.Count()— eager (terminal operators)
yield return — the iterator pattern
LINQ's lazy operators are implemented with yield:
public static IEnumerable<T> MyWhere<T>(IEnumerable<T> source, Func<T, bool> pred)
{
foreach (var item in source)
if (pred(item))
yield return item;
}
The compiler turns this into a state-machine class implementing IEnumerator<T>. Each yield return is a suspension point.
Roll your own:
static IEnumerable<int> Fibonacci()
{
int a = 0, b = 1;
while (true)
{
yield return a;
(a, b) = (b, a + b);
}
}
fibonacci.Take(10).ToList(); // [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
Infinite sequences are fine — laziness means you only compute what you consume.
Expression trees
When you write a lambda for IQueryable, the compiler does NOT compile it to IL. It builds an Expression<Func<T, bool>> tree:
Expression<Func<int, bool>> expr = n => n > 5;
// At runtime: BinaryExpression(GreaterThan, ParameterExpression, ConstantExpression)
Providers (Entity Framework, LINQ to SQL, Cosmos DB SDK) walk this tree and emit a query in their native language.
Compiling at runtime:
Func<int, bool> compiled = expr.Compile();
compiled(7); // true
Common LINQ patterns
// Group counting
var wordCounts = words.GroupBy(w => w).Select(g => new { Word = g.Key, Count = g.Count() });
// Join
var joined = users.Join(orders, u => u.Id, o => o.UserId, (u, o) => new { u.Name, o.Total });
// Aggregate (reduce)
var product = nums.Aggregate(1, (acc, n) => acc * n);
// SelectMany (flatMap)
var allTags = posts.SelectMany(p => p.Tags);
// Pagination
var page = items.Skip(offset).Take(pageSize);
// Distinct by key (manual since LINQ doesn't have DistinctBy until .NET 6)
var uniqueByName = items.GroupBy(i => i.Name).Select(g => g.First());
Common mistakes
- Iterating a query multiple times — runs the work twice. ToList once, iterate the list.
- Side effects in operators — execute lazily, surprises during debug.
- Missing ConfigureAwait equivalent for IQueryable — use
.AsAsyncEnumerable()or provider-specific async. - Trying to use unsupported expressions in IQueryable — provider can only translate what it knows. Custom methods may need to be fetched server-side via
.AsEnumerable()then operated on in memory. - Forgetting
null-handling inFirstOrDefault— for non-nullable structs, default is 0/false/etc.; for reference types it's null.
Discussion
Ask a question, share an insight, or help someone who’s stuck.
Sign in to post a comment or reply.
Loading…