Closures and higher-order functions
Understand how lambdas capture variables, and build function factories and caches.
11 min read
You will learn
- Explain what a closure captures
- Write functions that create and return functions
- Use captured state deliberately (counters, caches)
- Avoid the classic captured-variable pitfalls
Lambdas remember their surroundings
A lambda can use variables from the method around it:
int threshold = 10;
Func<int, bool> isBig = n => n > threshold; // uses 'threshold' from outside
Console.WriteLine(isBig(15)); // TrueA lambda together with the variables it uses is called a closure — it "closes over" those variables.
Variables, not values
The crucial detail: a closure captures the variable itself, not a snapshot of its value.
int threshold = 10;
Func<int, bool> isBig = n => n > threshold;
threshold = 100;
Console.WriteLine(isBig(15)); // False — the lambda sees the updated thresholdAnd a lambda can modify a captured variable:
int clicks = 0;
Action click = () => clicks++;
click(); click(); click();
Console.WriteLine(clicks); // 3Captured variables outlive their method
Normally a local variable disappears when its method returns. But if a lambda that captures it escapes the method, the variable lives on with the lambda:
Func<int> MakeCounter()
{
int count = 0; // a fresh variable for every call to MakeCounter
return () => ++count; // the returned lambda keeps 'count' alive
}
var a = MakeCounter();
var b = MakeCounter();
Console.WriteLine(a()); // 1
Console.WriteLine(a()); // 2
Console.WriteLine(b()); // 1 — b has its own countThe compiler makes this work by moving captured variables into a hidden object that the delegate references.
Higher-order functions
Functions that take or return functions are called higher-order functions. They let you build behavior from smaller pieces:
Func<double, double> Multiplier(double factor) => x => x * factor;
var double_ = Multiplier(2);
var triple = Multiplier(3);
Console.WriteLine(triple(double_(5))); // 30
Func<T, T> Twice<T>(Func<T, T> f) => x => f(f(x));
Console.WriteLine(Twice<int>(n => n + 10)(1)); // 21A practical closure: caching
A closure can keep private state that nobody else can touch — perfect for a cache:
Func<int, long> MemoizedFib()
{
var cache = new Dictionary<int, long>();
long Fib(int n)
{
if (n < 2) return n;
if (cache.TryGetValue(n, out var cached)) return cached;
return cache[n] = Fib(n - 1) + Fib(n - 2);
}
return Fib;
}
var fib = MemoizedFib();
Console.WriteLine(fib(80)); // instant, despite the naive recursionPitfall: capturing a loop variable
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.Write(i));
}
foreach (var a in actions) a(); // prints 333, not 012!All three lambdas capture the same i, which is 3 by the time they run. Copy it into a variable declared inside the loop:
for (int i = 0; i < 3; i++)
{
int copy = i; // a new variable per iteration
actions.Add(() => Console.Write(copy));
}foreach loops don't have this problem — each iteration gets its own variable.
Captured state and lifetimes
Because closures keep variables alive, a long-lived delegate (an event handler, a cached lambda) can keep large objects in memory longer than you expect. Keep captured state small and intentional.
Try it
C# runtime not loaded
Key takeaways
- A closure is a lambda plus the outer variables it uses.
- Closures capture variables, so they see — and can make — later changes.
- Captured variables live as long as the delegate does: that's how counters and caches work.
- In
forloops, copy the loop variable before capturing it.
Exercise
MakeCounter returns a function that returns 1, 2, 3… on successive calls — and each counter is independent. Multiplier(factor) returns a function multiplying its input by factor. Memoize wraps a slow function so repeated calls with the same input reuse the cached result.
C# runtime not loaded
Hint 1
MakeCounter: declare int count = 0; inside MakeCounter, then return () => ++count; — each call to MakeCounter creates a new count.
Hint 2
Memoize: create a Dictionary<int, int> cache inside Memoize; the returned lambda checks the cache before calling slow.
Show solution
var counter = MakeCounter();
Console.WriteLine($"{counter()} {counter()} {counter()}");
Func<int> MakeCounter()
{
int count = 0;
return () => ++count;
}
Func<double, double> Multiplier(double factor) => x => x * factor;
Func<int, int> Memoize(Func<int, int> slow)
{
var cache = new Dictionary<int, int>();
return x =>
{
if (!cache.TryGetValue(x, out int result))
{
result = slow(x);
cache[x] = result;
}
return result;
};
}Quiz
4 questions · 3 correct to pass1.What does a closure capture?
2.`int x = 1; Func<int> f = () => x; x = 5;` — what does f() return?
3.Why do two counters from MakeCounter() count independently?
4.What is a higher-order function?
To complete this lesson
- ○ Solve the exercise
- ○ Pass the quiz (3/4 correct)