Lambda Expressions · How it works
9 min readHow it works
Syntax
A: An anonymous function defined inline, with parameters on the left of => and the body on the right. It is shorthand for writing a named method and passing it as a delegate.
Func<int, int> square = x => x * x; // expression lambda
Func<int, int, int> add = (a, b) => a + b; // two parameters
Action greet = () => Console.WriteLine("hi"); // no parameters
Func<int, bool> isEven = (int n) => n % 2 == 0; // explicit parameter type
Func<string, int> parse = s => // statement lambda
{
if (!int.TryParse(s, out var n)) return -1;
return n;
};A: An expression lambda has a single expression as its body (x => x * 2) and its value is the return value. A statement lambda has a block body { … } with any number of statements and explicit returns. Only expression lambdas can be converted to expression trees.
- Discard parameters (C# 9):
(_, _) => 0when you do not use the arguments, common for event handlers. staticlambdas (C# 9):static x => x * 2forbids capturing outer variables (see Closures).- Natural type (C# 10):
var f = (int x) => x * 2;infersFunc<int, int>; parameter types must be explicit for this. - Explicit return type (C# 10):
var pick = object (bool b) => b ? 1 : "one";when the compiler cannot infer one. - Attributes (C# 10):
var f = [Obsolete] (int x) => x;and on parameters, used by minimal APIs (([FromQuery] int page) => …). - Default parameter values and
params(C# 12):var inc = (int x, int by = 1) => x + by;The natural type is then a compiler-generated delegate type, becauseFunc<>cannot express defaults.
A:
Delegates: what a lambda turns into
A: A type-safe object that points at a method: it stores the method to call and, for instance methods, the object to call it on (its Target). Func<T…, TResult> returns a value, Action<T…> returns void, and Predicate<T> is an older Func<T, bool> equivalent. You can declare your own (delegate decimal PriceRule(Order o);) when a descriptive name helps. A lambda is the most common way to create a delegate instance.
Func<int, bool> and Predicate<int> interchangeable?A: No. They have the same shape, but delegate types are nominal, so a Predicate<int> variable cannot be assigned to a Func<int, bool> one without wrapping (new Func<int, bool>(pred) or x => pred(x)). The same lambda text can be converted to either, which is why list.FindAll(x => x > 5) and list.Where(x => x > 5) both compile.
A: A method name used without parentheses (int.Parse, Console.WriteLine). It converts to a compatible delegate just like a lambda: strings.Select(int.Parse) is equivalent to strings.Select(s => int.Parse(s)). Since C# 11 the compiler caches the delegate for a static method group, so it no longer allocates on every use.
A: A delegate holding an invocation list of several methods, built with + / +=. Invoking it calls each in order; for a Func<> only the last return value is kept, and an exception stops the rest. Events are multicast delegates with restricted access (only the owner can invoke; others can only += and -=).
Delegate vs expression tree
Func<T, bool> and Expression<Func<T, bool>>?A: Func<T, bool> is compiled code: the lambda becomes IL and you can call it. Expression<Func<T, bool>> is data: the compiler emits code that builds a tree of Expression objects (parameter, member access, constant, binary operator…) describing the lambda. Nothing runs; a provider walks the tree and translates it, for example EF Core into SQL. You can call .Compile() to turn the tree into a delegate at runtime, which is expensive and should be cached. The Expression Trees and Out-of-Process Queries modules go deeper.
Func<Order, bool> inMemory = o => o.Total > 100; // IL
Expression<Func<Order, bool>> asData = o => o.Total > 100; // tree: Lambda(GreaterThan(Member(o, Total), Constant(100)))Func or an Expression?A: It decides where the filter runs. IQueryable<T>.Where takes an Expression<Func<T, bool>>, so EF Core translates it into a SQL WHERE. IEnumerable<T>.Where takes a Func<T, bool>, so it runs in memory after rows are loaded. If you store a filter as Func<Order, bool> and pass it to a DbSet, overload resolution silently picks Enumerable.Where: EF Core loads the whole table and filters in C#.
Func<Order, bool> big = o => o.Total > 100;
var slow = db.Orders.Where(big).ToList(); // SELECT * FROM Orders, filter in memory
Expression<Func<Order, bool>> bigExpr = o => o.Total > 100;
var fast = db.Orders.Where(bigExpr).ToList(); // WHERE Total > 100 in SQLA: Statement bodies ({ … }), assignments, the null-conditional operator ?., tuple literals, throw expressions, async/await, local functions and some newer pattern syntax. The compiler reports an error, which is a hint that the lambda is headed for a LINQ provider and must stay a pure expression.
Memory level
A: A private method on a compiler-generated singleton class (named <>c), plus a static field that caches the delegate. The first use creates the delegate; every later use reuses it, so a non-capturing lambda costs no allocation per call.
// You write
var evens = numbers.Where(n => n % 2 == 0);
// Roughly what the compiler emits
sealed class C // <>c
{
public static readonly C Instance = new();
public static Func<int, bool> Cache; // <>9__0_0
internal bool Lambda(int n) => n % 2 == 0;
}
var evens = numbers.Where(C.Cache ??= new Func<int, bool>(C.Instance.Lambda));A: It becomes a closure. The captured locals move into a compiler-generated heap object (a display class), the lambda becomes a method on that object, and a new delegate is created each time the lambda expression runs: typically two allocations. If it captures only this, the lambda becomes an instance method on your class and you pay for one new delegate per evaluation. Details, diagrams and leak patterns are in the Closures module.
A: A delegate call is an indirect call through the delegate object, so the JIT usually cannot inline it. That costs a few nanoseconds, which only matters in very tight loops. Modern .NET (8+) can partly recover this with dynamic PGO, which guesses the common target and inlines it behind a check, but you cannot rely on it. The bigger cost in practice is allocation from closures, not the call itself. For hot loops, prefer a plain loop or a generic struct "function object" over a Func<>.
A: Where, Select and friends store the delegate and run it only when the sequence is enumerated (deferred execution). The lambda reads captured variables at enumeration time, not when the query was written:
int min = 10;
var query = numbers.Where(n => n > min);
min = 100;
var result = query.ToList(); // filters with min = 100Async lambdas
async void lambda trap?A: An async lambda converted to an Action (or any delegate returning void) becomes an async void method. The caller cannot await it, has no idea when it finishes, and an exception inside it is not caught by the caller: it is raised on the synchronization context or thread pool and can crash the process.
// Bug: ForEach takes Action<T>, so this is async void.
// ForEach returns before any save completes; exceptions are unobservable.
orders.ForEach(async o => await SaveAsync(o));
// Fix: produce tasks and await them
await Task.WhenAll(orders.Select(o => SaveAsync(o)));
// or sequentially
foreach (var o in orders) await SaveAsync(o);
Watch for APIs whose parameter is Action (List<T>.ForEach, Parallel.ForEach, some event and timer callbacks). For Task.Run(async () => …) there is an overload taking Func<Task>, so it is safe.
A: Func<Task> for no result, Func<T, Task<TResult>> with a result: Func<int, Task<string>> load = async id => await repo.GetNameAsync(id);.
Lambdas vs local functions
A: When the helper is only called inside the method rather than passed around as a delegate. Local functions can be recursive and generic, support ref/out/params parameters and iterators (yield), are declared once below the code that uses them, and if they capture variables without being converted to a delegate they use a stack-allocated struct instead of a heap closure. Use a lambda when you need a delegate value to pass to LINQ, an event, a callback or store in a field.
A: A lambda cannot refer to itself until the variable holding it is assigned, so you declare the variable first (Func<int, int> fact = null!; fact = n => n <= 1 ? 1 : n * fact(n - 1);). That also makes it a closure over fact. A local function is the cleaner choice for recursion.
Where you use lambdas every day
- LINQ:
Where,Select,OrderBy,GroupBy,Any. - EF Core configuration and queries:
builder.HasKey(x => x.Id),Include(o => o.Lines); these are expression trees. - ASP.NET Core: minimal API handlers
app.MapGet("/orders/{id}", (int id, IOrderService s) => s.Get(id)), middlewareapp.Use(async (ctx, next) => …), optionsservices.Configure<MyOptions>(o => o.Retries = 3). - Validation and mapping:
RuleFor(x => x.Email).NotEmpty()(FluentValidation), AutoMapperForMember(d => d.Name, o => o.MapFrom(s => s.FullName)); both read the expression tree to get the property name. - Tests:
mock.Setup(r => r.Get(It.IsAny<int>()))(Moq),Assert.Throws<ArgumentException>(() => …). - Callbacks and events:
button.Click += (_, _) => …,Task.Run(() => …),cts.Token.Register(() => …).
A:
Common interview gotchas
var f = x => x * 2; not compile?A: The compiler cannot infer a natural type without parameter types. Write var f = (int x) => x * 2; or give the variable a delegate type.
A: Not in general. Each lambda expression compiles to its own method, so Func<int,int> a = x => x; Func<int,int> b = x => x; gives a.Equals(b) == false. That is why you cannot unsubscribe an event handler by writing the same lambda again.
var fs = new List<Func<int>>(); for (var i = 0; i < 3; i++) fs.Add(() => i); Console.WriteLine(string.Join(",", fs.Select(f => f())));A: 3,3,3. All three lambdas capture the single for loop variable. Copy it into a local inside the loop, or use foreach, to get 0,1,2.
Func<> make an EF Core query slow?A: DbSet.Where(Func<>) binds to Enumerable.Where, so EF Core fetches every row and the lambda filters in memory. Use Expression<Func<>> to keep the filter in SQL.
ref, out or in parameters?A: A lambda can declare them if its delegate type has them (a custom delegate, since Func/Action cannot), for example delegate bool TryGet(string key, out int value); with TryGet t = (string k, out int v) => int.TryParse(k, out v);. It cannot capture an outer ref, out or in parameter or a Span<T>.