Mastering Lambda Expressions and Anonymous Functions in C#

Why Lambda Expressions Transform Your Code

Lambda expressions let you write inline functions without the ceremony of declaring separate methods. They make your code more readable by keeping logic close to where you use it, especially when working with collections, LINQ queries, and event handlers.

Modern C# development relies heavily on functional programming patterns. Lambda expressions are the foundation of LINQ, async callbacks, and functional-style operations like map, filter, and reduce. You'll use them daily when working with lists, databases, or any code that needs to pass behavior as a parameter.

This guide shows you how to create lambda expressions, work with anonymous functions and delegates, understand closures, and apply these patterns effectively in real applications.

Understanding Delegates First

Before mastering lambdas, you need to understand delegates. A delegate is a type that represents a method signature. You can assign any method with a matching signature to a delegate variable, enabling you to pass methods as parameters.

Basic delegate declaration and usage
// Declare a delegate type
public delegate int MathOperation(int x, int y);

// Method that matches the delegate signature
public static int Add(int x, int y)
{
    return x + y;
}

public static int Multiply(int x, int y)
{
    return x * y;
}

// Using delegates
MathOperation operation = Add;
int result1 = operation(5, 3);  // Returns 8

operation = Multiply;
int result2 = operation(5, 3);  // Returns 15

Console.WriteLine($"Add: {result1}, Multiply: {result2}");

Delegates let you treat methods as first-class objects. You can store them in variables, pass them to other methods, and change which method a delegate points to at runtime.

Passing delegates as parameters
public static void ProcessNumbers(int[] numbers, MathOperation operation)
{
    for (int i = 0; i < numbers.Length - 1; i++)
    {
        int result = operation(numbers[i], numbers[i + 1]);
        Console.WriteLine($"{numbers[i]} op {numbers[i + 1]} = {result}");
    }
}

int[] values = { 10, 5, 8, 3 };

Console.WriteLine("Using Add:");
ProcessNumbers(values, Add);

Console.WriteLine("\nUsing Multiply:");
ProcessNumbers(values, Multiply);

This pattern separates the operation logic from the iteration logic. You can easily add new operations without changing ProcessNumbers.

Lambda Expression Syntax

Lambda expressions provide a concise way to create anonymous functions inline. The basic syntax uses the lambda operator => which reads as "goes to" or "becomes."

Lambda expression forms
// Expression lambda (single expression)
Func<int, int, int> add = (x, y) => x + y;
Console.WriteLine(add(5, 3));  // Output: 8

// Statement lambda (multiple statements)
Func<int, int, int> addWithLogging = (x, y) =>
{
    Console.WriteLine($"Adding {x} and {y}");
    int result = x + y;
    Console.WriteLine($"Result: {result}");
    return result;
};

// Single parameter (parentheses optional)
Func<int, int> square = x => x * x;
Console.WriteLine(square(4));  // Output: 16

// No parameters
Func<string> getMessage = () => "Hello, World!";
Console.WriteLine(getMessage());  // Output: Hello, World!

// Multiple parameters with explicit types
Func<string, int, string> repeat = (string text, int count) =>
{
    return string.Concat(Enumerable.Repeat(text, count));
};
Console.WriteLine(repeat("Hi ", 3));  // Output: Hi Hi Hi

Expression lambdas return the result of a single expression automatically. Statement lambdas need curly braces and explicit return statements when returning values.

Working with Func and Action Delegates

The .NET Framework includes built-in generic delegates that cover most scenarios. Func represents functions that return values, while Action represents functions that perform operations without returning anything.

Func and Action examples
// Func - takes T, returns TResult
Func<int, bool> isEven = n => n % 2 == 0;
Console.WriteLine(isEven(4));   // True
Console.WriteLine(isEven(7));   // False

// Func with multiple parameters
Func<string, string, bool> contains = (text, search) => text.Contains(search);
Console.WriteLine(contains("Hello World", "World"));  // True

// Action - takes T, returns nothing
Action<string> log = message => Console.WriteLine($"[LOG] {message}");
log("Application started");

// Action with multiple parameters
Action<string, int> printMultiple = (text, count) =>
{
    for (int i = 0; i < count; i++)
    {
        Console.WriteLine($"{i + 1}: {text}");
    }
};
printMultiple("Test", 3);

Using Func and Action eliminates the need to declare custom delegate types. These generic delegates handle up to 16 parameters, covering virtually all practical scenarios.

