Skip to main content
ANVISoftware Solutions
Lesson 42 of 62Intermediate16 min

Lambda Expressions

By the end of this lesson

Write inline functions and understand what they capture.

A lambda is a method written where it is used, with no name and no declaration. order => order.Total > 50_000m is a complete function: it takes an order and returns a bool.

The previous lesson passed named methods around as values. Most of the time those methods are two lines long, used once, and naming them adds a trip to another part of the file for whoever is reading. A lambda puts the behaviour at the point of use.

It also has one property that surprises people, and the second half of this lesson is about that: a lambda can use variables from the code around it, and it keeps using the variable rather than the value it held at the time.

The forms you will write
C#
Order order = new Order("SO-4471") { Total = 62_000m };

// parameter => single expression. The return is implied.
Func<decimal, decimal> addTax = amount => amount * 1.18m;
Func<Order, bool> isLarge = o => o.Total >= 50_000m;
Action<string> log = message => Console.WriteLine($"[orders] {message}");

// No parameters: an empty pair of brackets.
Action greet = () => Console.WriteLine("Order service ready");

// Two parameters: brackets and commas.
Func<decimal, decimal, decimal> applyDiscount = (total, rate) => total * (1m - rate);

// Several statements: braces, and an explicit return.
Func<Order, string> describe = o =>
{
    string size = o.Total >= 50_000m ? "large" : "standard";
    return $"{o.Reference} is a {size} order";
};

Console.WriteLine(addTax(1_000m));            // 1180
Console.WriteLine(isLarge(order));            // True
Console.WriteLine(describe(order));           // SO-4471 is a large order
log("totals recalculated");
greet();
  • The => separates the parameters from the body. Nothing about a lambda is magic: each of these is a method the compiler generates for you.
  • Parameter types are usually left out because the delegate type supplies them. addTax is a Func<decimal, decimal>, so amount must be a decimal. You can write them explicitly — (decimal amount) => ... — when it helps a reader.
  • In Func<A, B, C>, the last type argument is the return type and the others are parameters. Action has no return type at all.
  • A single-expression lambda returns that expression's value automatically. Once you add braces you are writing statements, so a return is required.
  • The body can be as long as you like, and the next callouts argue for keeping it short.
The classic surprise, and the fix
C#
List<Action> jobs = new List<Action>();

for (int i = 0; i < 3; i++)
{
    jobs.Add(() => Console.WriteLine($"Processing batch {i}"));
}

foreach (Action job in jobs)
{
    job();
}
// Processing batch 3
// Processing batch 3
// Processing batch 3

// The fix: a fresh variable inside the loop, so each lambda captures its own.
List<Action> fixedJobs = new List<Action>();

for (int i = 0; i < 3; i++)
{
    int batch = i;
    fixedJobs.Add(() => Console.WriteLine($"Processing batch {batch}"));
}

foreach (Action job in fixedJobs)
{
    job();
}
// Processing batch 0
// Processing batch 1
// Processing batch 2
  • In a for loop, i is one variable that lives for the whole loop. All three lambdas captured that one variable.
  • None of them ran during the loop — they were only stored. By the time the first one ran, the loop had finished and i held 3, which is the value that ended the loop.
  • int batch = i; declares a new variable on each pass, so there are three of them and each lambda captures a different one. That is the whole fix, and it is why the line looks pointless until you know this.
  • A detail worth knowing: foreach behaves differently. Its iteration variable has been a fresh variable per iteration since C# 5, so the same code written with foreach gives the expected output. The surprise survives in for loops, and anywhere a captured variable is changed after the lambda was created.
  • The general rule this teaches: if you want a snapshot, take a copy into a local variable. Capturing a variable that something else will change is a shared reference, whether or not that was the intention.

Terms you will meet around lambdas:

