C# & .NET (C# 14, .NET 10 LTS) Module 12 – Delegates, lambdas, events and functional style
Lambdas and closures
Write C# lambdas from one-line bodies to natural types, and predict exactly what a closure captures, in for and foreach loops alike, with runs that prove it.
What you will learn
- Write expression and statement lambdas, and tell which delegate type a lambda gets with var
- Predict what a closure captures, and when the lambdas made in a loop share one variable
- Explain what a captured variable costs in allocations and how long it keeps objects in memory
- Use static lambdas to stop accidental captures, and use the lambda features of C# 9 to C# 14
Before you start
What you’ll learn · Before you start
What you will learn
- Write expression and statement lambdas, and tell which delegate type a lambda gets with var
- Predict what a closure captures, and when the lambdas made in a loop share one variable
- Explain what a captured variable costs in allocations and how long it keeps objects in memory
- Use static lambdas to stop accidental captures, and use the lambda features of C# 9 to C# 14
Before you start
On this page
A booking screen makes four seat buttons in a for loop, one for each seat from B2-11 to B2-14, and gives each a
lambda that announces its seat. All four announce “no seat at index 4”. The lambdas are right; what they captured is
not.
You will write lambdas in their usual forms, see which delegate type the compiler gives each one, and predict exactly what a closure captures, in loops and outside them.
A lambda is a method without a name, written where it is used
A lambda expression: An unnamed method written where a delegate is expected, such as x => x * 2: parameters, the => operator and a body that is one expression or a block of statements. is parameters, the => operator and a body. forms.cs writes
the common shapes and asks the compiler which delegate type each one became:
// Lambdas in their common forms. Each one becomes a delegate; with var, the compiler chooses the delegate type.
using System.Globalization;
Func<int, int> square = x => x * x; // an expression lambda: its body is one expression
Action<string> greet = name => // a statement lambda: a block of statements
{
string text = $"Namaste, {name}";
Console.WriteLine(text);
};
Func<int, int, bool> divides = (d, n) => n % d == 0; // two parameters need parentheses
Func<string> today = () => "Saturday"; // no parameters: empty parentheses
Console.WriteLine($"square(12) = {square(12)}");
greet("Asha");
Console.WriteLine($"3 divides 12: {divides(3, 12)}, today: {today()}");
// A natural type (C# 10): with var and typed parameters, the compiler infers Func<...> or Action<...>.
var parse = (string s) => int.Parse(s, CultureInfo.InvariantCulture);
var shout = (string s) => Console.WriteLine(s.ToUpperInvariant());
Console.WriteLine($"parse is a {Name(parse.GetType())}; shout is an {Name(shout.GetType())}");
shout("platform 4");
// An explicit return type, for branches that have no type in common.
var status = object (bool paid) => paid ? 1 : "pending";
Console.WriteLine($"status is a {Name(status.GetType())}: {status(true)}, {status(false)}");
// A method group with exactly one overload has a natural type as well.
var isBlank = string.IsNullOrWhiteSpace;
Console.WriteLine($"isBlank is a {Name(isBlank.GetType())}: {isBlank(" ")}");
// The type names as C# writes them, for printing.
static string Name(Type type)
{
string name = type.Name switch
{
"Int32" => "int",
"String" => "string",
"Boolean" => "bool",
"Object" => "object",
_ => type.Name,
};
if (!type.IsGenericType)
{
return name;
}
string bare = name[..name.IndexOf('`')];
return $"{bare}<{string.Join(", ", type.GetGenericArguments().Select(Name))}>";
} Output
square(12) = 144 Namaste, Asha 3 divides 12: True, today: Saturday parse is a Func<string, int>; shout is an Action<string> PLATFORM 4 status is a Func<bool, object>: 1, pending isBlank is a Func<string, bool>: True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run forms.cs
x => x * x has a single expression as its body, and that expression is the result. The greet lambda has a block of
statements in braces, like a method body. One parameter needs no parentheses; none, or two and more, do. A lambda
converts to any delegate type its shape fits, Func<int, int> for square and Action<string> for greet.
With var there is no delegate type to fit, so since C# 10 the compiler infers one, the lambda’s
natural type: The delegate type the compiler infers for a lambda or a method group when nothing else gives one, as with var: a Func or an Action when one fits, otherwise a delegate type the compiler writes.: Func<string, int> for parse, Action<string> for shout. It picks
Func or Action when one fits and otherwise writes a delegate type of its own. An explicit return type,
object (bool paid) => …, settles branches that share no type, and a method group with one overload gets a natural
type too. A lambda whose parameters have no types gives nothing to infer from:
// A lambda with untyped parameters, assigned to var, gives the compiler nothing to infer a delegate type from.
var parse = s => int.Parse(s);
Console.WriteLine(parse("42")); from session import run
# nonatural assigns a lambda whose parameter has no type to var. dotnet run builds the project first and stops at the
# error.
run("""
cd nonatural
!dotnet run
""") Output
$ cd nonatural $ dotnet run nonatural/Program.cs(2,13): error CS8917: The delegate type could not be inferred. [nonatural/App.csproj] The build failed. Fix the build errors and run again. [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 nonatural_session.py
In short, what var gives each form:
| You write | var gives it |
|---|---|
(string s) => int.Parse(s) |
Func<string, int> |
(string s) => Console.WriteLine(s) |
Action<string> |
object (bool paid) => paid ? 1 : "pending" |
Func<bool, object> |
s => int.Parse(s) |
nothing: error CS8917 |
Lambdas take more than this. Parameters you ignore can be discards, (_, _) => 42. Since C# 12 a lambda can declare
default values and params, and since C# 14 a modifier such as out needs no type. A static lambda refuses to
capture anything at all; an API can still hand it the data it needs as an argument.
Checkpoint
Check: Lambda forms and natural types
A closure captures the variable, not its value
A lambda may use the local variables and parameters of the method around it. Together with the variables it uses,
it is a closure: A lambda or local function together with the outer variables it uses. It keeps those variables, not copies of their values, so it sees later changes and keeps the variables alive as long as the delegate., and closures keep the variables themselves, not copies. capture.cs shows
what that means:
// A lambda that uses a local variable of the method around it captures the variable itself, not a copy of its value.
int limit = 5;
Func<int, bool> isLow = units => units < limit;
Console.WriteLine($"limit {limit}: 4 units low? {isLow(4)}");
limit = 3; // the lambda reads the variable when it runs, so it sees the change
Console.WriteLine($"limit {limit}: 4 units low? {isLow(4)}");
// A lambda can change a captured variable, and the method sees the change.
int alerts = 0;
Action<string> raise = sku =>
{
alerts++;
Console.WriteLine($" alert {alerts}: {sku} is low");
};
raise("TEA-250");
raise("RICE-5");
Console.WriteLine($"alerts after two calls: {alerts}");
// Each call of MakeCounter makes a new variable, so each counter has its own.
Func<int> first = MakeCounter();
Func<int> second = MakeCounter();
Console.WriteLine($"first: {first()}, {first()}, {first()}; second: {second()}");
static Func<int> MakeCounter()
{
int count = 0;
return () => ++count;
} Output
limit 5: 4 units low? True limit 3: 4 units low? False alert 1: TEA-250 is low alert 2: RICE-5 is low alerts after two calls: 2 first: 1, 2, 3; second: 1
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run capture.cs
isLow read limit when it ran, not when it was made, so after limit = 3 it said False. raise changed alerts,
and the program saw 2. Each call of MakeCounter made a new count, so the counters counted apart. Step through the
first part:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The variable. int limit = 5 declares a local variable of the program. Because a lambda below uses it, the compiler keeps it in a place that both can reach.
- The lambda is made. isLow = units => units < limit makes a delegate. It does not copy 5: it refers to the variable limit itself.
- The first call reads 5. isLow(4) runs the body now, reads limit, finds 5, and 4 < 5 is True: the first line of the output.
- The program changes limit. limit = 3 writes the same variable. Nothing tells the lambda, and nothing needs to: it has no copy to update.
- The same call reads 3. isLow(4) again reads limit when it runs and finds 3, so 4 < 3 is False: the second line. A closure captures the variable, not the value it had.
A captured variable: A local variable, parameter or this of the code around a lambda that the lambda uses. The method and the lambda share the one variable, so a change by either is seen by both. is shared by the method and every lambda that uses it, and
that breaks the seat buttons. A for loop declares its variable once, in its initializer, so every lambda made in
the loop captures the same i, which holds the end value when the lambdas run. A foreach loop declares its variable
inside the loop, a new one for each pass, a placement the C# specification says decides how lambdas capture it.
Before C# 5, foreach shared one variable too.
// Lambdas made inside loops. A for loop has one variable for all its passes; foreach has a new one in every pass.
var fromFor = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
fromFor.Add(() => i);
}
Console.WriteLine($"for: {string.Join(" ", fromFor.Select(f => f()))}");
var fromForeach = new List<Func<int>>();
foreach (int n in new[] { 0, 1, 2 })
{
fromForeach.Add(() => n);
}
Console.WriteLine($"foreach: {string.Join(" ", fromForeach.Select(f => f()))}");
// The fix for the for loop: copy the variable into a local declared inside the loop body.
var fixedFor = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
int copy = i;
fixedFor.Add(() => copy);
}
Console.WriteLine($"for with copy: {string.Join(" ", fixedFor.Select(f => f()))}");
// The same bug with seats: every lambda reads the one variable, which ended at 4 after the loop.
string[] seats = ["B2-11", "B2-12", "B2-13", "B2-14"];
var announce = new List<Func<string>>();
for (int s = 0; s < seats.Length; s++)
{
announce.Add(() => s < seats.Length ? seats[s] : $"no seat at index {s}");
}
Console.WriteLine($"seats from for: {string.Join(", ", announce.Select(a => a()))}"); Output
for: 3 3 3 foreach: 0 1 2 for with copy: 0 1 2 seats from for: no seat at index 4, no seat at index 4, no seat at index 4, no seat at index 4
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run loops.cs
Capturing a for loop's variable
for (int s = 0; s < seats.Length; s++) announce.Add(() => seats[s]); gives every lambda the one s, which is 4
after the loop, so each would read past the end of the array. Declare int seat = s; inside the loop and capture
seat, or loop with foreach.
The seat lambdas of loops.cs guard the index, so they said “no seat at index 4” four times instead of throwing.
The three loops of the program, side by side:
| Loop | Variables the loop makes | What three lambdas return |
|---|---|---|
for (int i = 0; i < 3; i++) |
one i, 3 when the loop ends |
3 3 3 |
foreach (int n in …) |
one n per pass |
0 1 2 |
for with int copy = i; inside |
one copy per pass |
0 1 2 |
Each closure is also an object the compiler makes for you, so capturing costs memory, and the captured variables live as long as the delegate.
Key idea
Closures capture variables, not values, and read them when they run: a for loop’s single variable gives every
lambda its last value, while foreach and a copy declared in the loop body give each pass its own.
Checkpoint
Check: What a closure captures, in loops and outside them
Inside a closure: the class the compiler writes, what it costs and what it keeps alive Pro: opens with a Pro, Premium or Ultimate pass
A closure is an object that the compiler writes for you. It costs memory when it is made, and it holds everything it captured for as long as a delegate refers to it. The runs below measure both.
In this part:
- The class that holds captured variables, read back by reflection
- A kept lambda keeps its whole closure alive, reproduced
1 diagram · 2 runnable examples (C#) · about 6 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
static lambdas, state arguments and the lambda features of C# 9 to C# 14 Pro: opens with a Pro, Premium or Ultimate pass
A static lambda promises to capture nothing, and the compiler holds it to that promise. With an API that hands the lambda the value it needs, the promise costs nothing, and the measured saving is 88 bytes a call.
In this part:
- static turns an accidental capture into a build error
- Passing state instead of capturing it, measured
- Discards, natural types, defaults, params and modifiers, release by release
- A default value belongs to the delegate type, not to the lambda
1 diagram · 6 runnable examples (C#, Python) · about 7 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise: counters and a memo
counters.cs checks three helpers built on closures: a counter, a counter that starts where it is told, and
Memoize, which remembers each result of a slow fare lookup. All eight checks fail on the starter:
// Practice: write Counters.Start, Counters.From and Memo.Memoize, then run this file until all eight checks pass.
// What each method must do is in the comment above it. The fares are made up for this exercise.
(string Check, Func<string> Run, string Expected)[] checks =
[
("a counter counts from 1", () => Calls(Counters.Start(), 3), "1 2 3"),
("two counters are independent", TwoCounters, "1 2 3 | 1 2"),
("From starts where it is told", () => Calls(Counters.From(10), 3), "10 11 12"),
("a counter keeps counting between uses", KeepsCounting, "1 2, then 3"),
("Memoize returns the function's results", () => MemoFares(["Pune", "Nashik", "Pune"]).Results, "350 220 350"),
("Memoize calls the function once per input", () => MemoFares(["Pune", "Nashik", "Pune", "Pune"]).Lookups, "2 lookups"),
("two memoized functions keep separate results", SeparateMemos, "350 and 700"),
("a failed call is not remembered", FailureNotRemembered, "throws, then 220"),
];
int passed = 0;
foreach (var (check, run, expected) in checks)
{
string got;
try
{
got = run();
}
catch (Exception e)
{
got = $"throws {e.GetType().Name}";
}
bool ok = got == expected;
if (ok)
{
passed++;
}
Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}
Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;
static string Calls(Func<int> counter, int times) =>
string.Join(" ", Enumerable.Range(0, times).Select(_ => counter()));
static string TwoCounters()
{
Func<int> a = Counters.Start();
Func<int> b = Counters.Start();
string first = Calls(a, 3);
return $"{first} | {Calls(b, 2)}";
}
static string KeepsCounting()
{
Func<int> c = Counters.Start();
string first = Calls(c, 2);
return $"{first}, then {c()}";
}
// A slow fare lookup that counts how often it really runs.
static (string Results, string Lookups) MemoFares(string[] cities)
{
int lookups = 0;
Func<string, int> fare = city =>
{
lookups++;
return city == "Pune" ? 350 : 220;
};
Func<string, int> fast = Memo.Memoize(fare);
string results = string.Join(" ", cities.Select(c => fast(c)));
return (results, $"{lookups} lookups");
}
static string SeparateMemos()
{
Func<string, int> sleeper = Memo.Memoize<string, int>(_ => 350);
Func<string, int> ac = Memo.Memoize<string, int>(_ => 700);
return $"{sleeper("Pune")} and {ac("Pune")}";
}
static string FailureNotRemembered()
{
bool down = true;
Func<string, int> fast = Memo.Memoize<string, int>(city => down ? throw new TimeoutException("fares service down") : 220);
string first;
try
{
first = fast("Nashik").ToString();
}
catch (TimeoutException)
{
first = "throws";
}
down = false;
return $"{first}, then {fast("Nashik")}";
}
static class Counters
{
// Returns a counter: each call returns the next number, 1, 2, 3 and so on. Every counter has its own count.
public static Func<int> Start() => throw new NotImplementedException();
// Returns a counter whose first call returns first, then first + 1, and so on.
public static Func<int> From(int first) => throw new NotImplementedException();
}
static class Memo
{
// Returns a function that gives the same results as f, but calls f only once for each input it has seen, and
// remembers the result in a Dictionary<TIn, TOut> that only this function uses. When f throws, nothing is
// remembered, so the next call with that input calls f again.
public static Func<TIn, TOut> Memoize<TIn, TOut>(Func<TIn, TOut> f)
where TIn : notnull => throw new NotImplementedException();
} Output (exit status 1)
a counter counts from 1: FAIL, throws NotImplementedException instead of 1 2 3 two counters are independent: FAIL, throws NotImplementedException instead of 1 2 3 | 1 2 From starts where it is told: FAIL, throws NotImplementedException instead of 10 11 12 a counter keeps counting between uses: FAIL, throws NotImplementedException instead of 1 2, then 3 Memoize returns the function's results: FAIL, throws NotImplementedException instead of 350 220 350 Memoize calls the function once per input: FAIL, throws NotImplementedException instead of 2 lookups two memoized functions keep separate results: FAIL, throws NotImplementedException instead of 350 and 700 a failed call is not remembered: FAIL, throws NotImplementedException instead of throws, then 220 0 of 8 checks pass
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run counters.cs
Write the counters and Memoize
Copy counters.cs. In From, declare a local that starts at first and return a lambda that returns it and adds
one; Start is From(1). In Memoize, make a Dictionary<TIn, TOut> and return a lambda that looks the input up,
calls f only when it is missing and stores the result after f returns. Run dotnet run counters.cs until it
prints 8 of 8 checks pass.
Then check yourself on lambda forms and captures:
Practice that adapts to you
Practise this lesson
6 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a closure in C#?
A closure is a lambda or a local function together with the outer variables it uses: locals and parameters of the
method around it, or this. It holds on to the variables, not to snapshots of what they held: it sees later changes,
its own changes are seen by the method, and the variables live as long as the delegate does. In a for loop
every lambda shares the one loop variable and sees its last value; foreach gives each pass a new variable. To fix
a value at the moment the lambda is made, copy it into a local declared where the lambda is made.
-
Mid to senior
A dashboard keeps a small Func<string> title callback for every report it has shown. Memory grows by about 8 MB per report, although the callbacks only return titles, and a memory snapshot shows the report arrays reachable from the callbacks. Explain how, and fix it.
-
Mid to senior
A team proposes a rule: every lambda on a hot path must be static. What does static prevent and what does it not, how do you pass a static lambda the data it needs, and would you adopt the rule?
Key takeaways
- A lambda is parameters,
=>and a body that is one expression or a block; withvarit gets a natural type,FuncorActionwhen one fits, and untyped parameters give it none. - A closure captures variables, not values: a lambda reads them when it runs and can change them for the method too.
- A
forloop has one variable for all its passes, so its lambdas share the last value;foreachand a copy declared in the loop body give each pass its own.
Terms in this lesson
- captured variable
- A local variable, parameter or this of the code around a lambda that the lambda uses. The method and the lambda share the one variable, so a change by either is seen by both.
- closure
- A lambda or local function together with the outer variables it uses. It keeps those variables, not copies of their values, so it sees later changes and keeps the variables alive as long as the delegate.
- lambda expression
- An unnamed method written where a delegate is expected, such as x => x * 2: parameters, the => operator and a body that is one expression or a block of statements.
- natural type
- The delegate type the compiler infers for a lambda or a method group when nothing else gives one, as with var: a Func or an Action when one fits, otherwise a delegate type the compiler writes.
References
- Lambda expressions and anonymous functions (Microsoft Learn)
- C# language specification: the foreach statement (Microsoft Learn)
- C# language specification: the for statement (Microsoft Learn)
- Visual C# breaking changes in Visual Studio 2012 (Microsoft Learn)
- The history of C# (Microsoft Learn)
- What's new in C# 14: simple lambda parameters with modifiers (Microsoft Learn)
- Local functions: heap allocations (Microsoft Learn)
- ConcurrentDictionary<TKey,TValue>.GetOrAdd method (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
- WeakReference class (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress