C# Lambda Closure: Variable Capture Explained
c# lambda closure: Understand how C# lambda closures capture variables, the lifetime of captured state, and common pitfalls like loop variable capture.
c# lambda closure requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
When a C# lambda expression references a local variable from its enclosing method, the compiler generates a closure that captures that variable. This behavior is central to how lambdas work, but it also introduces subtle pitfalls that can surprise developers. The captured variable is not a copy; it is the same storage location, which means changes made after the lambda is created are visible inside the lambda when it executes.
How Variable Capture Works in C#
Consider this minimal example:
int factor = 2; Func<int, int> multiplier = x => x * factor; factor = 10; Console.WriteLine(multiplier(5)); // 50
The lambda captures factor by reference, not by value. When factor changes to 10, the lambda sees the updated value. The compiler implements this by hoisting the captured variable into a compiler-generated class, and the lambda becomes a method on that class. Both the enclosing method and the lambda share the same field.
This is different from a local variable that is not captured. The lifetime of a captured variable is extended to match the lifetime of the delegate. Even after the method returns, the variable remains alive as long as the delegate is referenced.
The Classic Loop Variable Pitfall
The most common mistake with C# lambda closures involves capturing a loop variable. In older C# versions (before C# 5), the loop variable in a for loop was shared across all iterations. Consider:
List<Action> actions = new List<Action>(); for (int i = 0; i < 3; i++) { actions.Add(() => Console.WriteLine(i)); } foreach (var action in actions) { action(); }
In C# 4 and earlier, this prints 3 3 3 because each lambda captures the same i variable, which ends up with the value 3 after the loop. In C# 5 and later, the loop variable is scoped to each iteration, so the output is 0 1 2. The change was made to the for loop, but foreach already had per-iteration scoping.
If you are working with older C# or need to be explicit, create a local copy inside the loop:
for (int i = 0; i < 3; i++) { int current = i; actions.Add(() => Console.WriteLine(current)); }
This ensures each lambda captures a distinct variable.
Captured Variables and Lifetime
Because a closure keeps captured variables alive, the delegate can outlive the method that created it. This is useful for callbacks and event handlers, but it also means you must be careful about memory usage. If a long-lived delegate captures a large object, that object cannot be garbage collected until the delegate is released.
For example, an event handler that captures a heavy service instance will keep that service alive as long as the event is subscribed. If you do not unsubscribe, you may leak memory. The same applies to static events or caches that store delegates.
Closures in Async and Event Handlers
Closures are especially common in async code and event handlers. When you write an async lambda, the captured variables are used after the await continues. The compiler generates a state machine, and the captured variables become fields of that state machine. This works correctly, but you should be aware of the timing:
int id = 42; Task.Run(() => Process(id)); id = 100; // The lambda may see either 42 or 100 depending on scheduling
If you need to capture the value at the time the lambda is created, copy it to a local variable that is never changed afterward. This is a common pattern in loops and async scenarios.
Memory and Performance Considerations
Creating a closure has a cost: the compiler allocates an object to hold the captured variables. In performance-sensitive code, this allocation may matter. For a one-off delegate, the overhead is negligible, but if you create thousands of closures per second, it can add pressure on the garbage collector.
There is also a subtle performance issue with captured variables in hot paths. Accessing a captured variable goes through a field on the closure object, which may be slightly slower than accessing a local variable directly. In practice, the difference is small, but it is worth keeping in mind when optimizing tight loops.
If you do not need to capture any variables, a static lambda or a method reference avoids the closure allocation entirely. For example, x => x * 2 does not capture anything and can be cached as a static field.
When to Avoid Closures
Closures are a powerful tool, but they are not always the right choice. If you need to pass data to a delegate without extending its lifetime, consider passing the data as a parameter instead. This is especially relevant when the delegate is stored and reused.
For example, instead of capturing a DbContext in a long-lived delegate, pass it as an argument to the method the delegate calls. This makes the dependency explicit and avoids keeping the context alive longer than necessary. It also makes the code easier to test and reason about.
Another case is when you need to compare delegates for equality. Two lambdas that capture the same variable are not equal, even if they produce the same behavior. If you need to identify a delegate, avoid closures or use a named method.
Capturing Mutable State and Side Effects
Closures capture variables, not values. If the captured variable is a mutable object, the lambda can mutate that object, and the mutation is visible outside the lambda. This can lead to unexpected side effects if the lambda is invoked later:
List<int> items = new List<int>(); Action addItem = () => items.Add(1); addItem(); Console.WriteLine(items.Count); // 1
This is often intentional, but it can make code harder to follow. When a closure captures a mutable collection or object, the delegate becomes a way to modify state from a different context. Use this pattern deliberately, and document the side effects.
Closure and the using Statement
A common mistake is using a captured variable inside a using block and then invoking the delegate after the block has disposed the resource. The lambda captures the variable, but the resource is disposed when the using block exits. If the lambda runs later, it may access a disposed object.
IDisposable resource = GetResource(); Action action = () => resource.DoWork(); using (resource) { // do something } action(); // may throw ObjectDisposedException
To avoid this, copy the resource to a local variable that is not disposed, or restructure the code so the delegate is invoked before disposal. This is a real production issue, especially with IDbConnection, Stream, or CancellationTokenSource.
Closure in LINQ Queries
LINQ methods like Where, Select, and OrderBy often take lambdas that capture variables from the surrounding scope. This is convenient, but it can lead to unexpected behavior if the captured variable changes between the creation of the query and its execution. LINQ queries are lazy; they execute when you enumerate them, not when you define them.
int min = 10; var query = items.Where(x => x > min); min = 20; var result = query.ToList(); // uses min = 20
If you need the query to use the value at definition time, copy the variable to a local that is not modified afterward. This is a common source of bugs in data filtering and reporting code.
Final Technical Consideration: Delegate Equality and Closure Identity
Two delegates created from the same lambda expression are not equal unless they capture the same target and the same method. When a lambda captures variables, the compiler generates a closure object, and each closure instance is distinct. This means action1 == action2 is false even if the code is identical. This matters when you use delegates as dictionary keys or in caching logic.
If you need to deduplicate delegates, consider using a static method or a method group instead of a lambda. Method groups that refer to the same method on the same target are equal. Closures break that identity, which can be surprising in production code that relies on delegate equality.