.NET Academy

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));   // True

A 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 threshold

And a lambda can modify a captured variable:

int clicks = 0;
Action click = () => clicks++;
 
click(); click(); click();
Console.WriteLine(clicks);      // 3

Captured 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 count

The 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));   // 21

A 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 recursion

Pitfall: 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

Program.cs
Loading editor…

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 for loops, 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.

Program.cs
Loading editor…

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 pass
  1. 1.What does a closure capture?

  2. 2.`int x = 1; Func<int> f = () => x; x = 5;` — what does f() return?

  3. 3.Why do two counters from MakeCounter() count independently?

  4. 4.What is a higher-order function?

To complete this lesson

  • ○ Solve the exercise
  • ○ Pass the quiz (3/4 correct)