This post is about the concept of closure. If you’re already familiar with it or fluent in JavaScript, you might not find much new here—except for a quick refresher. However, if closure is a new concept to you, this will serve as a gentle, step-by-step introduction.
Garbage Collection
In traditional compiled OOP languages like C#, variable scope rarely causes confusion1. For the most part, we can confidently determine when a variable goes out of scope and becomes inaccessible.
One of the advantages of garbage-collected languages is that they reduce the cognitive burden of tracking whether something is “temporary.” This convenience however, also blurs the definition of “temporariness”.
For example:
- An object created within a function call may be temporary and removed from memory once the function returns.
- But if you add the object to a collection or assign it to a field before returning, it suddenly persists, potentially outliving the call frame in which it was created.
Some argue that this introduces opacity, making it harder to predict a variable’s lifetime. While that’s a valid concern, C# developers can generally rely on a simple assumption:
“Everything I still have use for will stay in memory.”
Or more succinctly:
“I need it, it lives.”
What we’ll explore in this post is a common source of confusion—a situation where it’s not immediately clear whether this philosophy applies.
A Slightly Puzzling Situation
Assume that you are defining a function that creates and returns a new function. There are plenty of practical reasons to do this, but let’s start with a hyper-simplistic example:
using System;
class Program
{
static Action A()
{
Console.WriteLine("A is called");
void B()
{
Console.WriteLine("Hello!");
}
return B;
}
static void Main()
{
Action b = A();
// Output: A is called
b();
// Output: Hello!
}
}
Here’s what happens:-
- While
A()
is running, it defines a new function,B()
. B()
is returned and assigned to a variable inMain()
.- When
b()
is called, it executesB()
, printing"Hello!"
.
According to the “I need it, it lives” philosophy, you don’t need to worry about B()
being removed from memory when A()
returns. Since B()
is assigned to b
, the C# runtime keeps it alive as long as b
is still in use.2
Now let’s add in a twist to make it more interesting: what if, inside B
(), we make a reference to another variable created within A
()?
using System;
class Program
{
static Action A()
{
int number = 42; // Define an integer
void B()
{
Console.WriteLine($"Hello! The number is {number}");
}
return B;
}
static void Main()
{
Action b = A();
b();
// Output: Hello! The number is 42
}
}
If this example isn’t making you slightly uncomfortable, then congrats, you weren’t traumatized by all the Segmentation Fault (core dumped)
like many of us were.
Jokes aside though, it’s actually quite fascinating that during a b()
call in Main()
, b
still “knows” what you mean by number
, because it really looks like we have reasons to believe that GC could wipe number
off of the memory when A()
finishes executing:
number
is not added to any collection outside the definition ofA()
.number
is not assigned to another variable outside the definition ofA()
.number
isn’t the return value ofA()
.- If you attempt to do a
Console.WriteLine($"number is: {number}");
at line 20, then you’ll get a compiler error, because apparentlynumber
doesn’t exist in this context, and note thatMain()
is also the only living frame on the stack, which means there’s seemingly no stack on whichnumber
can live.
However, remember our philosophy?
“I need it, it lives“. Since number
is referred to by the function definition of B()
, and B()
is returned as an Action
from A()
and assigned to b
, as long as we may still call b()
, we will still need number
, hence it still lives.
But what if…?
So, number
is needed by b
, which means it stays alive as long as b
does. But that explanation probably didn’t feel very satisfying—if anything, it raised more questions than it answered:
- What if
A()
is called twice, with the return values assigned to different variables? Do they share the samenumber
? - What if
b()
is called multiple times? Does it always use the samenumber
, or does it create a new one each time? - What if there’s another function,
C()
, insideA()
that also accessesnumber
? Does it share the samenumber
asB()
, or do they each get their own copy?
Let’s cram all of these questions into a single experiment and see what happens:
using System;
class Program
{
static (Action, Action) A()
{
int number = 42; // Define an integer
void B()
{
number++;
Console.WriteLine($"B executed. Number: {number}");
}
void C()
{
number++;
Console.WriteLine($"C executed. Number: {number}");
}
return (B, C);
}
static void Main()
{
// First time calling A()
(Action b, Action c) = A();
b();
b(); // Call b() for the second time
c();
Console.WriteLine("------");
// Second time calling A()
(Action b2, Action c2) = A();
b2();
c2();
}
}
Take a moment to guess the output before comparing it to the actual results:
B executed. Number: 43
B executed. Number: 44
C executed. Number: 45
------
B executed. Number: 43
C executed. Number: 44
This experiment answers all three questions:
- Each call to
A()
creates a newnumber
, stored separately in memory. - A function returned from
A()
keeps using the samenumber
across multiple calls. - Multiple functions (
B
andC
) returned from the sameA()
call share the samenumber
.
These results aren’t the most intuitive, which is why they’re tricky to memorize. And while our little experiment shows what happens, it doesn’t really explain why.
To understand that, we need to take a closer look at closures in C#.
Closures
A closure is a function that captures variables from its surrounding scope, even after that scope has exited. The key insight is that these captured variables are not merely copies of values—they are references that persist beyond the function’s execution.
In C#, closures arise when an inner function references a variable from an enclosing function. Instead of letting that variable disappear when the outer function returns, the compiler ensures that it remains accessible to the returned function.
Let’s look again at our previous example:
static (Action, Action) A()
{
int number = 42; // Captured variable
void B()
{
number++;
Console.WriteLine($"B executed. Number: {number}");
}
void C()
{
number++;
Console.WriteLine($"C executed. Number: {number}");
}
return (B, C);
}
Here, number
is captured by both B
and C
. Instead of being stored on the stack (which disappears when A()
returns), number
is moved to the heap, ensuring its longevity.
Behind the Scenes: Compiler Transformations
The C# compiler handles closures by internally transforming the code into something similar to this:
class Closure
{
public int number = 42;
public void B() { number++; Console.WriteLine($"B executed. Number: {number}"); }
public void C() { number++; Console.WriteLine($"C executed. Number: {number}"); }
}
static (Action, Action) A()
{
var closure = new Closure();
return (closure.B, closure.C);
}
The key transformation here is that number
is no longer a local variable; it is now a field in a separate object (Closure
). Since closure
is being returned from A, it persists beyond the execution of A()
. When B()
or C()
are called, they operate on this shared instance.
Key Takeaways
- Variables captured by closures are moved to the heap: This allows them to outlive their defining function’s stack frame.
- Each call to the enclosing function creates a new closure instance: That’s why calling
A()
twice results in separatenumber
values. - Functions from the same call share state:
B()
andC()
both modify the samenumber
when obtained from the sameA()
call.
Practical Use Cases
Closures are more than just an academic curiosity—they are powerful tools in real-world programming. Here are some scenarios where closures shine:
1. Encapsulation and Data Hiding
Closures allow you to maintain private state without needing a separate class:
Func<int> Counter()
{
int count = 0;
return () => ++count;
}
var counter = Counter();
Console.WriteLine(counter()); // Output: 1
Console.WriteLine(counter()); // Output: 2
Each call to Counter()
returns a function that retains access to its own count
variable.
2. Deferred Evaluation
Closures can help generate lazy sequences, much like Haskell’s infinite lists:
static void Main()
{
var lazyNumbers = LazyRange(1);
var current = lazyNumbers;
for (int i = 0; i < 10; i++)
{
Console.WriteLine(current.Value);
current = current.Next();
}
}
class LazyList<T>
{
public T Value { get; }
public Func<LazyList<T>> Next { get; }
public LazyList(T value, Func<LazyList<T>> next)
{
Value = value;
Next = next;
}
}
static LazyList<int> LazyRange(int start)
{
return new LazyList<int>(start, () => LazyRange(start + 1));
}
This generates an infinite sequence of numbers, computing values only when requested, saving memory and time compared to eager evaluation.
I’ll admit that this example may be a bit convoluted3.
3. Callbacks and Event Handling
Closures allow for flexible event-driven programming:
void SetupButtonClick(Button button, string message)
{
button.Click += (sender, e) => Console.WriteLine(message);
}
Wrapping It Up
Closures might seem like a tricky concept at first, but once you grasp how they work, they become an incredibly useful tool. Whether you’re encapsulating state, deferring execution, or handling events, closures give you an elegant way to write cleaner, more efficient code. Now that you understand them, you’ll start noticing them everywhere in C#—and hopefully, using them to your advantage!