LINQ In Depth

Course 2 · Ch 2
LINQ In Depth
Deferred, but genuinely re-enumerable — a real difference from java2-4's own single-use Streams

csharp1-8 previewed LINQ's two syntaxes. This chapter goes underneath both — into what a LINQ query actually is before it runs, and where its behavior genuinely diverges from java2-4's own Streams API.

Method Syntax vs. Query Syntax — When to Reach for Which

var result = people.Where(p => p.Age >= 18).OrderBy(p => p.Name); // composes cleanly with anything else var result2 = from p in people where p.Age >= 18 orderby p.Name select p; // reads naturally for joins and multi-source queries

Method syntax composes naturally with the rest of C# — custom extension methods, conditionals, anything else in scope. Query syntax tends to read more clearly once a query involves a join or spans multiple sources, where its SQL-like shape genuinely earns its keep.

Deferred Execution

var numbers = new List<int> { 1, 2, 3 }; var query = numbers.Where(n => n > 1); // NOT executed yet — just describes the operation numbers.Add(99); // mutate the SOURCE after the query is defined foreach (var n in query) Console.WriteLine(n); // prints 2, 3, 99 — includes the late addition

Genuinely comparable to java2-4's own lazy Streams — a LINQ query isn't run when it's defined, only when it's actually enumerated. But here's a real, concrete difference: a LINQ query can be re-enumerated — each foreach, each .ToList(), re-runs the whole query fresh against the current state of the source. java2-4's own Streams throw IllegalStateException on a second terminal operation; C#'s query above has no such restriction at all.

IEnumerable<T> vs. IQueryable<T>

IEnumerable<Customer> local = customers.Where(c => c.Active); // runs as real C# delegates, in memory IQueryable<Customer> remote = dbContext.Customers.Where(c => c.Active); // becomes an EXPRESSION TREE // remote is translated into real SQL and executed on the database — only when enumerated

IEnumerable<T> queries run as ordinary in-memory delegates, the same territory java2-4's Streams live in entirely. IQueryable<T> — used by Entity Framework and similar tools — is a genuinely different mechanism: the same .Where()/.OrderBy() calls build an expression tree instead of executing anything, which gets translated into real SQL and run on a database server, only once the query is enumerated. java2-4's Streams have no equivalent capability at all — they only ever operate over already-materialized in-memory Java objects.

A Deferred Execution Gotcha — Variable Capture

int threshold = 10; var query = numbers.Where(n => n > threshold); // captures threshold BY REFERENCE, not by value threshold = 50; // changed AFTER the query was defined foreach (var n in query) Console.WriteLine(n); // uses 50, not 10 — the live value at enumeration time

A real, opposite design choice from java2-3's own Java lambdas: Java requires a captured local variable to be effectively final — reassigning it anywhere makes the capture illegal, a compile error. C# places no such restriction on ordinary local variables — the lambda captures the variable itself, not a snapshot of its value, so the query genuinely reads whatever threshold holds at the moment it's actually enumerated, not the moment it was written.

AspectJava Streams (java2-4 / java2-3)C# LINQ
Execution timingdeferred until a terminal opdeferred until enumeration
Re-use after first executionthrows IllegalStateExceptionfully re-enumerable, re-runs fresh each time
Remote/database translationnot available — in-memory onlyIQueryable<T> — expression trees translated to SQL
Captured local variablemust be effectively finalcaptured live, by reference — freely reassignable
Force immediate evaluation with ToList()/ToArray() for a stable snapshot
When a query result needs to stay fixed regardless of later changes to the source or captured variables, materialize it immediately — .ToList() or .ToArray() runs the query once, right there, and hands back a real, independent collection.
Enumerating the same query twice can silently produce different results
Because a LINQ query re-runs fresh on every enumeration, two separate foreach loops over the same query variable can genuinely produce different output if the source collection or a captured variable changed in between — a real, easy-to-miss source of confusing bugs, especially coming from Java's single-use Stream model where this scenario simply can't arise.

Coding Challenges

Challenge 1

Define a LINQ query over a List<int> filtering for values greater than 5, add a new qualifying element to the list after defining the query, then enumerate the query and show the new element is included.

📄 View solution
Challenge 2

Write a query capturing a local int variable in a Where() lambda, enumerate it once, then change the variable's value and enumerate the exact same query variable a second time, showing the two results differ.

📄 View solution
Challenge 3

Repeat Challenge 1's scenario, but call .ToList() immediately after defining the query. Add the same new element to the source list afterward, then print the materialized list, showing the new element is NOT included this time.

📄 View solution

Chapter 2 Quick Reference

  • Method syntax composes naturally; query syntax reads best for joins/multi-source queries
  • LINQ queries are deferred, like java2-4's Streams — but genuinely re-enumerable, unlike Streams' single-use IllegalStateException restriction
  • IEnumerable<T> runs in-memory like Java Streams; IQueryable<T> translates to expression trees and real SQL — no Java Streams equivalent exists
  • C# lambdas capture local variables live, by reference — no effectively-final restriction, the opposite of java2-3's own Java rule
  • .ToList()/.ToArray() force immediate evaluation for a stable, independent snapshot
  • Next chapter: delegates, events, and lambda expressions — a different mechanism behind similar syntax to java2-3's own SAM interfaces