Skip to content

7. Delegates, Lambdas, and Events

In the 2008 course, this chapter followed the one on Windows Forms graphical user interfaces: events were first introduced in the context of buttons and forms, and then students learned how to create events in their own classes. The chapter on graphical user interfaces has been removed from this version of the course. We therefore begin directly with the concept that underlies the entire mechanism: the delegate, that is, an object that references one or more methods. All examples are console programs.

Over the past twenty years, delegates have become one of the most widely used tools in the language. Writing them has become considerably simpler with lambda expressions (C# 3), which we have already used—without going into detail—with LINQ in Chapter 5: [Where(n => n > 5)] passes a lambda—and thus a delegate—to the method [Where]. This chapter explains what lies behind this syntax. It concludes with events, which are also based on delegates. The graphical user interfaces of .NET (Windows Forms, WPF, .NET MAUI) as well as the web frameworks are based on this mechanism: a button click or the arrival of a HTTP request are signaled to subscribed methods.

The programs in this chapter are single-file applications located in the [07-delegates] folder. They are run using [dotnet run delegates-NN.cs].

7.1. Predefined Delegate Types

7.1.1. The concept of a delegate

A delegate type describes a model (also called a prototype or signature) of a method: the types of its parameters and the type of its return value. A variable of a delegate type can then reference any method that matches this model and execute it.

The .NET framework defines numerous delegate types. The oldest is [EventHandler], used by all the events we’ll discuss at the end of this chapter. It is defined as follows:

public delegate void EventHandler(object? sender, EventArgs e);

The keyword [delegate] makes [EventHandler] a delegate type. This type denotes a method pattern:

  • whose first parameter is an object of type [object?] (the object that triggered the event, possibly [null]);
  • whose second parameter is an object of type [EventArgs] (information about the event);
  • returning no result ([void]).

A method corresponding to the model defined by [EventHandler] could be as follows:

static void Display(object? sender, EventArgs e) { ... }

To create an object of type [EventHandler] that references this method, you can write:

EventHandler handler = new EventHandler(Display);

or, more simply:

EventHandler handler = Display;

In this second notation, the method name [Display] (a method group, since a method can have multiple overloads) is automatically converted by the compiler into a [EventHandler] object: this is method group conversion, which has been possible since C# 2. The compiler selects, from among the overloads of [Display], the one that matches the delegate’s signature. The delegate is then executed as a method: [handler("program", EventArgs.Empty)].

7.1.2. The Generic Delegates Func, Action, and Predicate

Before generics (C# 2), you had to define a delegate type for each method signature. Today, the framework provides generic delegate types that cover almost all needs:

Delegate type
Methods Referenced
[Action]
No parameters, no return value: [void M()]
[Action<T1, ..., T16>]
1 to 16 parameters, no results: [void M(T1 p1, ...)]
[Func<TResult>]
No parameters, one result: [TResult M()]
[Func<T1, ..., T16, TResult>]
1 to 16 parameters, a result of the type [TResult] (always the latest type)
[Predicate<T>]
one parameter, a Boolean result: [bool M(T p)]
[Comparison<T>]
two parameters of the same type, an integer result: [int M(T x, T y)]

[Predicate<T>] and [Func<T, bool>] describe the same signature but are two different types: [Predicate<T>] dates from .NET 2 and is used by the methods of [List<T>] and [Array] ([FindAll], [RemoveAll], [Exists]…), whereas LINQ, which was introduced with .NET 3.5, uses [Func<T, bool>].

The program [delegates-01.cs] uses these delegated types:

// the predefined delegate types of .NET

// 1 - the EventHandler delegate: void M(object? sender, EventArgs e)
EventHandler handler = new EventHandler(Display);
// short form: method group conversion
handler = Display;
// execution of the delegate
handler("program", EventArgs.Empty);

// 2 - Func<T1, T2, TResult>: two int parameters, one int result
Func<int, int, int> operation = Add;
Console.WriteLine($"operation(2, 3)={operation(2, 3)}");
// Func<TResult>: no parameter, one result
Func<string> greet = Greet;
Console.WriteLine($"greet()={greet()}");

// 3 - Action<T>: one parameter, no result
Action<string> write = Console.WriteLine;
write("Action<string> executed Console.WriteLine");

// 4 - Predicate<T>: one parameter, a bool result
Predicate<int> isEven = IsEven;
List<int> numbers = [3, 8, 1, 6, 5, 4];
// FindAll and RemoveAll of List<T> expect a Predicate<T>
Console.WriteLine($"even: {string.Join(" ", numbers.FindAll(isEven))}");
int nbRemoved = numbers.RemoveAll(IsEven);
Console.WriteLine($"{nbRemoved} even numbers removed, remaining: {string.Join(" ", numbers)}");

// 5 - Comparison<T>: two T parameters, an int result
List<string> names = ["Paul", "Mélanie", "Jo", "Sébastien"];
names.Sort(CompareLengths);
Console.WriteLine($"sorted by length: {string.Join(" ", names)}");

// 6 - a delegate is an object: its type and the method it references
Console.WriteLine($"type: {operation.GetType()}");
Console.WriteLine($"method: {operation.Method.Name}, target: {operation.Target?.ToString() ?? "none (static method)"}");

// the methods referenced by the delegates
static void Display(object? sender, EventArgs e)
{
    Console.WriteLine($"Display(sender={sender}, e={e.GetType().Name})");
}

static int Add(int n1, int n2) => n1 + n2;

static string Greet() => "Hello";

static bool IsEven(int n) => n % 2 == 0;

static int CompareLengths(string s1, string s2) => s1.Length.CompareTo(s2.Length);

Results:

> dotnet run delegates-01.cs
Display(sender=program, e=EventArgs)
operation(2, 3)=5
greet()=Hello
Action<string> executed Console.WriteLine
even: 8 6 4
3 even numbers removed, remaining: 3 1 5
sorted by length: Jo Paul Mélanie Sébastien
type: System.Func`3[System.Int32,System.Int32,System.Int32]
method: <<Main>$>g__Add|0_1, target: none (static method)

Comments:

  • line 4: creation of a delegate [EventHandler] using the operator [new]. This was the required syntax in C# 1;
  • line 6: the same thing with a method group conversion. This is the standard syntax today;
  • line 8: the delegate is executed as a method. The method [Display] from line 39 is executed, with the parameters passed to the delegate;
  • line 11: [Func<int, int, int>] refers to a method with two parameters, [int], and returning a [int] (the last generic type). The method [Add] on line 44 is suitable;
  • line 14: [Func<string>] refers to a method with no parameters that returns a string;
  • line 18: [Action<string>] refers to a method with one parameter, [string], and no return value. [Console.WriteLine] has many overloads; the compiler chooses [WriteLine(string)];
  • line 22: A predicate is a method that returns a Boolean. The method [FindAll] of the class [List<T>] (line 25) expects a parameter of type [Predicate<T>]: it returns the list of elements for which the predicate returns [true]. Line 26: The name of the method [IsEven] is passed directly; the compiler converts it to *[Predicate<int>]*;
  • Line 31: The method [Sort] accepts a delegate [Comparison<T>] that compares two elements (resulting in negative, zero, or positive values, like [CompareTo]). Here, the names are sorted in ascending order by length;
  • lines 35–36: A delegate is an object. Its type, displayed on line 9 of the results, is [System.Func`3[System.Int32,System.Int32,System.Int32]]: the technical name for a generic type with three type parameters. Every delegate type derives from the class [System.Delegate], which provides, in particular, the properties [Method] (the referenced method) and [Target] (the object on which the method is called, [null] for a static method);
  • the method name, on line 10 of the output, is surprising: the methods on lines 39–50 are local functions of the main program (see Chapter 3). The compiler transforms them into static methods of a class it generates, with a name that no C# program can write.

7.2. Defining Delegate Types

Predefined types are often sufficient, but you can define your own delegate types. The statement:

public delegate int Operation(int n1, int n2);

defines a type called [Operation], which represents a method template that takes two integers and returns an integer. Compared to [Func<int, int, int>], the type [Operation] has a name that describes its purpose, and its parameters have names ([n1], [n2]) that development tools display.

A variable [op] of type [Operation] is used to store a list of methods corresponding to the [Operation] model: this is referred to as a multicast delegate.

  • A method of type [f] is stored in the variable [op] using [op = new Operation(f)] or, more simply, [op = f];
  • To add a method [g] to the list of already registered methods, write [op += g];
  • To remove a method [h] that is already registered, enter [op -= h];
  • The [n = op(n1, n2)] statement executes, in the order in which they were registered, all methods registered in [op], using the parameters [n1] and [n2]. The returned result [n] is that of the last method executed.

The 2008 course indicated that it is not possible to obtain the results of all methods. It is possible, but not with a single call: you must iterate through the list of methods returned by [GetInvocationList] and call them one by one. In practice, methods registered in a multicast delegate most often return [void].

Consider the example [delegates-02.cs]:

// defining your own delegate type

// we define an object of type Operation to register methods in it
// we register the static method Increase
Operation op = Calculations.Increase;
// we execute the delegate
int n = op(4, 7);
Console.WriteLine($"n={n}");

// creation of an object c1 of type Calculations
var c1 = new Calculations();
// we register the Add method of c1 in the delegate
op = c1.Add;
// execution of the delegate object
n = op(2, 3);
Console.WriteLine($"n={n}");
// we register the Subtract method of c1 in the delegate
op = c1.Subtract;
n = op(2, 3);
Console.WriteLine($"n={n}");

// registration of two methods in the delegate (multicast delegate)
op = c1.Add;
op += c1.Subtract;
// execution of the delegate object: we get the result of the last method
n = op(10, 4);
Console.WriteLine($"n={n}");
// the results of all the methods with the invocation list
foreach (Operation method in op.GetInvocationList().Cast<Operation>())
{
    Console.WriteLine($"{method.Method.Name} -> {method(10, 4)}");
}
// we remove a method from the delegate: the result can be null (type Operation?)
Operation? rest = op - c1.Subtract;
// we execute the delegate if it is not null
if (rest is not null)
{
    n = rest(1, 1);
    Console.WriteLine($"n={n}");
}
// we remove the last method: the delegate becomes null
Operation? empty = rest - c1.Add;
Console.WriteLine($"empty is null: {empty is null}");
// ?.Invoke executes the delegate only if it is not null
Console.WriteLine($"empty?.Invoke(1, 1)={empty?.Invoke(1, 1)}");

// a class whose methods have the signature of the Operation delegate
public class Calculations
{
    // two instance methods
    public int Add(int n1, int n2)
    {
        Console.WriteLine($"Add({n1},{n2})");
        return n1 + n2;
    }

    public int Subtract(int n1, int n2)
    {
        Console.WriteLine($"Subtract({n1},{n2})");
        return n1 - n2;
    }

    // a static method
    public static int Increase(int n1, int n2)
    {
        Console.WriteLine($"Increase({n1},{n2})");
        return n1 + 2 * n2;
    }
}

// definition of a delegate type: a method template
// accepting two integers as parameters and returning an integer
public delegate int Operation(int n1, int n2);

Results:

> dotnet run delegates-02.cs
Increase(4,7)
n=18
Add(2,3)
n=5
Subtract(2,3)
n=-1
Add(10,4)
Subtract(10,4)
n=6
Add(10,4)
Add -> 14
Subtract(10,4)
Subtract -> 6
Add(1,1)
n=2
empty is null: True
empty?.Invoke(1, 1)=

Comments:

  • line 73: definition of the delegate type [Operation]. In a high-level language application, type declarations (classes, interfaces, delegates, etc.) are placed after the main program statements;
  • Lines 48–67: The class [Calculations] has two instance methods, [Add] and [Subtract], and one static method, [Increase], all three of which have the signature of type [Operation];
  • line 5: the variable [op] is of type [Operation]. It is assigned a first method reference, that of the static method [Calculations.Increase];
  • line 7: the delegate [op] is executed. All methods referenced by [op] are executed, with the parameters passed to [op]. Here, only the method [Calculations.Increase] is executed;
  • line 11: an instance [c1] of class [Calculations] is created;
  • line 13: the instance method [c1.Add] is assigned to the delegate [op]. [Increase] was a static method, [Add] is an instance method: it doesn’t matter. In the second case, the delegate also stores the object [c1] (its property [Target]) on which the method will be called;
  • Line 18: Do the same for the instance method [Subtract];
  • Lines 23–24: We register the two methods [Add] and [Subtract] in the delegate [op];
  • line 26: both methods are executed (lines 8–9 of the results). The result obtained, 6, is that of [Subtract], the last method executed;
  • lines 29–31: [GetInvocationList] returns the list of methods in the delegate as an array [Delegate[]]. The method LINQ [Cast<Operation>()] converts each of its elements to [Operation], which allows them to be called one by one and each result to be retrieved;
  • Line 34: The method [Subtract] is removed from the delegate. The notation ***[op -= c1.Subtract] is equivalent to ***[op = op - c1.Subtract]***. The result of the [-] operator is of type [Operation?]: if we remove the last method from a delegate, we get [null]*, not an empty delegate. The compiler, which tracks nullable types (see Chapter 3), would issue warnings if you wrote [op -= c1.Subtract] followed by [op(1, 1)]: [CS8601] when assigning a potentially null value to the non-nullable variable [op], and [CS8602] when calling the method. We therefore store the result in a variable of type [Operation?] and check it before executing it (line 36);
  • line 42: we remove the last method; the resulting delegate is [null] (line 17 of the results);
  • line 45: a delegate can also be executed via its method [Invoke]: [op(1, 1)] and [op.Invoke(1, 1)] are equivalent. With the [?.] operator (C# 6), [empty?.Invoke(1, 1)] executes the delegate only if it is not [null]; otherwise, the expression evaluates to [null] and nothing is executed. String interpolation renders [null] as an empty string. We’ll see this syntax again when discussing events.

A delegate is an immutable object: the operations [+] and [-] (and therefore [+=] and [-=]) do not modify the existing delegate; they create a new one. This property will be important when dealing with events.

7.3. Anonymous Methods and Lambda Expressions

7.3.1. From anonymous methods to lambdas

In the previous examples, each delegate references a named method written elsewhere in the program. For short methods used only once, this is cumbersome. C# 2 introduced anonymous methods: the method body is written right where the delegate is created. C# 3 introduced an even more concise syntax—lambda expressions—which have virtually replaced anonymous methods. The program [delegates-03.cs] demonstrates the different forms:

// from anonymous methods to lambda expressions

// 1 - an anonymous method (C# 2): the body of the method is written in place
Func<int, int, int> add = delegate (int n1, int n2)
{
    return n1 + n2;
};
Console.WriteLine($"anonymous method: add(2, 3)={add(2, 3)}");

// 2 - the same thing with a lambda expression (C# 3): parameters => body
Func<int, int, int> subtract = (int n1, int n2) =>
{
    return n1 - n2;
};
Console.WriteLine($"block-bodied lambda: subtract(2, 3)={subtract(2, 3)}");

// 3 - the parameter types are inferred from the target delegate type
Func<int, int, int> multiply = (n1, n2) => n1 * n2;
Console.WriteLine($"expression-bodied lambda: multiply(2, 3)={multiply(2, 3)}");

// 4 - a single parameter: the parentheses are optional
Func<int, int> square = x => x * x;
Predicate<string> isEmpty = s => s.Length == 0;
Console.WriteLine($"square(7)={square(7)}, isEmpty(\"\")={isEmpty("")}");

// 5 - no parameter: empty parentheses
Func<string> greet = () => "Hello";
Action beep = () => Console.WriteLine("beep!");
Console.WriteLine(greet());
beep();

// 6 - a block body with several statements
Action<string, int> repeat = (text, times) =>
{
    for (int i = 0; i < times; i++)
    {
        Console.Write(text);
    }
    Console.WriteLine();
};
repeat("ab", 3);

// 7 - ignored parameters (discards): we do not need their values
EventHandler handler = (_, _) => Console.WriteLine("event received");
handler(null, EventArgs.Empty);

// 8 - a lambda passed directly as a parameter of a method
List<int> numbers = [3, 8, 1, 6, 5, 4];
Console.WriteLine($"even: {string.Join(" ", numbers.FindAll(n => n % 2 == 0))}");
numbers.Sort((n1, n2) => n2.CompareTo(n1));
Console.WriteLine($"descending order: {string.Join(" ", numbers)}");

Results:

> dotnet run delegates-03.cs
anonymous method: add(2, 3)=5
block-bodied lambda: subtract(2, 3)=-1
expression-bodied lambda: multiply(2, 3)=6
square(7)=49, isEmpty("")=True
Hello
beep!
ababab
event received
even: 8 6 4
descending order: 8 6 5 4 3 1

Comments:

  • Lines 4–6: an anonymous method. The keyword [delegate] is followed by the list of parameters and the method body. The compiler generates a method (with a name of its choosing) and creates a delegate [Func<int, int, int>] that references it. There is no method name or return type: the return type is inferred from the instructions [return];
  • lines 11–13: the same thing with a lambda expression. We write the list of parameters, the [=>] operator (which reads “gives” or “such that”), then the body. A lambda whose body is a block enclosed in curly braces is a block-bodied lambda (statement lambda);
  • Line 18: The parameter types are missing. The compiler infers them from the target delegate type [Func<int, int, int>]: [n1] and [n2] are of type [int]. The body is reduced to an expression, whose value is the result of the lambda: this is a lambda with an expression body (lambda expression). This is the most common form;
  • line 22: with a single parameter whose type is not specified, parentheses are optional. [x => x * x] reads “x gives x times x.” Line 23: the same single-parameter lambda, of type [string], returns a Boolean: it is a [Predicate<string>];
  • Lines 27–28: Without parameters, empty parentheses are used. A lambda whose body is a method call ([void]) is suitable for a delegate ([Action]);
  • lines 33–39: A lambda with a block body can contain any statements: declarations, loops, tests, etc.;
  • line 44: The underscore character [_] denotes an ignored parameter (discard, C# 9). It is used when the delegate’s signature requires parameters that the lambda does not need, which is common for event handlers;
  • line 49: The most common use of lambdas is to pass them directly as parameters to a method that expects a delegate. Compare with [delegates-01.cs]: it is no longer necessary to write a method [IsEven];
  • line 50: a lambda of type [Comparison<int>] that reverses the usual comparison: the list is sorted in descending order.

Let’s review the forms of a lambda expression:

Form
Example
typed parameters, block body
[(int a, int b) => { return a + b; }]
untyped parameters, expression body
[(a, b) => a + b]
a single untyped parameter
[x => x * x]
no parameters
[() => "Hello"]
parameters ignored
[(_, _) => Console.WriteLine("received")]

Parameters are either all typed or all untyped: you cannot mix the two. In this course, we will use lambdas rather than anonymous methods, which are now only useful in rare cases.

7.3.2. More on Lambdas

Recent versions of C# have enhanced lambdas. The program [delegates-04.cs] demonstrates these new features:

// more on lambda expressions (C# 9 to C# 14)

// 1 - natural type of a lambda (C# 10): var is possible if the parameters are typed
var twice = (int x) => x * 2;
Console.WriteLine($"twice(21)={twice(21)}, type: {Name(twice)}");
var display = (string s) => Console.WriteLine($"[{s}]");
display("Action<string>");
Console.WriteLine($"type of display: {Name(display)}");
// a method group also has a natural type if it has only one overload
var isEven = IsEven;
Console.WriteLine($"isEven(4)={isEven(4)}, type: {Name(isEven)}");

// 2 - explicit return type (C# 10): useful when it cannot be inferred
var choose = object (bool b) => b ? 1 : "one";
Console.WriteLine($"choose(true)={choose(true)}, choose(false)={choose(false)}");

// 3 - parameters with a default value (C# 12)
var greet = (string name, string greeting = "Hello") => $"{greeting} {name}";
Console.WriteLine(greet("Pierre"));
Console.WriteLine(greet("Pierre", "Good evening"));
// as well as params
var sum = (params int[] values) => values.Sum();
Console.WriteLine($"sum()={sum()}, sum(1, 2, 3)={sum(1, 2, 3)}");
// the type of these lambdas is an anonymous delegate generated by the compiler
Console.WriteLine($"type of greet: {Name(greet)}");

// 4 - static lambda (C# 9): it can use no local variable and no this
const int Coefficient = 3;
Func<int, int> triple = static x => x * Coefficient; // a constant is allowed
Console.WriteLine($"triple(5)={triple(5)}");

// 5 - ref / out / in modifiers without a type (C# 14)
// the Incrementer delegate type fixes the types: the lambda does not need to repeat them
Incrementer increment = (ref x) => x++;
int counter = 10;
increment(ref counter);
increment(ref counter);
Console.WriteLine($"counter={counter}");
Parser parse = (text, out value) => int.TryParse(text, out value);
Console.WriteLine($"parse(\"12\"): {parse("12", out int v1)} {v1}");
Console.WriteLine($"parse(\"x\"): {parse("x", out int v2)} {v2}");

// readable name of a delegate type: Func<Int32, Int32> rather than Func`2
static string Name(Delegate d)
{
    Type t = d.GetType();
    if (!t.IsGenericType)
    {
        return t.Name;
    }
    string name = t.Name[..t.Name.IndexOf('`')];
    return $"{name}<{string.Join(", ", t.GetGenericArguments().Select(a => a.Name))}>";
}

static bool IsEven(int n) => n % 2 == 0;

// delegate types with ref and out parameters
delegate void Incrementer(ref int x);
delegate bool Parser(string text, out int value);

Results:

> dotnet run delegates-04.cs
twice(21)=42, type: Func<Int32, Int32>
[Action<string>]
type of display: Action<String>
isEven(4)=True, type: Func<Int32, Boolean>
choose(true)=1, choose(false)=one
Hello Pierre
Good evening Pierre
sum()=0, sum(1, 2, 3)=6
type of greet: <>f__AnonymousDelegate0<String, String, String>
triple(5)=15
counter=12
parse("12"): True 12
parse("x"): False 0

Comments:

  • Line 4: Up to C# 9, a lambda did not have a type of its own: you always had to specify the target delegate type ([Func<int, int> twice = x => x * 2]). Since C# 10, a lambda with typed parameters has a natural type: here, [Func<int, int>], as shown in line 2 of the results. We can therefore write [var]. However, [var twice = x => x * 2;] is rejected by the compiler, which cannot infer the type of [x];
  • line 6: the natural type of a lambda that returns nothing is a [Action<...>];
  • line 10: A method group also has a natural type, provided there is only one method with that name. [var write = Console.WriteLine;] would be rejected: [WriteLine] has about twenty overloads;
  • line 14: Since C# 10, you can specify the return type before the list of parameters. This is necessary here: the two branches of the conditional operator have different types ([int] and [string]), and the compiler would not know which return type to choose;
  • line 18: Starting with C# 12, lambda parameters can have default values, just like method parameters. Lines 19–20 call the lambda with and without the second parameter;
  • line 22: similarly, the last parameter of a lambda can be [params];
  • line 25: neither the types [Func] nor [Action] can represent an optional parameter or [params]. The compiler then generates an anonymous delegate type, in this case [<>f__AnonymousDelegate0] (line 10 of the results). It can only be used by [var];
  • Line 29: A lambda declared as [static] (C# 9) cannot use either the local variables of the enclosing method or [this]. It can use constants, such as [Coefficient] on line 28. We’ll see in the next section what it means to “use a local variable”; the keyword [static] ensures that a lambda does not capture any, which prevents memory allocation and errors;
  • line 58: a delegate type whose parameter is passed by reference ([ref], see Chapter 3). The types [Func] and [Action] do not allow parameters of type [ref] or [out]: you must define your own delegate type;
  • Line 34: Up to C# 13, a lambda whose parameter was [ref], [out], or [in] had to explicitly type all its parameters as [(ref int x) => x++]. Starting with C# 14, the modifier alone is sufficient, as the type is inferred from the target delegate. Lines 36–37 increment the variable [counter] twice;
  • line 39: the same thing with a parameter [out]. The lambda passes its parameter [out] to [int.TryParse];
  • lines 44–52: a utility function that displays the name of a generic delegate type in a readable format ([Func<Int32, Int32>] rather than [Func`2]). It uses a lambda itself, on line 52.

7.3.3. Variable Capture and Closures

A lambda can use the local variables of the method in which it is written. It is said to capture these variables. This very powerful mechanism holds a few surprises. The program [delegates-05.cs] highlights them:

// variable capture and closures

// 1 - a lambda can use the local variables surrounding it
int threshold = 5;
Func<int, bool> exceeds = n => n > threshold;
Console.WriteLine($"threshold={threshold}: exceeds(7)={exceeds(7)}");
// the variable is captured, not its value: the lambda sees the modification
threshold = 10;
Console.WriteLine($"threshold={threshold}: exceeds(7)={exceeds(7)}");

// 2 - a lambda can also modify a captured variable
int total = 0;
Action<int> accumulate = n => total += n;
accumulate(3);
accumulate(4);
Console.WriteLine($"total={total}");

// 3 - a closure: the captured variable survives the method that created it
Func<int> counterA = CreateCounter();
Func<int> counterB = CreateCounter();
Console.WriteLine($"A: {counterA()} {counterA()} {counterA()}");
Console.WriteLine($"B: {counterB()} {counterB()}");

// 4 - the capture trap in a for loop
List<Action> actions = [];
for (int i = 0; i < 3; i++)
{
    // the three lambdas capture the same variable i
    actions.Add(() => Console.Write($"{i} "));
}
Console.Write("for, capture of i: ");
foreach (Action action in actions)
{
    action();
}
Console.WriteLine();

// the fix: a local copy, new at each loop iteration
actions.Clear();
for (int i = 0; i < 3; i++)
{
    int copy = i;
    actions.Add(() => Console.Write($"{copy} "));
}
Console.Write("for, capture of a copy: ");
actions.ForEach(action => action());
Console.WriteLine();

// foreach (since C# 5) creates a new variable at each iteration
actions.Clear();
foreach (int j in new[] { 0, 1, 2 })
{
    actions.Add(() => Console.Write($"{j} "));
}
Console.Write("foreach: ");
actions.ForEach(action => action());
Console.WriteLine();

// a method that returns a function
static Func<int> CreateCounter()
{
    // local variable captured by the returned lambda
    int value = 0;
    return () => ++value;
}

Results:

1
2
3
4
5
6
7
8
9
> dotnet run delegates-05.cs
threshold=5: exceeds(7)=True
threshold=10: exceeds(7)=False
total=7
A: 1 2 3
B: 1 2
for, capture of i: 3 3 3 
for, capture of a copy: 0 1 2 
foreach: 0 1 2 

Comments:

  • line 5: the lambda uses the local variable [threshold] declared on line 4. [threshold] is not a parameter of the lambda: it is a captured variable;
  • line 8: [threshold] is modified after the lambda is created. The results (lines 2–3) show that the lambda sees the new value: [exceeds(7)] returns [True] and then [False]. The lambda did not copy the value of [threshold] at the time of its creation; it shares the variable itself with the enclosing method;
  • line 13: a lambda can also modify a captured variable. After the two calls on lines 14–15, the variable [total] is equal to 7;
  • Lines 60–64: The function [CreateCounter] returns a lambda that captures its local variable [value]. Normally, a local variable disappears when the method ends. Here, the variable [value] survives [CreateCounter] because the returned lambda still uses it. The combination of the lambda and the variables it captures is called a closure. To implement this, the compiler transforms the captured variables into fields of an object it generates, and the lambda into a method of that object;
  • lines 19–20: Each call to [CreateCounter] creates a new variable [value]—and thus a new counter—that is independent of the others. The results (lines 5–6) confirm this;
  • lines 26–29: the classic pitfall. We create three lambdas in a [for] loop, expecting them to print 0, 1, and 2. They all print 3 (line 7 of the results). In fact, the loop [for] has only one variable, [i], which is shared by all three lambdas. When these lambdas are executed, the loop has already finished, and [i] is equal to 3;
  • Lines 42–43: The correction. The variable [copy] is declared within the loop body: a new variable is created on each iteration, and each lambda captures its own;
  • lines 51–53: With [foreach], the problem does not arise. Up to C# 4, the variable in a loop named [foreach] was unique, just like the one in a loop named [for], and this code printed 2 2 2. C# 5 changed this rule (it is one of the few backward-incompatible changes in the language’s history): the variable [j] is now initialized anew on each iteration. The loop [for] has not been modified;
  • line 46: the method [ForEach] of [List<T>] expects a [Action<T>] and executes it on each element. Here, each element is itself a [Action] delegate, which the lambda executes.

The [for] loop trap occurs primarily with deferred processing: a lambda registered as an event handler or launched in another thread (Chapter 8) executes after the loop that created it has finished.

7.4. Delegates or Interfaces?

The concepts of delegates and interfaces may seem quite similar, and one might wonder what exactly the differences between the two are. Let’s take the example [delegates-06.cs], which is similar to [delegates-02.cs]:

// a parameter of delegate type

// execution of the Add delegate
Console.WriteLine(Execute(Add, 2, 3));
// execution of the Subtract delegate
Console.WriteLine(Execute(Subtract, 2, 3));
// execution of a multicast delegate
Operation op = Add;
op += Subtract;
Console.WriteLine(Execute(op, 2, 3));
// execution of a lambda: no named method is needed
Console.WriteLine(Execute((n1, n2) => n1 * n2, 2, 3));

// execution of a delegate
static int Execute(Operation op, int n1, int n2)
{
    return op(n1, n2);
}

// two methods having the signature of the Operation delegate
static int Add(int n1, int n2)
{
    Console.WriteLine($"Add({n1},{n2})");
    return n1 + n2;
}

static int Subtract(int n1, int n2)
{
    Console.WriteLine($"Subtract({n1},{n2})");
    return n1 - n2;
}

// the delegate type
delegate int Operation(int n1, int n2);

Results:

1
2
3
4
5
6
7
8
9
> dotnet run delegates-06.cs
Add(2,3)
5
Subtract(2,3)
-1
Add(2,3)
Subtract(2,3)
-1
6

On line 15, the [Execute] method expects a reference to an object of the delegate type [Operation] defined on line 34. This allows different methods to be passed to the [Execute] method (lines 4, 6, 10, and 12). The last line even passes it a lambda: it was not necessary to write a multiplication method. This polymorphism can also be achieved using an interface, as shown by [delegates-07.cs]:

// a parameter of interface type

// execution of the Add operation
Console.WriteLine(Execute(new Add(), 2, 3));
// execution of the Subtract operation
Console.WriteLine(Execute(new Subtract(), 2, 3));

// execution of the single method of the IOperation interface
static int Execute(IOperation op, int n1, int n2)
{
    return op.Operation(n1, n2);
}

// IOperation interface
public interface IOperation
{
    int Operation(int n1, int n2);
}

// Add class
public class Add : IOperation
{
    public int Operation(int n1, int n2)
    {
        Console.WriteLine($"Add({n1},{n2})");
        return n1 + n2;
    }
}

// Subtract class
public class Subtract : IOperation
{
    public int Operation(int n1, int n2)
    {
        Console.WriteLine($"Subtract({n1},{n2})");
        return n1 - n2;
    }
}

Results:

1
2
3
4
5
> dotnet run delegates-07.cs
Add(2,3)
5
Subtract(2,3)
-1

Comments:

  • lines 15–17: the [IOperation] interface defines a [Operation] method;
  • lines 21–26 and 31–36: The classes [Add] and [Subtract] implement the interface [IOperation];
  • lines 9–11: the method [Execute], whose first parameter is of the type defined by the interface [IOperation]. It receives, in succession, an instance of the class [Add] as its first parameter, followed by an instance of the class [Subtract].

This clearly demonstrates the polymorphic nature of the delegate-type parameter from the previous example. At the same time, both examples highlight the differences between these two concepts. Delegate and interface types are interchangeable:

  • if the interface has only one method. In fact, the delegate type is a wrapper for a single method, whereas an interface can define multiple methods;
  • if the delegate’s multicast feature is not used. This multicast concept does not exist for an interface.

If both of these conditions are met, you can choose between the following two signatures for the method [Execute]:

int Execute(IOperation op, int n1, int n2)
int Execute(Operation op, int n1, int n2)

The second one, which uses the delegate, is more flexible to use. In the first signature, the first parameter must implement the [IOperation] interface: this requires creating a class to define the method to be passed to [Execute]. In the second approach, any existing method with the correct signature will work, and a lambda expression even allows you to define that method right at the call site. No additional code is required. This flexibility explains why modern libraries (LINQ, asynchronous programming, ASP.NET Core) make extensive use of delegates.

The interface regains the advantage as soon as the behavior to be passed involves several interrelated methods, or requires its own state and identity. This is the case with the [ITaxDao] interface from the [dao] layer in Chapter 6: although it has only one member, its implementations have state (the brackets read, the file name), are created by the dependency injection container, and represent a clearly named role within the application. A [Func<TaxBracket[]>] delegate would technically have sufficed, but it would have been much less descriptive.

7.5. Event Handling

7.5.1. The .NET Event Model

Delegates are used to define events. A [C1] class can define a [Evt1] event as follows:

  • A delegate type is defined within or outside the class [C1]:
delegate void Evt(T1 param1, T2 param2, ...);
  • The class [C1] defines a field of this delegated type:
public Evt? Evt1;
  • When an instance [c1] of the class [C1] wants to signal an event, it executes its delegate [Evt1] by passing it the parameters defined by the type [Evt]. All methods registered in the delegate [Evt1] are then executed with these parameters. It can be said that they have been notified of the event [Evt1];
  • if a [c2] object that uses the [c1] object wants to be notified of the [Evt1] event of the [c1] object, it registers one of its methods, [c2.M], with the delegate [c1.Evt1]. Its method [c2.M] will then be executed every time the event [Evt1] occurs on the object [c1]. It can also unsubscribe when it no longer wishes to be notified;
  • since the delegate [c1.Evt1] can register multiple methods, different [ci] objects can register with [c1.Evt1] to be notified of the event.

In this scenario, we have:

  • a class that signals (also called “publishes” or “emits”) an event;
  • classes that are notified of this event. They are said to subscribe to the event;
  • a delegate type that defines the signature of the methods that will be notified of the event. These methods are called event handlers.

This is the Observer design pattern: the emitter does not know its subscribers; it simply notifies them.

The .NET framework defines a standard signature for an event delegate:

public delegate void MyEventHandler(object? sender, EventArgs e);
  • [sender]: the object that triggered the event;
  • [e]: an object of type [EventArgs] or a derived type, which provides information about the event;
  • By convention, the name of the delegate type ends with [EventHandler].

It also defines a standard way to declare an event of type [MyEventHandler] in a class:

1
2
3
4
5
public class C1
{
    public event MyEventHandler? Evt1;
    ...
}

The field [Evt1] is of the delegate type. The keyword [event] restricts the operations that can be performed on it:

  • From outside the [C1] class, only the operations [+=] and [-=] are permitted. This prevents the subscribed methods from being overridden or deleted (by mistake, for example, by writing [=] instead of [+=]) by other objects. You can only subscribe ([+=]) or unsubscribe ([-=]);
  • only the class [C1] can execute the delegate, that is, trigger the event: [Evt1?.Invoke(this, e)].

If you try, from outside the class, to write [c1.Evt1 = null;] or [c1.Evt1(null, EventArgs.Empty);], the compiler responds with: [error CS0070: The event 'C1.Evt1' can only appear on the left hand side of += or -= (except when used from within the type 'C1')].

Rather than defining a delegate type for each event, we use the predefined generic delegate:

public delegate void EventHandler<TEventArgs>(object? sender, TEventArgs e);

The type [TEventArgs] is that of the second parameter, the object that describes the event. With this generic delegate, the declaration of a [X] event in a [C] class follows the recommended pattern below:

  • define a type [XEventArgs] derived from [EventArgs] to encapsulate information about the [X] event;
  • define an event of type [EventHandler<XEventArgs>] in the [C] class;
  • define in the [C] class a protected and virtual method [protected virtual void OnX(XEventArgs e)] intended to publish the [X] event to subscribers. It is virtual so that a derived class can modify how the event is published.

In 2008, the type [TEventArgs] was required to derive from [EventArgs] (constraint [where TEventArgs : EventArgs]). This constraint was removed in .NET Framework 4.5: any type is acceptable, including a [record]. However, by convention, we continue to derive from [EventArgs]. Note that a [record] cannot be derived from [EventArgs]: a [record] can only inherit from another [record] (error: [CS8864: Records may only inherit from object or another record]). We will therefore use a regular class.

7.5.2. An example: the temperature is too high

Consider the following example:

  • A [Publisher] class encapsulates a temperature. When this temperature exceeds a certain threshold, an event must be triggered. We’ll call this event [TemperatureTooHigh]. Information about this event will be encapsulated in a [TemperatureTooHighEventArgs] type;
  • a class named [Subscriber] subscribes to the previous event. When it is notified of the event, it displays a message on the console. Unlike in the 2008 version, a subscriber unsubscribes automatically after receiving a specified number of alerts;
  • A console program creates a publisher and two subscribers. It enters temperatures via the keyboard and stores them in the publisher. If any of them is too high, the publisher publishes the [TemperatureTooHigh] event.

The 2008 course distributed these classes across four files in a project. We have grouped them into the single-file application [delegates-08.cs], which we present in three parts. First, here is the event information class and the class [Publisher]:

// the information carried by the event
public class TemperatureTooHighEventArgs(decimal temperature) : EventArgs
{
    // temperature at the time of the event
    public decimal Temperature { get; } = temperature;
    // time of the event
    public DateTime Time { get; } = DateTime.Now;
}

// the class that raises the event
public class Publisher(string name)
{
    // threshold triggering the event
    public const decimal Threshold = 19;
    // name of the publisher
    public string Name { get; } = name;
    // the raised event
    public event EventHandler<TemperatureTooHighEventArgs>? TemperatureTooHigh;

    // observed temperature
    public decimal Temperature
    {
        get;
        set
        {
            field = value;
            if (value > Threshold)
            {
                // we raise the event to the subscribers
                OnTemperatureTooHigh(new TemperatureTooHighEventArgs(value));
            }
        }
    }

    // raising the event
    protected virtual void OnTemperatureTooHigh(TemperatureTooHighEventArgs e)
    {
        // emission of the event to the subscribers, if there are any
        TemperatureTooHigh?.Invoke(this, e);
    }
}

Comments:

  • Lines 38–43: The [TemperatureTooHighEventArgs] class derives from [EventArgs], as per convention. It uses a primary constructor (C# 12, see Chapter 4): the parameter [temperature] initializes the read-only property [Temperature], which represents the temperature that triggered the event. We have added the event time, [Time]. Event information is read-only: a subscriber must not be able to modify it for subsequent subscribers;
  • line 50: the temperature threshold above which the event [TemperatureTooHigh] is published;
  • line 52: the sender has a name for identification purposes;
  • line 54: the event [TemperatureTooHigh], of type [EventHandler<TemperatureTooHighEventArgs>]. The type is nullable ([?]): as long as no one has subscribed, the delegate is set to [null];
  • lines 57–66: the property [Temperature]. Its accessor, [set], raises the event if the recorded temperature exceeds the threshold. It uses the C# 14 keyword [field] (see Chapter 4), which refers to the hidden field of an automatic property: it is no longer necessary to declare a private field [temperature] as was required in 2008;
  • Line 66: The publication is handled by the [OnTemperatureTooHigh] method, to which a [TemperatureTooHighEventArgs] object containing the temperature that exceeded the threshold is passed;
  • Lines 72–75: The event is published with the sender itself ([this]) as the first parameter and the object received by the method as the second parameter. The operator [?.] is essential: if there are no subscribers, [TemperatureTooHigh] is equivalent to [null]. The 2008 code used [TemperatureTooHigh(this, evt)], which triggered a [NullReferenceException] if there were no subscribers. The notation [TemperatureTooHigh?.Invoke(this, e)] is also safer in a multithreaded program (Chapter 8): the delegate is read only once, then checked and executed; a simultaneous unsubscription in another thread cannot cause it to become [null] between the check and the call.

Here is the [Subscriber] class:

// a class that subscribes to the event
public class Subscriber(string name, int nbAlertsMax)
{
    // name of the subscriber
    public string Name { get; } = name;
    // number of alerts received
    private int nbAlerts;

    // subscription to the event of a publisher
    public void Subscribe(Publisher publisher)
    {
        publisher.TemperatureTooHigh += EvtTemperatureTooHigh;
    }

    // unsubscription
    public void Unsubscribe(Publisher publisher)
    {
        publisher.TemperatureTooHigh -= EvtTemperatureTooHigh;
    }

    // handler of the TemperatureTooHigh event
    private void EvtTemperatureTooHigh(object? source, TemperatureTooHighEventArgs e)
    {
        if (source is not Publisher publisher)
        {
            return;
        }
        Console.WriteLine($"Subscriber [{Name}]: the source [{publisher.Name}] reported a temperature too high: [{e.Temperature}]");
        // beyond nbAlertsMax alerts, the subscriber unsubscribes
        nbAlerts++;
        if (nbAlerts == nbAlertsMax)
        {
            Unsubscribe(publisher);
            Console.WriteLine($"Subscriber [{Name}]: unsubscribed after {nbAlerts} alert(s)");
        }
    }
}

Comments:

  • line 80: Each subscriber is identified by a name ([Name]) and receives the maximum number of alerts it accepts ([nbAlertsMax]);
  • Lines 88–90: Subscription to a publisher’s event. The subscriber’s method [EvtTemperatureTooHigh] is recorded in the event: this is a method group conversion. In 2008, the subscription was handled by the main program; here, it is the subscriber’s responsibility, who must know which method to remove in order to unsubscribe;
  • Lines 94–96: Unsubscribing removes the same method using [-=]. For the delegate, the method [EvtTemperatureTooHigh] from [s0] and the one from [s1] are two different methods, since they do not have the same target ([Target]): [-=] removes only the one for the relevant subscriber;
  • line 100: the event handler. It has the signature imposed by the delegate [EventHandler<TemperatureTooHighEventArgs>]: a [object?] and a [TemperatureTooHighEventArgs]. It is private: only the subscriber can register it with a publisher;
  • Line 102: The parameter [source] is of type [object?]. The pattern filtering verifies that it is indeed a [Publisher] and stores it in the variable [publisher]. The 2008 code used a type conversion to [((Publisher)source).Name];
  • line 106: the handler displays the subscriber’s name, the name of the sender who reported the event, and the temperature that triggered it;
  • lines 108–111: After the [nbAlertsMax] alerts, the subscriber unsubscribes from the publisher. The subscriber does this while the event is being published, from their own handler. This is safe: we’ve seen that a delegate is immutable. [-=] creates a new delegate and stores it in the event field, but the current publication continues to iterate through the old list.

Here, finally, is the main program:

// event handling: a publisher and subscribers

// creation of an event publisher
var e1 = new Publisher("e");
// creation of two subscribers subscribed to the TemperatureTooHigh event of e1
Subscriber[] subscribers = [new("s0", 3), new("s1", 1)];
foreach (Subscriber subscriber in subscribers)
{
    subscriber.Subscribe(e1);
}
// a third subscriber: a lambda
e1.TemperatureTooHigh += (source, e) => Console.WriteLine($"Log: {e.Temperature} °C at {e.Time:HH:mm:ss}");

// we read the temperatures from the keyboard
while (true)
{
    Console.Write("Temperature (nothing to stop): ");
    string input = Console.ReadLine()?.Trim() ?? "";
    // end of input?
    if (input == "")
    {
        break;
    }
    // is the input a decimal number?
    if (decimal.TryParse(input, out decimal temperature))
    {
        // correct temperature - we store it in the publisher
        e1.Temperature = temperature;
    }
    else
    {
        // we report the error
        Console.WriteLine("Incorrect temperature");
    }
}

Results:

> dotnet run delegates-08.cs
Temperature (nothing to stop): 17
Temperature (nothing to stop): 21
Subscriber [s0]: the source [e] reported a temperature too high: [21]
Subscriber [s1]: the source [e] reported a temperature too high: [21]
Subscriber [s1]: unsubscribed after 1 alert(s)
Log: 21 °C at 11:20:25
Temperature (nothing to stop): abc
Incorrect temperature
Temperature (nothing to stop): 22.5
Subscriber [s0]: the source [e] reported a temperature too high: [22.5]
Log: 22.5 °C at 11:20:27
Temperature (nothing to stop): 25
Subscriber [s0]: the source [e] reported a temperature too high: [25]
Subscriber [s0]: unsubscribed after 3 alert(s)
Log: 25 °C at 11:20:28
Temperature (nothing to stop): 30
Log: 30 °C at 11:20:28
Temperature (nothing to stop): 

Comments:

  • line 4: creation of the sender;
  • Lines 6–9: creation of two subscribers, [s0] (up to 3 alerts) and [s1] (1 alert), which are subscribed to the sender’s event [TemperatureTooHigh]. The collection expression [new("s0", 3), new("s1", 1)] uses the untyped [new] operator (C# 9), as the type is inferred from the array’s type;
  • line 12: a third subscriber, a lambda, which acts as a log. The types of [source] and [e] are inferred from the event type. A lambda cannot be easily unsubscribed: it would need to be stored in a variable to be removed using [-=], because two lambdas written in the same way are two different delegates. Lambdas are therefore reserved for subscriptions that last as long as the emitter;
  • Lines 15–33: Loop for entering temperatures via the keyboard. [Console.ReadLine()] returns [null] if the standard input is closed; [?.Trim() ?? ""] treats this case as an empty entry;
  • line 28: if the entered temperature is valid, it is sent to the [e1] emitter, which triggers the [TemperatureTooHigh] event if it is greater than 19 °C.

The results show:

  • line 2: 17 °C does not exceed the threshold: no event;
  • lines 3–7: 21 °C triggers the event. The three subscribers are notified in the order of their subscriptions. [s1], which accepts only one alert, unsubscribes immediately, but this does not prevent the log—which subscribed after it—from being notified;
  • lines 10–12: [s1] is no longer notified;
  • lines 13–16: [s0] receives its third alert and unsubscribes in turn;
  • line 17: only the log remains.

7.6. The delegates and LINQ

In Chapter 5, we used the methods of LINQ ([Where], [Select], [OrderBy]…) by passing lambdas to them. We now know what these parameters are: the [Where] method is declared as follows in the [System.Linq.Enumerable] class:

public static IEnumerable<TSource> Where<TSource>(this IEnumerable<TSource> source, Func<TSource, bool> predicate)

Its parameter [predicate] is a delegate [Func<TSource, bool>]: any method or lambda that takes one element and returns a boolean will work. To fully understand how LINQ works, let’s write a filtering method ourselves. This is the program [delegates-09.cs]:

// delegates and LINQ

int[] numbers = [3, 8, 1, 6, 5, 4, 9, 2];

// 1 - Where and Select expect Func<int, bool> and Func<int, TResult> delegates
Func<int, bool> isEven = n => n % 2 == 0;
var evenSquares = numbers.Where(isEven).Select(n => n * n);
Console.WriteLine($"LINQ: {string.Join(" ", evenSquares)}");

// 2 - our own filtering method, called with a lambda then a method group
Console.WriteLine($"Filter(n > 4): {string.Join(" ", Tools.Filter(numbers, n => n > 4))}");
Console.WriteLine($"Filter(IsOdd): {string.Join(" ", Tools.Filter(numbers, IsOdd))}");

// 3 - execution is deferred: the predicate is called only during the iteration
IEnumerable<int> large = Tools.Filter(numbers, n =>
{
    Console.Write($"[test {n}] ");
    return n > 7;
});
Console.WriteLine("the query is defined, nothing has been executed yet");
foreach (int n in large)
{
    Console.Write($"-> {n} ");
}
Console.WriteLine();

// 4 - with extension members, our methods chain like those of LINQ
string[] cities = ["Angers", "Paris", "Lyon", "Nantes", "Aix"];
var result = cities.Filter(v => v.Length <= 5).Transform(v => v.ToUpper());
Console.WriteLine($"extension: {string.Join(" ", result)}");

static bool IsOdd(int n) => n % 2 != 0;

public static class Tools
{
    // filters a sequence with a predicate: the equivalent of Where
    public static IEnumerable<T> Filter<T>(IEnumerable<T> source, Func<T, bool> predicate)
    {
        foreach (T element in source)
        {
            if (predicate(element))
            {
                // the element is returned to the caller; the iteration will resume here
                yield return element;
            }
        }
    }
}

public static class Extensions
{
    // extension members (C# 14) of IEnumerable<T>
    extension<T>(IEnumerable<T> source)
    {
        // Filter as an extension method: we reuse Tools.Filter
        public IEnumerable<T> Filter(Func<T, bool> predicate) => Tools.Filter(source, predicate);

        // transforms each element: the equivalent of Select
        public IEnumerable<TResult> Transform<TResult>(Func<T, TResult> transformation)
        {
            foreach (T element in source)
            {
                yield return transformation(element);
            }
        }
    }
}

Results:

1
2
3
4
5
6
7
> dotnet run delegates-09.cs
LINQ: 64 36 16 4
Filter(n > 4): 8 6 5 9
Filter(IsOdd): 3 1 5 9
the query is defined, nothing has been executed yet
[test 3] [test 8] -> 8 [test 1] [test 6] [test 5] [test 4] [test 9] -> 9 [test 2] 
extension: PARIS LYON AIX

Comments:

  • Lines 6–7: The lambda from [Where] is first stored in a variable of type [Func<int, bool>] to reveal its type. [Select], in turn, receives a delegate [Func<int, int>] that transforms each element: this yields the squares of even numbers;
  • lines 37–44: our method [Filter]. It is generic: it filters a sequence of elements of any type [T], using a predicate of type [Func<T, bool>]. It iterates over the sequence and calls the predicate (line 41) on each element;
  • line 44: the [yield return] statement turns [Filter] into an iterator: the method does not construct a list; instead, it returns the selected elements one by one, as requested by the program iterating over the result. After each call to [yield return], the execution of [Filter] is suspended; it resumes at the same point when the next element is requested;
  • Line 11: call to [Filter] with a lambda. The type [T] is inferred from the array: [int]. On line 12, a group of methods is passed to it;
  • Lines 15–18: a verbose predicate that prints each element it tests. The result (lines 5–6) shows that creating the query does not execute anything: the message on line 20 is displayed before the first test. It is only during the [foreach] loop that [Filter] executes, and the tests alternate with the loop’s print statements. This is deferred execution, the same as that of the LINQ queries;
  • lines 53–63: an extension members block (C# 14, introduced in Chapter 4) adds two methods to the [IEnumerable<T>] type: [Filter], which reuses [Tools.Filter], and [Transform], the equivalent of [Select]. [Transform] is doubly generic: [T] is the type of the sequence’s elements, and [TResult] is the type of the elements produced by the transformation;
  • Line 29: Thanks to the extension methods, our methods are called just like the methods in the sequence and chain together just like those in LINQ: we keep cities with 5 letters or fewer, then capitalize them.

The entire LINQ library is built on this model: extension methods of [IEnumerable<T>] that accept delegates and return new sequences, computed on demand by iterators.

7.7. Expression Trees

A lambda expression can be converted into a delegate, as we have done throughout this chapter. It can also be converted into an expression tree: instead of compiling the lambda into executable code, the compiler generates instructions that construct, at runtime, a data structure describing the lambda. To do this, simply use the type [Expression<TDelegate>] from the namespace [System.Linq.Expressions] instead of the delegate type. The program [delegates-10.cs] compares the two:

// expression trees
using System.Linq.Expressions;

// the same lambda, compiled into a delegate or kept as data
Func<int, bool> function = n => n > 5 && n % 2 == 0;
Expression<Func<int, bool>> expression = n => n > 5 && n % 2 == 0;

// a delegate executes, but its code is not readable
Console.WriteLine($"delegate: {function}");
// an expression tree can be traversed and displayed
Console.WriteLine($"expression: {expression}");
Console.WriteLine($"parameter: {expression.Parameters[0].Name}, returned type: {expression.ReturnType.Name}");
Console.WriteLine("tree:");
Display(expression.Body, 1);

// an expression tree can be compiled into a delegate then executed
Func<int, bool> compiled = expression.Compile();
Console.WriteLine($"compiled(8)={compiled(8)}, compiled(7)={compiled(7)}");

// recursive display of a tree node and of its children
static void Display(Expression node, int level)
{
    string margin = new(' ', 2 * level);
    switch (node)
    {
        case BinaryExpression binary:
            // operation with two operands: &&, >, ==, %...
            Console.WriteLine($"{margin}{binary.NodeType}");
            Display(binary.Left, level + 1);
            Display(binary.Right, level + 1);
            break;
        case ParameterExpression parameter:
            Console.WriteLine($"{margin}Parameter {parameter.Name}");
            break;
        case ConstantExpression constant:
            Console.WriteLine($"{margin}Constant {constant.Value}");
            break;
        default:
            Console.WriteLine($"{margin}{node.NodeType} {node}");
            break;
    }
}

Results:

> dotnet run delegates-10.cs
delegate: System.Func`2[System.Int32,System.Boolean]
expression: n => ((n > 5) AndAlso ((n % 2) == 0))
parameter: n, returned type: Boolean
tree:
  AndAlso
    GreaterThan
      Parameter n
      Constant 5
    Equal
      Modulo
        Parameter n
        Constant 2
      Constant 0
compiled(8)=True, compiled(7)=False

Comments:

  • line 2: the namespace [System.Linq.Expressions] is not part of the implicit [using];
  • Lines 5–6: The same lambda is assigned to a delegate [Func<int, bool>] and then to an expression tree [Expression<Func<int, bool>>];
  • line 9: a delegate can only display its type; its code is compiled and unreadable;
  • line 11: an expression tree, on the other hand, can be displayed. The result (line 3) reproduces the lambda: [AndAlso] is the name of the operator [&&];
  • line 12: We can query the tree: its parameters, the type of its result, and its body ([Body]);
  • lines 21–39: a recursive method that displays the tree. Each node is an object derived from the [Expression] class: [BinaryExpression] for a two-operand operation ([&&], [>], [==], [%]), [ParameterExpression] for a parameter, [ConstantExpression] for a constant. [switch], with pattern filtering based on type, handles each case. The results (lines 6–14) show the structure of the expression [n > 5 && n % 2 == 0];
  • line 17: an expression tree can be compiled at runtime into a delegate and then executed.

What is the purpose of this? A delegate can only be executed within the program. An expression tree can be parsed and translated into another language. This is what Entity Framework Core does, which we will explore in Chapter 11. When writing a LINQ query on a database table, for example [context.Brackets.Where(t => t.Limit > 20000)], the method [Where] used is not [IEnumerable<T>] but [IQueryable<T>], whose parameter is an [Expression<Func<T, bool>>]. EF Core traverses this tree, just like our [Display] method, and translates it into a SQL [WHERE limits > 20000] clause executed by the database server. The same lambda, written in the same way, thus becomes either .NET code or SQL code, depending on the type of the parameter it receives.

7.8. Conclusion

Key takeaways from this chapter:

  • A delegate is an object that references one or more methods with a given signature. The predefined types [Func<>], [Action<>], and [Predicate<T>] usually eliminate the need to define your own delegate types;
  • lambda expressions allow you to write the method right where you create the delegate. They capture the variables surrounding them (be careful with [for] loops);
  • A delegate can replace a single-method interface, offering greater flexibility;
  • an event is a delegate protected by the keyword [event]: you subscribe to it using [+=], unsubscribe using [-=], and only the class that declares it triggers it, using [Evt?.Invoke(this, e)];
  • LINQ is built on methods that accept delegates, and EF Core is built on expression trees.

Delegates will be discussed again in the following chapters: a task ([Task.Run]) executes a delegate in another thread (Chapters 8 and 9), and the ASP.NET Core web services associate lambdas with HTTP addresses (Chapter 13).