Closures and Captured Variables

Vaibhav • September 11, 2025

In the previous article, we explored higher-order functions - functions that accept or return other functions. These are a cornerstone of functional programming and are made even more powerful by a concept called closures. In C#, closures allow functions to “remember” variables from the scope in which they were created, even after that scope has exited. This behavior is subtle but incredibly useful for writing expressive, stateful, and modular code.

In this article, we’ll explore what closures are, how captured variables behave in C#, and how closures interact with delegates and lambdas. We’ll walk through practical examples, explain how closures are implemented under the hood, and highlight best practices for using them effectively - especially in asynchronous and event-driven scenarios.

What Is a Closure?

A closure is a function that captures variables from its surrounding scope. When you create a lambda or anonymous method inside another method, and that lambda uses local variables from the outer method, those variables are “captured” - meaning they stay alive as long as the lambda does.

This allows the lambda to maintain state across invocations, even though the original method has finished executing. Let’s look at a simple example:

Func<int> MakeCounter()
{
    int count = 0;
    return () =>
    {
        count++;
        return count;
    };
}

var counter = MakeCounter();
Console.WriteLine(counter()); // Output: 1
Console.WriteLine(counter()); // Output: 2

The lambda returned by MakeCounter captures the count variable. Even though count is declared inside MakeCounter, it remains alive and mutable inside the lambda. Each time counter() is called, it increments and returns the updated value.

How Captured Variables Work

When a lambda captures a variable, the compiler generates a hidden class to hold that variable. This class is known as a closure class. The lambda becomes a method on that class, and the captured variables become fields.

This means that captured variables behave like instance fields - they persist as long as the delegate referencing the lambda is alive. They are not copied or cloned - they are shared.

Captured variables are shared across all lambdas that capture them. If multiple lambdas capture the same variable, they all see the same value - and can modify it.

Multiple Lambdas Sharing a Variable

Let’s explore what happens when multiple lambdas capture the same variable. This can lead to interesting - and sometimes surprising - behavior.

Action[] actions = new Action[3];
int shared = 0;

for (int i = 0; i < 3; i++)
{
    actions[i] = () =>
    {
        shared += i;
        Console.WriteLine($"shared = {shared}");
    };
}

foreach (var action in actions)
{
    action();
}

You might expect each lambda to use a different value of i, but in older versions of C#, all lambdas captured the same i - leading to unexpected results. In modern C#, the compiler creates a new variable for each loop iteration, so each lambda captures a distinct value.

However, the variable shared is captured by all lambdas - and they all modify the same instance. This demonstrates how closures can share state across multiple functions.

Closures in Event Handlers

Closures are especially useful in event-driven programming. You can use them to customize behavior based on context - without creating separate classes or fields.

void RegisterButton(string label)
{
    Button button = new Button();
    button.Click += (sender, e) =>
    {
        Console.WriteLine($"Button {label} clicked");
    };
}

The lambda captures the label variable. Each button gets its own handler with its own label - even though the handler is defined inline. This makes your code more modular and expressive.

Closures and Asynchronous Code

Closures are also useful in asynchronous programming - but they can introduce subtle bugs if you’re not careful. For example:

for (int i = 0; i < 3; i++)
{
    Task.Run(() => Console.WriteLine(i));
}

In older versions of C#, all lambdas captured the same i, so all tasks printed the same value. In modern C#, the compiler creates a new variable for each iteration - fixing the issue.

Still, be cautious when capturing variables in asynchronous code. If the variable changes before the lambda runs, you may get unexpected results. To avoid this, copy the variable to a local before capturing:

for (int i = 0; i < 3; i++)
{
    int copy = i;
    Task.Run(() => Console.WriteLine(copy));
}

This ensures that each lambda captures a stable value - avoiding race conditions and timing issues.

Closures for State Management

You can use closures to manage state without creating classes. This is useful for small utilities, counters, throttlers, and other logic that needs to retain state across calls.

Func<bool> Toggle()
{
    bool state = false;
    return () =>
    {
        state = !state;
        return state;
    };
}

var toggle = Toggle();
Console.WriteLine(toggle()); // Output: True
Console.WriteLine(toggle()); // Output: False

The lambda captures the state variable and toggles it each time. This pattern is simple, elegant, and avoids the need for a separate class or field.

Closures vs Static Methods

Static methods cannot capture variables - they don’t have access to instance or local scope. If you need to retain state or customize behavior, use lambdas or instance methods.

For example, this won’t compile:

static Func<int> MakeCounter()
{
    int count = 0; // ❌ Cannot capture in static method
    return () => count++;
}

To use closures, you need a non-static method - or pass the captured variables explicitly.

Closures and Memory Management

Because closures retain captured variables, they can keep objects alive longer than expected. If a lambda captures a reference to a large object, that object won’t be garbage collected until the lambda is released.

This can lead to memory leaks - especially in event handlers or long-lived delegates. To avoid this:

  • Unsubscribe from events when no longer needed
  • Avoid capturing large objects unless necessary
  • Use weak references or cleanup logic if needed

Note: Closures are powerful - but they come with responsibility. Be mindful of what you capture, and how long the lambda lives.

Summary

Closures allow lambdas and anonymous methods to retain access to variables from their outer scope - even after that scope has exited. This enables powerful patterns for state management, customization, and modular design. In this article, you learned how closures work in C#, how captured variables behave, and how to use closures in loops, events, and asynchronous code.

You also explored how closures are implemented under the hood, how they interact with memory management, and how to avoid common pitfalls. Closures are a subtle but essential feature of C# - and mastering them will help you write cleaner, more expressive code.

In the next article, we’ll explore Predicate Delegates - a special kind of delegate used for filtering and decision-making, and how they fit into the functional programming toolbox.