Using lambdas with collections
var numbers = new List<int> { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

// Filter with lambda
var evenNumbers = numbers.Where(n => n % 2 == 0).ToList();
Console.WriteLine($"Even: {string.Join(", ", evenNumbers)}");

// Transform with lambda
var squared = numbers.Select(n => n * n).ToList();
Console.WriteLine($"Squared: {string.Join(", ", squared)}");

// Aggregate with lambda
int sum = numbers.Aggregate((acc, n) => acc + n);
Console.WriteLine($"Sum: {sum}");

// ForEach with lambda
numbers.ForEach(n => Console.Write($"{n} "));

// Complex filtering and transformation
var result = numbers
    .Where(n => n > 3)
    .Select(n => n * 2)
    .OrderByDescending(n => n)
    .Take(3)
    .ToList();
Console.WriteLine($"\nProcessed: {string.Join(", ", result)}");

LINQ methods like Where, Select, and Aggregate expect lambda expressions. This integration makes data transformation pipelines readable and expressive.

Anonymous Methods vs Lambda Expressions

Before lambda expressions were introduced in C# 3.0, developers used anonymous methods. While lambdas are now preferred, you'll encounter anonymous methods in older code.

Comparing anonymous methods and lambdas
var numbers = new List<int> { 1, 2, 3, 4, 5 };

// Anonymous method (older style)
var evenOld = numbers.FindAll(delegate(int n)
{
    return n % 2 == 0;
});

// Lambda expression (modern style)
var evenNew = numbers.FindAll(n => n % 2 == 0);

// Both produce the same result
Console.WriteLine($"Old style: {string.Join(", ", evenOld)}");
Console.WriteLine($"New style: {string.Join(", ", evenNew)}");

// Anonymous method with event handler
Button button = new Button();

button.Click += delegate(object sender, EventArgs e)
{
    Console.WriteLine("Button clicked (anonymous method)");
};

// Lambda version (more concise)
button.Click += (sender, e) => Console.WriteLine("Button clicked (lambda)");

Lambda expressions offer cleaner syntax with type inference. The compiler figures out parameter types from context, reducing boilerplate. Always prefer lambdas in new code.

Understanding Closures and Variable Capture

Lambda expressions can access variables from the surrounding scope, creating closures. The captured variables remain accessible even after the enclosing method finishes, which has important implications.

Variable capture in lambdas
public static Func<int> CreateCounter()
{
    int count = 0;  // This variable is captured

    return () =>
    {
        count++;  // Lambda accesses and modifies captured variable
        return count;
    };
}

// Usage
var counter1 = CreateCounter();
Console.WriteLine(counter1());  // 1
Console.WriteLine(counter1());  // 2
Console.WriteLine(counter1());  // 3

var counter2 = CreateCounter();
Console.WriteLine(counter2());  // 1 (separate closure)
Console.WriteLine(counter1());  // 4 (original continues)

Each call to CreateCounter creates a new closure with its own captured count variable. The lambda keeps this variable alive even though CreateCounter has finished executing.

Common closure pitfall with loops
var actions = new List<Action>();

// Problematic: all lambdas capture the same variable
for (int i = 0; i < 5; i++)
{
    actions.Add(() => Console.Write($"{i} "));
}

// All print 5 because they captured the loop variable
foreach (var action in actions)
{
    action();  // Output: 5 5 5 5 5
}

Console.WriteLine();

// Solution: capture a copy of the loop variable
var correctActions = new List<Action>();
for (int i = 0; i < 5; i++)
{
    int copy = i;  // Create a copy for each iteration
    correctActions.Add(() => Console.Write($"{copy} "));
}

foreach (var action in correctActions)
{
    action();  // Output: 0 1 2 3 4
}

Loop variables are captured by reference, not by value. When the loop finishes, all lambdas see the final value. Creating a loop-scoped copy captures the value at each iteration.

Practical Lambda Patterns

Lambda expressions enable several powerful patterns that make your code more maintainable and expressive.

Strategy pattern with lambdas
public class Calculator
{
    private Dictionary<string, Func<double, double, double>> operations;

    public Calculator()
    {
        operations = new Dictionary<string, Func<double, double, double>>
        {
            ["add"] = (x, y) => x + y,
            ["subtract"] = (x, y) => x - y,
            ["multiply"] = (x, y) => x * y,
            ["divide"] = (x, y) => y != 0 ? x / y : throw new DivideByZeroException()
        };
    }

    public double Execute(string operation, double x, double y)
    {
        if (operations.TryGetValue(operation, out var func))
        {
            return func(x, y);
        }
        throw new ArgumentException($"Unknown operation: {operation}");
    }

    public void AddOperation(string name, Func<double, double, double> operation)
    {
        operations[name] = operation;
    }
}

// Usage
var calc = new Calculator();
Console.WriteLine(calc.Execute("add", 10, 5));       // 15
Console.WriteLine(calc.Execute("multiply", 10, 5));  // 50

// Add custom operation
calc.AddOperation("power", (x, y) => Math.Pow(x, y));
Console.WriteLine(calc.Execute("power", 2, 8));      // 256

This pattern uses lambdas to implement strategies without creating separate classes for each operation. Adding new operations becomes trivial.

Lazy evaluation with lambdas
public static void LogMessage(string level, Func<string> messageFactory)
{
    // Only call messageFactory if logging is enabled
    if (IsLoggingEnabled(level))
    {
        string message = messageFactory();  // Compute message only when needed
        Console.WriteLine($"[{level}] {message}");
    }
}

public static bool IsLoggingEnabled(string level)
{
    return level == "ERROR" || level == "WARNING";
}

// Usage
var user = new { Id = 123, Name = "John" };

// Expensive operation only executes if logging is enabled
LogMessage("DEBUG", () => $"User {user.Id} details: {GetExpensiveUserDetails(user)}");
LogMessage("ERROR", () => $"Failed to process user {user.Id}");

static string GetExpensiveUserDetails(dynamic user)
{
    Console.WriteLine("Computing expensive details...");
    return $"Full profile for {user.Name}";
}

Wrapping expensive operations in lambdas defers their execution until needed. This pattern improves performance by avoiding unnecessary computations.

Lambda Expressions in LINQ Queries

LINQ queries become powerful when combined with lambda expressions. You can chain operations to build complex data transformations declaratively.

Complex LINQ with lambdas
public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public decimal Price { get; set; }
    public string Category { get; set; }
    public int Stock { get; set; }
}