Lambda expression
An unnamed method written inline. The compiler generates a real method behind it.
Closure
A lambda that uses variables from its surrounding scope, plus the hidden object holding them.
Captured variable
A variable from outside the lambda that the lambda uses. It is shared, not copied, and it outlives the enclosing method if the lambda does.
Anonymous method
The older delegate(Order o) { ... } syntax. It does the same job. Read it in existing code; write lambdas for new work.
static lambda
static o => o.Total > 0m tells the compiler the lambda must capture nothing. If it accidentally uses an outer variable, the build fails.
Expression tree
Expression<Func<Order, bool>> stores the lambda as data describing the code rather than as a compiled method, so a library can translate it — into SQL, for instance. The LINQ module returns to this.

Summary

  • A lambda is an unnamed method written where it is used, and the compiler turns it into a real method
  • Parameter and return types usually come from the delegate type, so they are left out
  • A lambda using an outer variable is a closure, and it captures the variable rather than its value at that moment
  • A for loop's variable is shared by every lambda created in it — copy it into a loop-local variable for a snapshot
  • Reach for a named method once the logic deserves a name, is reused, must be detached from an event, or needs testing

Practice

Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.

Try it yourself

Try it yourself

Predict the output of this, then run it. A list of three SKUs, and a for loop over indexes 0 to 2 that adds a lambda printing skus[index] to a list of Action. Call them all after the loop.

Then fix it so each lambda prints a different SKU, and explain what changed.

Show solution

The first version throws an ArgumentOutOfRangeException rather than printing the wrong values, which makes it a sharper demonstration than the batch-number example. All three lambdas share the one index variable, and after the loop it holds 3 — one past the end of a three-item list.

The fix declares a new variable inside the loop body. Each iteration creates its own, each lambda captures its own, and the values are 0, 1 and 2 when the lambdas eventually run.

Worth noticing why the compiler cannot warn about this. Capturing a variable that will change is legitimate and sometimes exactly what you want — a counter shared between several lambdas, for instance. The compiler has no way to know which of the two you meant.

A second defensible fix is to use foreach over the list instead of indexing, which avoids the shared variable entirely because the iteration variable is fresh each pass. That is usually the better code anyway: fewer moving parts and no index arithmetic to get wrong.

C#
List<string> skus = new List<string> { "DL-1001", "CH-2002", "TB-3003" };

// Broken: every lambda shares one index variable.
List<Action> broken = new List<Action>();

for (int index = 0; index < skus.Count; index++)
{
    broken.Add(() => Console.WriteLine(skus[index]));
}

foreach (Action print in broken)
{
    print();      // throws: index is 3 by the time any of these run
}

// Fixed: one variable per iteration.
List<Action> working = new List<Action>();

for (int index = 0; index < skus.Count; index++)
{
    int current = index;
    working.Add(() => Console.WriteLine(skus[current]));
}

foreach (Action print in working)
{
    print();      // DL-1001, CH-2002, TB-3003
}

Think about it

Think about it

Two lambdas are created in the same method and both use a local variable int processed. One increments it, the other prints it.

What does the print lambda show after the increment lambda has run five times, and what does that tell you about where processed actually lives?

Show solution

It prints 5. Both lambdas captured the same variable, so a change made through one is visible through the other.

That is only possible if processed is not an ordinary local. A local variable lives on the stack and disappears when the method returns, but these lambdas may be called afterwards. So the compiler moves processed into a field on a hidden class, creates one instance of that class, and gives both lambdas a reference to it. The enclosing method reads and writes the same field.

The useful consequence: captured variables are shared state, with all the care that implies. If two lambdas run on different threads, incrementing a captured counter has the same race conditions as incrementing a shared field, and none of the syntax hints at it.

This behaviour is also a tool rather than only a hazard. Sharing a counter or an accumulated total between a producer and a reporter is a legitimate use of it. What matters is deciding on purpose whether you want sharing or a snapshot, and taking a copy when it is the latter.

Saved in this browser only.