Lambda Expressions · How it works

9 min read
Mid-level10 min read
Rapid overview

How it works

Syntax

Q: What is a lambda expression?

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;
};
Q: What is the difference between an expression lambda and a statement lambda?

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): (_, _) => 0 when you do not use the arguments, common for event handlers.
  • static lambdas (C# 9): static x => x * 2 forbids capturing outer variables (see Closures).
  • Natural type (C# 10): var f = (int x) => x * 2; infers Func<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, because Func<> cannot express defaults.
Q: What newer lambda syntax should you know?

A:


Delegates: what a lambda turns into

Q: What is a delegate?

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.

Q: Are 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.

Q: What is a method group, and how does it relate to lambdas?

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.

Q: What is a multicast delegate?

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

Q: What is the difference between 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)))
Q: Why does it matter whether a LINQ query gets a 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 SQL
Q: What can a lambda not contain if it must become an expression tree?

A: 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

Q: What does the compiler generate for a lambda that captures nothing?

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));
Q: What changes when the lambda captures a variable?

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.

Q: How expensive is calling a delegate compared with a direct method call?

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<>.

Q: Why are LINQ lambdas lazy, and what does that mean for captured variables?

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 = 100

Async lambdas

Q: What is the 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.

Q: How do you write the type of an async lambda?

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

Q: When should you use a local function instead of a lambda?

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.

Q: How do you write a recursive lambda?

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)), middleware app.Use(async (ctx, next) => …), options services.Configure<MyOptions>(o => o.Retries = 3).
  • Validation and mapping: RuleFor(x => x.Email).NotEmpty() (FluentValidation), AutoMapper ForMember(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(() => …).
Q: Name common places lambdas appear in .NET code.

A:


Common interview gotchas

Q: Why does 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.

Q: Are two lambdas with identical bodies equal?

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.

Q: What prints? 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.

Q: Why might a filter written as a 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.

Q: Can a lambda use 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>.

See also