var products = new List<Product>
{
    new Product { Id = 1, Name = "Laptop", Price = 999, Category = "Electronics", Stock = 5 },
    new Product { Id = 2, Name = "Mouse", Price = 25, Category = "Electronics", Stock = 50 },
    new Product { Id = 3, Name = "Desk", Price = 299, Category = "Furniture", Stock = 10 },
    new Product { Id = 4, Name = "Chair", Price = 199, Category = "Furniture", Stock = 15 },
    new Product { Id = 5, Name = "Monitor", Price = 349, Category = "Electronics", Stock = 0 }
};

// Complex query with multiple lambda expressions
var expensiveInStock = products
    .Where(p => p.Stock > 0)
    .Where(p => p.Price > 100)
    .OrderByDescending(p => p.Price)
    .Select(p => new
    {
        p.Name,
        p.Price,
        DiscountPrice = p.Price * 0.9m,
        p.Stock
    })
    .ToList();

Console.WriteLine("Expensive in-stock items:");
expensiveInStock.ForEach(p =>
    Console.WriteLine($"{p.Name}: ${p.Price} (Sale: ${p.DiscountPrice:F2})"));

// Grouping with lambdas
var byCategory = products
    .GroupBy(p => p.Category)
    .Select(g => new
    {
        Category = g.Key,
        Count = g.Count(),
        AvgPrice = g.Average(p => p.Price),
        TotalValue = g.Sum(p => p.Price * p.Stock)
    })
    .ToList();

Console.WriteLine("\nCategory summary:");
byCategory.ForEach(c =>
    Console.WriteLine($"{c.Category}: {c.Count} items, avg ${c.AvgPrice:F2}"));

Lambda expressions make LINQ queries readable while performing complex transformations. Each operation clearly expresses its intent through inline logic.

Frequently Asked Questions (FAQ)

What's the difference between lambda expressions and anonymous methods?

Lambda expressions provide a more concise syntax than anonymous methods for creating inline functions. While both create anonymous functions, lambdas use the => operator and can omit parameter types when the compiler can infer them. Anonymous methods use the delegate keyword and require explicit parameter type declarations. Lambda expressions are the modern, preferred approach in C# development.

When should you use Func versus Action delegates?

Use Func when your lambda returns a value and Action when it performs an operation without returning anything. Func takes up to 16 input parameters and always has a return type as the last generic parameter. Action takes up to 16 parameters but returns void. These built-in delegate types eliminate the need to declare custom delegate types for most scenarios.

Can lambda expressions capture variables from the surrounding scope?

Yes, lambda expressions create closures that capture variables from the enclosing scope. These captured variables remain accessible even after the enclosing method finishes executing. The compiler generates a hidden class to store captured variables, allowing the lambda to reference them later. This powerful feature enables callbacks and event handlers to access local context naturally.

Back to Articles