Skip to main content

πŸ“ Lesson 2.1: Delegates and Lambdas

Until now, data was data and methods were methods. In this lesson those worlds merge: you'll learn to treat a method itself as a value you can store, pass to other methods, and call later. This idea powers events, LINQ, and modern C#.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Explain what a delegate is and why "methods as data" is useful
  • Declare a delegate type, assign a method to it, and invoke it
  • Use the built-in Func, Action, and Predicate delegates
  • Write concise lambda expressions with =>
  • Pass behavior into a method as an argument

Estimated Time: 60 minutes

Project: Build a mini toolkit that takes behavior as a parameter.

In This Lesson

Methods as Data

You already pass values into methods β€” numbers, strings, objects. What if you could pass a method itself, so the receiving code can call it? That's exactly what delegates make possible.

πŸ“– Definition

Delegate: a type that holds a reference to a method. A delegate variable can store any method whose signature (parameters and return type) matches, and you can invoke that method through the variable.

Think of a delegate as a "slot" for a method. The slot specifies the shape of method it accepts (say, "takes two ints, returns an int"), and you can plug in any matching method β€” then swap it out later.

graph LR A["Delegate variable
(a slot for a method)"] --> B["Add()"] A -.->|"reassign"| C["Multiply()"] A --> D["Invoke β†’ runs whichever
method is plugged in"] style A fill:#eff6ff,stroke:#3b82f6,stroke-width:2px style D fill:#e8f5e9,stroke:#4CAF50,stroke-width:2px

This unlocks a powerful pattern: writing general code that does the structure of a task, while the caller supplies the specific behavior.

Declaring a Delegate

You can declare a delegate type with the delegate keyword. It looks like a method signature with no body:

// A delegate type: "any method taking two ints and returning an int"
delegate int MathOp(int a, int b);

Now any matching method can be stored in a variable of that type and called through it:

int Add(int a, int b) => a + b;
int Multiply(int a, int b) => a * b;

MathOp op = Add;              // plug in the Add method
Console.WriteLine(op(3, 4)); // 7 β€” calling Add through the delegate

op = Multiply;               // swap in a different method
Console.WriteLine(op(3, 4)); // 12 β€” now it calls Multiply

πŸ’‘ Note the signatures match

Both Add and Multiply take two ints and return an int β€” exactly what MathOp requires. A method with a different signature won't fit the slot, and the compiler will say so. (Also note: you assign Add without parentheses β€” op = Add stores the method; op(3, 4) calls it.)

Func, Action, and Predicate

In practice you rarely declare your own delegate types, because .NET provides ready-made generic ones that cover almost every case. These are the ones you'll use constantly:

DelegateRepresents a method that…
Actiontakes no arguments and returns nothing (void)
Action<T>takes a T and returns nothing
Func<TResult>takes nothing and returns a TResult
Func<T, TResult>takes a T and returns a TResult
Predicate<T>takes a T and returns a bool (a test)

The rule for Func: the last type parameter is the return type; the earlier ones are parameters. So Func<int, int, int> means "takes two ints, returns an int" β€” our MathOp without the custom declaration:

Func<int, int, int> add = Add;        // two int params, int result
Console.WriteLine(add(3, 4));         // 7

Action<string> greet = name => Console.WriteLine($"Hi, {name}!");
greet("Ada");                         // Hi, Ada!

Predicate<int> isEven = n => n % 2 == 0;
Console.WriteLine(isEven(10));        // True

βœ… Prefer the built-ins

Reach for Func, Action, and Predicate instead of writing your own delegate types. They're instantly recognizable to other developers and are what .NET's own APIs (including LINQ) expect.

Lambda Expressions

Writing a whole named method just to plug it into a delegate is often overkill. A lambda expression is a compact, inline, anonymous method. You've already seen the => ("goes to") arrow in expression-bodied methods; lambdas use the same arrow:

// Parameters => result
Func<int, int, int> add = (a, b) => a + b;
Func<int, int> square = x => x * x;
Action greet = () => Console.WriteLine("Hello!");

Console.WriteLine(add(3, 4));   // 7
Console.WriteLine(square(5));   // 25
greet();                        // Hello!

Anatomy of a lambda:

  • Parameters in parentheses on the left β€” (a, b). With exactly one parameter you can drop the parentheses: x => x * x.
  • The => arrow.
  • The body on the right β€” a single expression whose value is returned automatically.

For multiple statements, use braces and an explicit return:

Func<int, string> describe = n =>
{
    if (n > 0) return "positive";
    if (n < 0) return "negative";
    return "zero";
};

Console.WriteLine(describe(-5));   // negative

πŸ’‘ Types are inferred

You didn't declare types for a, b, or x β€” C# infers them from the delegate the lambda is assigned to. That brevity is the whole point: lambdas let you express behavior with minimal ceremony.

Passing Behavior to Methods

Here's where it all pays off. A method can accept a delegate as a parameter, letting the caller inject custom behavior. Consider a method that processes a list but lets you decide the operation:

// Applies the given operation to every number and prints the result
void ForEachNumber(List<int> numbers, Action<int> action)
{
    foreach (int n in numbers)
    {
        action(n);
    }
}

List<int> nums = new List<int> { 1, 2, 3 };

ForEachNumber(nums, n => Console.WriteLine(n * 10));   // 10, 20, 30
ForEachNumber(nums, n => Console.WriteLine($"[{n}]")); // [1], [2], [3]

Same loop, totally different behavior β€” chosen by the caller at the call site. Now a filtering example using a Func that returns a bool:

List<int> Filter(List<int> numbers, Func<int, bool> test)
{
    List<int> results = new List<int>();
    foreach (int n in numbers)
    {
        if (test(n))          // the caller decides what "keep" means
        {
            results.Add(n);
        }
    }
    return results;
}

List<int> nums = new List<int> { 1, 2, 3, 4, 5, 6 };

List<int> evens = Filter(nums, n => n % 2 == 0);     // 2, 4, 6
List<int> big = Filter(nums, n => n > 3);            // 4, 5, 6

βœ… This is how LINQ works

That Filter method is essentially LINQ's Where. In Lesson 2.3 you'll use the real, built-in versions β€” Where, Select, and friends β€” which all take lambdas exactly like this. Delegates and lambdas are the foundation LINQ is built on.

Capturing Variables

A lambda can use variables from the surrounding code, not just its own parameters. This is called capturing (or a "closure"):

int threshold = 3;

Func<int, bool> aboveThreshold = n => n > threshold;   // captures 'threshold'

Console.WriteLine(aboveThreshold(5));   // True
Console.WriteLine(aboveThreshold(2));   // False

The lambda "remembers" threshold even though it wasn't passed in as a parameter. This makes lambdas great for building behavior tailored to the current context.

⚠️ Captured variables are live references

A lambda captures the variable, not a snapshot of its value. If the variable changes after the lambda is created, the lambda sees the new value:

int factor = 2;
Func<int, int> scale = n => n * factor;
Console.WriteLine(scale(10));   // 20

factor = 5;                     // change it afterward
Console.WriteLine(scale(10));   // 50 β€” uses the updated factor

This is usually what you want, but it can surprise you inside loops β€” a well-known gotcha worth remembering.

Exercise & Quiz

πŸ‹οΈ Exercise: A Configurable Transformer

Objective: Write methods that take behavior as parameters, and call them with lambdas.

Instructions:

  1. Create a new project called Behaviors.
  2. Write a method List<int> Transform(List<int> numbers, Func<int, int> op) that returns a new list with op applied to each element.
  3. Call it three ways with lambdas: double each number, square each number, and add 100 to each.
  4. Write a method int CountWhere(List<int> numbers, Func<int, bool> test) that returns how many elements satisfy the test. Use it to count evens and count numbers greater than 50.

Starter Code:

List<int> nums = new List<int> { 1, 2, 3, 4, 5 };

// TODO: use Transform to double each, then print

List<int> Transform(List<int> numbers, Func<int, int> op)
{
    // TODO: apply op to each element into a new list
    return new List<int>();
}

// TODO: int CountWhere(List<int> numbers, Func<int, bool> test)
πŸ’‘ Hint

In Transform, loop and results.Add(op(n));. To double, call Transform(nums, n => n * 2). In CountWhere, keep a counter and if (test(n)) count++;.

βœ… Solution
List<int> nums = new List<int> { 1, 2, 3, 4, 5 };

List<int> doubled = Transform(nums, n => n * 2);
Console.WriteLine(string.Join(", ", doubled));    // 2, 4, 6, 8, 10

List<int> squared = Transform(nums, n => n * n);
Console.WriteLine(string.Join(", ", squared));    // 1, 4, 9, 16, 25

Console.WriteLine(Transform(nums, n => n + 100)[0]);  // 101

Console.WriteLine(CountWhere(nums, n => n % 2 == 0)); // 2 (evens: 2,4)
Console.WriteLine(CountWhere(nums, n => n > 50));      // 0

List<int> Transform(List<int> numbers, Func<int, int> op)
{
    List<int> results = new List<int>();
    foreach (int n in numbers)
    {
        results.Add(op(n));
    }
    return results;
}

int CountWhere(List<int> numbers, Func<int, bool> test)
{
    int count = 0;
    foreach (int n in numbers)
    {
        if (test(n))
        {
            count++;
        }
    }
    return count;
}

(string.Join(", ", list) is a handy way to print a collection on one line.)

🎯 Quick Quiz

Question 1: What does a delegate hold?

Question 2: What does Func<int, bool> describe?

Question 3: In the lambda x => x * x, what is x?

Summary

πŸŽ‰ Key Takeaways

  • A delegate is a type that references a method β€” a "slot" that accepts any method with a matching signature.
  • Prefer the built-in generic delegates: Action (returns void), Func<…, TResult> (returns a value, last type param is the result), and Predicate<T> (returns bool).
  • Lambdas (parameters => body) are concise inline methods; single-expression bodies return automatically, and types are inferred.
  • Passing a delegate/lambda into a method lets the caller inject behavior β€” the pattern behind LINQ.
  • Lambdas capture surrounding variables by reference (a closure), so later changes are visible.

πŸ“š Additional Resources

πŸš€ What's Next?

Delegates hold methods β€” and that's the mechanism behind events, C#'s way of letting objects broadcast notifications. In Lesson 2.2: Events, you'll build objects that others can subscribe to and react to.

πŸŽ‰ Behavior is now data!

You can pass logic around like any other value. Next, we use delegates to power events.