Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

C# & .NET (C# 14, .NET 10 LTS)  Module 12 – Delegates, lambdas, events and functional style

Delegates, Func and Action

Store methods in C# delegates, pass them as Func, Action and Predicate, and predict what a multicast delegate calls, returns and skips when one target throws.

  • Intermediate
  • 20 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Declare a delegate type, store a method or a lambda in it and call it with () or Invoke
  • Choose Func, Action or Predicate for a callback, and declare your own delegate type for an out parameter
  • Pass methods as arguments, so that one method works with many rules
  • Predict what a multicast delegate calls, returns and skips when one of its targets throws
  • Call a delegate that may be null with ?.Invoke, and tell when passing a method allocates a new delegate

Before you start

What you’ll learn · Before you start

What you will learn

  • Declare a delegate type, store a method or a lambda in it and call it with () or Invoke
  • Choose Func, Action or Predicate for a callback, and declare your own delegate type for an out parameter
  • Pass methods as arguments, so that one method works with many rules
  • Predict what a multicast delegate calls, returns and skips when one of its targets throws
  • Call a delegate that may be null with ?.Invoke, and tell when passing a method allocates a new delegate

Before you start

On this page

A checkout that knows five discount rules usually grows five if branches, and a sixth rule means editing it once more. A delegate takes the branches out: it is a value that holds a method, so the rule can be passed to the checkout like any other argument, and one Price method serves every rule. LINQ over collections, the comparison handed to List<T>.Sort and every event in .NET are built on delegates.

You will declare a delegate type and call methods through it, choose between Func, Action and Predicate, pass rules as arguments, predict what a delegate with several targets runs and returns, and measure what passing a method costs.

A delegate holds a method, and calling the delegate calls the method

basics.cs declares a delegate: A value that holds a method of a given shape, its parameters and its return type, so the method can be stored, passed to other code and called later. Its type is declared with the delegate keyword, or is a Func, an Action or a Predicate. type, delegate decimal PriceRule(decimal price);, and stores three different methods in one variable of that type, one after the other:

basics.cs: one delegate variable, three methods C# · basics/basics.cs
// A delegate type names a shape of method: here, one decimal in and one decimal out. A variable of that type holds a
// method of that shape, and calling the variable calls the method. The prices and rules are made up for this lesson.
PriceRule rule = Rules.Festival;
Console.WriteLine($"Festival:  Rs {rule(800m)}");
Console.WriteLine($"Invoke:    Rs {rule.Invoke(800m)}");
Console.WriteLine($"  method {rule.Method.Name}, target {Describe(rule.Target)}");

// The same variable now holds an instance method, so the delegate also keeps the object to call it on.
var shop = new Shop(fee: 40m);
rule = shop.WithDelivery;
Console.WriteLine($"Delivery:  Rs {rule(800m)}");
Console.WriteLine($"  method {rule.Method.Name}, target {Describe(rule.Target)}");

// A lambda of the same shape converts to the same delegate type.
rule = price => price >= 1000m ? price - 150m : price;
Console.WriteLine($"Lambda:    Rs {rule(800m)} and Rs {rule(1200m)}");

Console.WriteLine($"The type of rule: {rule.GetType().Name}, derived from {rule.GetType().BaseType!.FullName}");

static string Describe(object? target) => target is null ? "none (a static method)" : target.ToString()!;

static class Rules
{
    // 10 % off.
    public static decimal Festival(decimal price) => price * 90 / 100;
}

class Shop(decimal fee)
{
    public decimal WithDelivery(decimal price) => price + fee;

    public override string ToString() => $"the Shop with a delivery fee of Rs {fee}";
}

delegate decimal PriceRule(decimal price);

Output

Festival:  Rs 720
Invoke:    Rs 720
  method Festival, target none (a static method)
Delivery:  Rs 840
  method WithDelivery, target the Shop with a delivery fee of Rs 40
Lambda:    Rs 800 and Rs 1050
The type of rule: PriceRule, derived from System.MulticastDelegate

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run basics.cs

PriceRule rule = Rules.Festival; names the method without parentheses, a method group: A method's name written without parentheses or arguments, such as Rules.Festival. Where a delegate is expected, the compiler converts it into a delegate that calls the method, choosing the overload that fits., and the compiler converts it into a PriceRule object that refers to Festival. Nothing runs yet. rule(800m) then calls Festival and returned 720; rule.Invoke(800m) is the same call written out, because the compiler gives every delegate type an Invoke method with its own parameters and return type. A delegate type is a class: the last line printed PriceRule, derived from System.MulticastDelegate.

The delegate keeps two things, and the program printed both. Method is the method to run. Target is the object to run it on: none for the static Festival, and the shop with its Rs 40 fee for the instance method WithDelivery, which is why the same call returned 840. Step through that second call:

Interactive · step by step One call through a delegate Step through rule(800m) after rule = shop.WithDelivery. Watch which object the delegate calls the method on.
One call through a delegate, step 5 of 5. Showing Your code (rule(800m) returned 840), PriceRule object (Target: null), shop: a Shop (not used), WithDelivery (Rules.Festival: 720). In focus: PriceRule object.rule(800m)MethodTargetYour coderule(800m) returned 840PriceRule objectTarget: nullshop: a Shopnot usedWithDeliveryRules.Festival: 720Method: Festival, Target: none

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The delegate is made. rule = shop.WithDelivery converts the method group into a PriceRule object. It keeps two references: Method, the method to run, and Target, the object to run it on. Nothing has run yet.
  2. The call reaches the delegate. rule(800m) is a call of the delegate's Invoke method with the argument 800. Your code does not know which method that will be; only the delegate does.
  3. The method runs on its target. The delegate calls its Method, WithDelivery, on its Target, the shop whose fee is Rs 40. Inside the method, fee is that shop's fee.
  4. The result comes back. WithDelivery returns 800 + 40, and the delegate passes it back unchanged, so rule(800m) returned 840, the third line that basics.cs printed.
  5. A static method has no target. Before that, rule held Rules.Festival. A static method needs no object, so Target was null and the program printed "none (a static method)"; 800 became 720.

The shape is checked when the delegate is made, not when it runs. mismatch tries two assignments that look harmless:

mismatch/Program.cs C# · mismatch/Program.cs
// Two assignments the compiler refuses. Round has a second parameter, so it does not have the shape of PriceRule.
// Predicate<string> and Func<string, bool> have the same shape, but they are two different types.
PriceRule rule = Rules.Round;

Predicate<string> isBlank = string.IsNullOrWhiteSpace;
Func<string, bool> test = isBlank;

Console.WriteLine(rule(799.5m));
Console.WriteLine(test(" "));

static class Rules
{
    public static decimal Round(decimal price, int digits) => Math.Round(price, digits);
}

delegate decimal PriceRule(decimal price);
Building mismatch Python · mismatch_session.py
from session import run

# mismatch assigns a method with two parameters to PriceRule, and a Predicate<string> to a Func<string, bool>.
# dotnet run builds the project first and stops at the errors.
run("""
cd mismatch
!dotnet run
""")

Output

$ cd mismatch
$ dotnet run
mismatch/Program.cs(3,24): error CS0123: No overload for 'Round' matches delegate 'PriceRule' [mismatch/App.csproj]
mismatch/Program.cs(6,27): error CS0029: Cannot implicitly convert type 'System.Predicate<string>' to 'System.Func<string, bool>' [mismatch/App.csproj]

The build failed. Fix the build errors and run again.
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 mismatch_session.py

CS0123 refused Round, whose second parameter PriceRule does not have. CS0029 refused a Predicate<string> for a Func<string, bool>, although both take a string and return a bool: the C# specification makes delegate types name equivalent, not structurally equivalent, so two delegate types with the same shape are still two types.

Checkpoint

Check: Storing and calling a method through a delegate

  1. Question 1 of 2 basics.cs gave Rs 840 for rule = shop.WithDelivery; rule(800m), with a delivery fee of Rs 40. A second shop, cheap, has a fee of Rs 25. What does rule = cheap.WithDelivery; rule(800m) return?

    Type a number.

    A hint

    The delegate keeps the object it was made from, and the method reads that object's fee.

    Show the answer: Question 1 of 2

    Answer: 825 rupees

    A delegate made from an instance method stores the object as its Target. rule = cheap.WithDelivery makes a new delegate whose Target is cheap, so the method adds cheap's fee: 800 + 25 = 825.

  2. Question 2 of 2 With delegate decimal PriceRule(decimal price); declared, which line compiles?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: PriceRule r = price => price - 50m;

    The compiler checks a delegate's shape when the delegate is made: the parameters and the return type must match, and a delegate of another type does not convert even when its shape is the same.

Func, Action and Predicate cover most delegate shapes

Declaring a delegate type for every callback would mean a new type for every shape. .NET already has generic ones, and shapes.cs uses each family:

shapes.cs: Func, Action, Predicate and a delegate of your own C# · shapes/shapes.cs
// The built-in delegate families: Func returns a value (its last type argument), Action returns nothing, Predicate<T>
// answers yes or no about one value. A custom delegate type covers what they cannot express: an out parameter.
using System.Globalization;

Func<decimal, decimal, decimal> addGst = (price, ratePercent) => price + price * ratePercent / 100;
Action<string> log = message => Console.WriteLine($"log: {message}");
Predicate<int> isLow = units => units < 5;

log($"Rs 1000 with 18 % GST is Rs {addGst(1000m, 18m)}");
log($"3 units left, low: {isLow(3)}");

// List<T>.FindAll takes a Predicate<T>; LINQ's Count takes a Func<T, bool>. The same test, two delegate types.
List<int> stock = [12, 3, 0, 7, 4];
List<int> low = stock.FindAll(isLow);
log($"FindAll found {low.Count} low: {string.Join(", ", low)}");
log($"Count with a Func<int, bool>: {stock.Count(units => units < 5)}");

// To pass the Predicate where a Func is wanted, pass its Invoke method: a method group of the right shape.
Func<int, bool> sameTest = isLow.Invoke;
log($"Count with isLow.Invoke: {stock.Count(sameTest)}");

// Parsing hands back its result through an out parameter, which Func and Action cannot declare.
TryParser<int> parseUnits = (string text, out int value) => int.TryParse(text, out value);
// C# 14: a modifier such as out no longer needs the parameter's type.
TryParser<decimal> parsePrice = (text, out value) => decimal.TryParse(text, CultureInfo.InvariantCulture, out value);

foreach (string text in new[] { "42", "4.5", "lots" })
{
    bool isUnits = parseUnits(text, out int units);
    bool isPrice = parsePrice(text, out decimal price);
    string unitsText = isUnits ? units.ToString(CultureInfo.InvariantCulture) : "no";
    string priceText = isPrice ? price.ToString(CultureInfo.InvariantCulture) : "no";
    log($"\"{text}\": units {unitsText}, price {priceText}");
}

delegate bool TryParser<T>(string text, out T value);

Output

log: Rs 1000 with 18 % GST is Rs 1180
log: 3 units left, low: True
log: FindAll found 3 low: 3, 0, 4
log: Count with a Func<int, bool>: 3
log: Count with isLow.Invoke: 3
log: "42": units 42, price 42
log: "4.5": units no, price 4.5
log: "lots": units no, price no

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run shapes.cs

Func<decimal, decimal, decimal> takes two decimals and returns one: the return type is always the last type argument. Action<string> takes a string and returns nothing. Predicate<int> takes one value and returns a bool, the shape List<T>.FindAll asks for, which found the three low stock counts 3, 0 and 4. Microsoft’s guide to these types says it simply: use an Action type for a delegate that returns void, and a Func type for one that returns a value. Both come with up to 16 parameters.

LINQ asks for Func<int, bool> where List<T> asks for Predicate<int>, and as mismatch showed, one does not convert to the other. isLow.Invoke is the bridge: a method group of the right shape, so Count(sameTest) counted the same 3.

The generic families cannot declare an out parameter, and parsing needs one: it returns whether it worked and hands the value back through out. TryParser<T> is a delegate type of the lesson’s own for that shape. Its second lambda uses a C# 14 change: a parameter modifier such as out no longer needs the parameter’s type, so (text, out value) => … compiles. The run shows the difference between the two parsers: “4.5” is no count of units, but it is a price. The four shapes, side by side:

The callback The delegate type In shapes.cs
returns a value Func<T1, …, TResult>, result last Func<decimal, decimal, decimal> addGst
returns nothing Action<T1, …> Action<string> log
tests one value, for List<T> and Array methods Predicate<T> Predicate<int> isLow
has a ref, out or in parameter, or needs a name your own delegate type TryParser<T>

Reach for Func and Action first

A parameter typed Func<Order, decimal> tells every reader its shape at once, and callers need no new type to learn. Declare your own delegate type only for ref, out or in parameters, or when a name such as TryParser says more than the shape does.

Checkpoint

Check: Func, Action, Predicate and delegate types of your own

  1. Question 1 of 2 A callback takes an order number (int) and a status (string) and returns nothing. Which type fits it?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Action<int, string>

    Func returns a value, its last type argument; Action returns nothing. A callback with two parameters and no result is Action<int, string>.

  2. Question 2 of 2 Why does shapes.cs declare its own TryParser<T> delegate instead of using a Func?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Parsing hands its value back through an out parameter, and Func and Action cannot declare one

    Declare your own delegate type when a parameter needs ref, out or in, or when a name says more than the shape. In C# 14, a lambda for it may write (text, out value) without the parameter types.

Passing a method as an argument lets one method serve every rule

The opening’s checkout, with the rule as a parameter: Price(decimal amount, Func<decimal, decimal> rule). checkout.cs gives it five rules of four kinds, a method group, a lambda, a method that makes a rule and a rule that goes too far:

checkout.cs: one Price method, many discount rules C# · checkout/checkout.cs
// One Price method serves every discount rule, because the rule is a parameter. The amounts are made up for this lesson.
decimal cart = 2000m;

Console.WriteLine($"no discount:       Rs {Price(cart, NoDiscount)}");
Console.WriteLine($"festival 10 %:     Rs {Price(cart, Festival)}");
Console.WriteLine($"coupon Rs 100:     Rs {Price(cart, amount => amount - 100m)}");
Console.WriteLine($"member 15 %:       Rs {Price(cart, PercentOff(15))}");
Console.WriteLine($"coupon Rs 2,500:   Rs {Price(cart, amount => amount - 2500m)}");

// Rules in a list, applied one after another: the order changes the total.
Func<decimal, decimal> coupon = amount => amount - 100m;
Console.WriteLine($"10 % then Rs 100:  Rs {PriceAll(cart, [Festival, coupon])}");
Console.WriteLine($"Rs 100 then 10 %:  Rs {PriceAll(cart, [coupon, Festival])}");

// The rule is the only thing that varies; Price never goes below zero, whatever rule it is given.
static decimal Price(decimal amount, Func<decimal, decimal> rule) => Math.Max(0m, rule(amount));

static decimal PriceAll(decimal amount, Func<decimal, decimal>[] rules)
{
    foreach (Func<decimal, decimal> rule in rules)
    {
        amount = rule(amount);
    }

    return Math.Max(0m, amount);
}

static decimal NoDiscount(decimal amount) => amount;

static decimal Festival(decimal amount) => amount * 90 / 100;

// A method that makes a rule: the lambda it returns remembers percent (the next lesson explains how).
static Func<decimal, decimal> PercentOff(int percent) => amount => amount * (100 - percent) / 100;

Output

no discount:       Rs 2000
festival 10 %:     Rs 1800
coupon Rs 100:     Rs 1900
member 15 %:       Rs 1700
coupon Rs 2,500:   Rs 0
10 % then Rs 100:  Rs 1700
Rs 100 then 10 %:  Rs 1710

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run checkout.cs

Price never changed. It received NoDiscount and Festival as method groups, the coupon as a lambda, and PercentOff(15), a method that returns a new rule each time it is called: 1,700 for 15 % off 2,000. The coupon of Rs 2,500 would have taken the total below zero, and Price returned 0, because the rule that a total is never negative lives in the one method every rule passes through. Microsoft’s guide to delegate patterns describes this design as minimal coupling: the caller supplies one method, and nothing else about it needs to be known.

Writing Festival() where a rule is expected

Price(cart, Festival()) is a call of Festival, made on the spot with no amount to work on, so the build stops; the rule was meant to be called later, by Price. Pass the method group, Price(cart, Festival), and let the method that receives it decide when to call it and how often.

PriceAll takes several rules and applies them in the order of the array. Ten per cent then Rs 100 gave 1,700, and Rs 100 then ten per cent gave 1,710. Step through both orders:

Interactive · step by step The same two rules, in two orders Step through PriceAll with the festival discount and the coupon. The left column runs 10 % first, the right column the coupon first.
The same two rules, in two orders, step 6 of 6. Showing Cart: Rs 2,000, Cart: Rs 2,000, Festival: 10 % off (gives Rs 1,800), coupon: Rs 100 off (gives Rs 1,900), coupon: Rs 100 off (gives Rs 1,700), Festival: 10 % off (gives Rs 1,710).Cart: Rs 2,000rules: [Festival, coupon]Cart: Rs 2,000rules: [coupon, Festival]Festival: 10 % offgives Rs 1,800coupon: Rs 100 offgives Rs 1,900coupon: Rs 100 offgives Rs 1,700Festival: 10 % offgives Rs 1,7101,710 against 1,700

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. Two arrays of rules. Both calls start from the same cart of Rs 2,000 and the same two rules. Only the order of the array differs, and PriceAll applies the rules in that order.
  2. Ten per cent first. On the left, the first rule is Festival: 2,000 * 90 / 100 is 1,800. PriceAll stores the result and hands it to the next rule.
  3. Then the coupon. The coupon takes Rs 100 off what it receives, 1,800, and the left total is Rs 1,700.
  4. The coupon first. On the right, the coupon runs first, on the whole Rs 2,000, and leaves 1,900.
  5. Then ten per cent. Festival then takes 10 % of 1,900, so the right total is Rs 1,710: Rs 10 more than the left, because the coupon was taken off before the percentage, not after.
  6. The order is a decision. Neither total is wrong; they are two business rules. Because PriceAll applies an array in order, that decision is written in one place, the array, instead of in every caller.

Key idea

Pass the part that varies as a delegate: the method that receives it keeps the steps and the checks every rule needs, and a new rule is a new argument, not a new branch.

Checkpoint

Check: Passing methods as arguments

  1. Question 1 of 2 checkout.cs has coupon = amount => amount - 100m and Festival = amount * 90 / 100. What does PriceAll(1000m, [coupon, Festival]) return?

    Type a number.

    A hint

    PriceAll applies the rules in the order of the array, each to the result of the one before.

    Show the answer: Question 1 of 2

    Answer: 810 rupees

    The coupon runs first, 1,000 − 100 = 900, then Festival takes 10 % off that: 900 * 90 / 100 = 810. The other order would give 1,000 * 90 / 100 − 100 = 800.

  2. Question 2 of 2 A new offer arrives: Rs 50 off orders above Rs 1,000. What is the smallest change to checkout.cs that adds it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Pass a new rule: Price(cart, amount => amount > 1000m ? amount - 50m : amount)

    When the varying part is passed in as a delegate, new behaviour arrives as a new argument. The method that receives it keeps what every rule shares, such as the rule that a price is never negative.

A multicast delegate calls every target in order, and stops at the first exception

A delegate can hold more than one method. += combines two delegates into a new one whose invocation list: The methods a delegate calls, in order. += and -= build a new delegate with a longer or shorter list, while the old delegate keeps its own list. is both lists, the left one first, and calling it calls every method on the list with the same arguments. multicast.cs sends one order update to three channels:

multicast.cs: three channels, a copy, a result and a failure C# · multicast/multicast.cs
// One delegate with three targets. += does not change a delegate: it makes a new one whose invocation list has one
// more method, and the variable now refers to the new delegate.
Action<string> notify = Sms;
notify += Email;
notify += Push;
Console.WriteLine($"notify has {notify.GetInvocationList().Length} targets");
notify("Order 4519 shipped");

// A copy taken before += keeps the old list.
Action<string> before = notify;
notify += Sms;
Console.WriteLine($"after one more +=: notify has {notify.GetInvocationList().Length}, before still has {before.GetInvocationList().Length}");

// -= removes the last occurrence of a method; removing one that is not there does nothing.
notify -= Sms;
notify -= Whatsapp;
Console.WriteLine($"after -= Sms and -= Whatsapp: {notify.GetInvocationList().Length} targets");

// A Func with two targets runs both and returns the last one's result.
Func<int, int> score = n => n + 1;
score += n => n * 10;
Console.WriteLine($"score(5) returns {score(5)}");

// The middle target throws, so the third one never runs.
Action<string> fragile = Sms;
fragile += FailingEmail;
fragile += Push;
try
{
    fragile("Order 4520 shipped");
}
catch (InvalidOperationException e)
{
    Console.WriteLine($"stopped by: {e.Message}");
}

static void Sms(string text) => Console.WriteLine($"  SMS:   {text}");

static void Email(string text) => Console.WriteLine($"  email: {text}");

static void Push(string text) => Console.WriteLine($"  push:  {text}");

static void Whatsapp(string text) => Console.WriteLine($"  WhatsApp: {text}");

static void FailingEmail(string text) => throw new InvalidOperationException("the mail server refused the message");

Output

notify has 3 targets
  SMS:   Order 4519 shipped
  email: Order 4519 shipped
  push:  Order 4519 shipped
after one more +=: notify has 4, before still has 3
after -= Sms and -= Whatsapp: 3 targets
score(5) returns 50
  SMS:   Order 4520 shipped
stopped by: the mail server refused the message

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run multicast.cs

The first call ran SMS, email and push, in the order they were added. A delegate never changes once it is made: += built a new delegate with four targets and stored it in notify, while before, which still referred to the old delegate, kept its three. -= removed the last Sms, and removing Whatsapp, which was never added, did nothing and threw nothing. Every delegate in .NET supports several targets, so any delegate can become a multicast delegate: A delegate whose invocation list holds more than one method. Calling it runs every method in order with the same arguments, returns the last method's result, and stops at the first exception..

Two rules of the C# specification decide the last two results. A Func with several targets runs them all and returns the result of the last one: score(5) computed 6 and then 50, and only 50 came back.

Collecting answers from a multicast Func

Func<Order, bool> checks = InStock; checks += AddressValid; returns only AddressValid’s answer, so an order that is out of stock passes whenever its address is valid. When every target’s result matters, keep the checks in a list, or call each delegate of GetInvocationList() yourself and look at every result.

And when a target throws, the exception goes to the caller, and the targets after it do not run. Step through the failing call:

Interactive · step by step A multicast call that stops at an exception Step through fragile("Order 4520 shipped"). The left column is the delegate's invocation list, in order.
A multicast call that stops at an exception, step 5 of 5. Showing 1. Sms, 2. FailingEmail, 3. Push (never ran for this call), try { fragile(...) }, catch block. In focus: catch block.callexception1. Smsprints the SMS line2. FailingEmailthrows3. Pushnever ran for this calltry { fragile(...) }the callercatch blockInvalidOperationExceptionexception

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The call starts the list. Calling fragile calls each method of its invocation list in order, with the same argument, "Order 4520 shipped". The first is Sms.
  2. Sms runs. Sms prints "SMS: Order 4520 shipped", the only line this call printed, and returns. The delegate moves on to the next method.
  3. FailingEmail throws. FailingEmail throws InvalidOperationException. Nothing inside it catches the exception, so it leaves the delegate call at once.
  4. Push never runs. The specification says the methods further down the list are not invoked, so Push is skipped: no push line was printed for order 4520.
  5. The caller catches it. The exception reaches the caller's catch block, which printed "stopped by: the mail server refused the message". The SMS has gone out, the push has not.

Key idea

A multicast delegate calls its targets in order with the same arguments, returns only the last result, and stops at the first exception, so it suits notifications, not decisions.

Checkpoint

Check: Multicast delegates, their results and exceptions

  1. Question 1 of 2 Func<int, int> score = n => n + 1; score += n => n * 10; score += n => n - 3; What does score(5) return?

    Type a number.

    A hint

    Each target gets the same argument, 5, and only one result comes back.

    Show the answer: Question 1 of 2

    Answer: 2

    All three targets run with 5: they compute 6, 50 and 2. A delegate with several targets returns the last target's result, so score(5) is 2; the 6 and the 50 are thrown away.

  2. Question 2 of 2 notify holds Sms, Email and Push, in that order, and Email throws. notify("Order 77") runs inside try and catch. What was sent?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The SMS only: Email threw, and Push never ran

    The C# specification: if an exception occurs and is not caught within the method that was invoked, the methods further down the invocation list are not invoked.

A delegate may be null, and making one may allocate

A delegate variable with no method in it is null, and calling null throws. allocations.cs shows the safe call, then counts the heap bytes of 1,000 calls that each pass a delegate:

allocations.cs: heap bytes per call, and a null delegate C# · allocations/allocations.cs
// Heap bytes for 1,000 calls that each pass a delegate. Each measurement calls once before it reads the counter, so
// the runtime's one-time work does not show.
using System.Runtime.CompilerServices;

var shop = new Shop();
Func<decimal, decimal> stored = shop.Festival;

Console.WriteLine($"Heap bytes per call, over {Measure.Calls} calls:");
Console.WriteLine($"  a static method group               {Measure.Bytes(() => Pricing.Apply(100m, Rules.Festival)) / Measure.Calls,4}");
Console.WriteLine($"  a lambda that uses only its input   {Measure.Bytes(() => Pricing.Apply(100m, amount => amount - 10m)) / Measure.Calls,4}");
Console.WriteLine($"  an instance method group            {shop.GroupBytes() / Measure.Calls,4}");
Console.WriteLine($"  a lambda that calls an instance one {shop.LambdaBytes() / Measure.Calls,4}");
Console.WriteLine($"  a delegate made once, outside       {Measure.Bytes(() => Pricing.Apply(100m, stored)) / Measure.Calls,4}");

// A delegate variable with no method in it is null. ?.Invoke reads it once and calls it only when it is not null.
Action<string>? onSaved = null;
onSaved?.Invoke("draft 1");
Console.WriteLine($"?.Invoke on null: nothing ran, onSaved is {(onSaved is null ? "null" : "set")}");
try
{
    onSaved!("draft 1");
}
catch (NullReferenceException)
{
    Console.WriteLine("calling null directly: NullReferenceException");
}

onSaved += text => Console.WriteLine($"saved {text}");
onSaved?.Invoke("draft 2");

static class Pricing
{
    public static decimal Apply(decimal amount, Func<decimal, decimal> rule) => rule(amount);
}

static class Rules
{
    public static decimal Festival(decimal amount) => amount * 90 / 100;
}

static class Measure
{
    public const int Calls = 1000;

    // Runs `pass` once to warm up, then Calls times, and returns the heap bytes the Calls passes allocated. The
    // lambda that wraps each pass is made once, so only what happens inside a pass is counted.
    [MethodImpl(MethodImplOptions.AggressiveOptimization)]
    public static long Bytes(Action pass)
    {
        pass();
        long start = GC.GetAllocatedBytesForCurrentThread();
        for (int i = 0; i < Calls; i++)
        {
            pass();
        }

        return GC.GetAllocatedBytesForCurrentThread() - start;
    }
}

class Shop
{
    public decimal Festival(decimal amount) => amount * 90 / 100;

    // shop.Festival as a method group: each pass converts it into a new delegate that holds this shop.
    public long GroupBytes() => Measure.Bytes(() => Pricing.Apply(100m, Festival));

    // A lambda that calls an instance method uses this, so it cannot be cached either.
    public long LambdaBytes() => Measure.Bytes(() => Pricing.Apply(100m, amount => Festival(amount)));
}

Output

Heap bytes per call, over 1000 calls:
  a static method group                  0
  a lambda that uses only its input      0
  an instance method group              64
  a lambda that calls an instance one   64
  a delegate made once, outside          0
?.Invoke on null: nothing ran, onSaved is null
calling null directly: NullReferenceException
saved draft 2

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run allocations.cs

onSaved?.Invoke("draft 1") did nothing, because ?. calls Invoke only when the delegate is not null, while onSaved!("draft 1") threw NullReferenceException. += on the null variable simply stored the new delegate, and the next ?.Invoke printed “saved draft 2”. ?. also reads the variable once, so another thread that sets it to null in between cannot make the call fail: Microsoft’s reference calls this a thread-safe way to invoke a delegate, because delegate instances are immutable.

The byte counts show when a delegate is created. Passing the instance method group Festival from inside the shop made a new 64-byte delegate on every call, because each conversion builds an object that holds the method and the shop. A lambda that calls that instance method needs the shop too, and cost the same 64 bytes. The static method group cost nothing per call: since C# 11 the compiler may reuse one delegate for such a conversion, and the run shows it did. A lambda that uses only its own parameter is cached in the same way, and a delegate made once, outside the loop, is reused by every call.

Passed on every call Heap bytes per call Why
a static method group, Rules.Festival 0 the compiler caches the delegate
a lambda that uses only its input 0 cached the same way
an instance method group, Festival in the shop 64 a new delegate holds the shop each time
a lambda that calls the instance method 64 it needs the shop, so it is not cached
a delegate made once, outside the loop 0 one object for every call

Sixty-four bytes is nothing once and a steady stream of garbage in a loop that runs millions of times. Store the delegate once, in a local or a field, when the target stays the same.

Checkpoint

Check: Null delegates and the cost of making one

  1. Question 1 of 2 Action<string>? onSaved = null; Which line runs without an exception?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: onSaved?.Invoke("draft");

    A delegate variable with no method is null. ?.Invoke reads it once and calls it only when it holds a delegate.

  2. Question 2 of 2 In allocations.cs, which argument, passed on each of 1,000 calls, allocated 64 bytes per call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: The instance method group Festival, passed from inside the shop

    A delegate needs its target. A static method group and a lambda that captures nothing have none, so one cached delegate serves every call; an instance method group needs the object, and a new delegate is made each time.

Practise: a retry helper and a text pipeline

retry.cs checks two helpers that take delegates. Retry calls an attempt until it succeeds or the attempts run out, and reports each retry through an Action<int>; Pipeline turns several Func<string, string> steps into one. The comments in the file give every rule, and all 10 checks fail on the starter:

retry.cs: ten checks of Retry and Pipeline C# · practice/retry.cs
// Practice: write Retries.Retry and Pipelines.Pipeline, then run this file until all ten checks pass. What each method
// must do is in the comment above it. The order numbers and texts are made up for this exercise.
(string Check, Func<string> Run, string Expected)[] checks =
[
    ("succeeds on the first attempt", () => RetryWith(failures: 0, maxAttempts: 3), "attempt 1 succeeded; retried after none"),
    ("succeeds on the third attempt", () => RetryWith(failures: 2, maxAttempts: 3), "attempt 3 succeeded; retried after 1, 2"),
    ("gives up after maxAttempts", () => RetryWith(failures: 9, maxAttempts: 3), "no attempt succeeded; retried after 1, 2"),
    ("a single attempt allowed", () => RetryWith(failures: 9, maxAttempts: 1), "no attempt succeeded; retried after none"),
    ("maxAttempts of 0", () => Outcome(() => Retries.Retry(_ => true, 0, _ => { }).ToString()), "throws ArgumentOutOfRangeException"),
    ("an exception is not retried", ThrowingAttempt, "throws InvalidOperationException after 1 call"),
    ("steps run in order", () => Pipelines.Pipeline(s => s.Trim(), s => s.ToUpperInvariant())("  upi ref 77 "), "UPI REF 77"),
    ("the order of the steps matters", () => Pipelines.Pipeline(s => s + ".", s => s.ToUpperInvariant(), s => $"[{s}]")("paid"), "[PAID.]"),
    ("no steps give back the input", () => Pipelines.Pipeline()("unchanged"), "unchanged"),
    ("each call runs the steps again", CountedPipeline, "2 calls ran 4 steps"),
];

int passed = 0;
foreach (var (check, run, expected) in checks)
{
    string got = Outcome(run);
    bool ok = got == expected;
    if (ok)
    {
        passed++;
    }

    Console.WriteLine(ok ? $"{check}: ok" : $"{check}: FAIL, {got} instead of {expected}");
}

Console.WriteLine($"{passed} of {checks.Length} checks pass");
return passed == checks.Length ? 0 : 1;

// An attempt that fails the first `failures` times, and a log of the retries Retry reports.
static string RetryWith(int failures, int maxAttempts)
{
    var retried = new List<int>();
    int result = Retries.Retry(attempt => attempt > failures, maxAttempts, failed => retried.Add(failed));
    string outcome = result == 0 ? "no attempt succeeded" : $"attempt {result} succeeded";
    return $"{outcome}; retried after {(retried.Count == 0 ? "none" : string.Join(", ", retried))}";
}

static string ThrowingAttempt()
{
    int calls = 0;
    string outcome = Outcome(() => Retries.Retry(_ => { calls++; throw new InvalidOperationException("the bank is down"); }, 3, _ => { }).ToString());
    return $"{outcome} after {calls} call{(calls == 1 ? "" : "s")}";
}

static string CountedPipeline()
{
    int steps = 0;
    Func<string, string> pipeline = Pipelines.Pipeline(s => { steps++; return s.Trim(); }, s => { steps++; return s.ToLowerInvariant(); });
    pipeline(" A ");
    pipeline(" B ");
    return $"2 calls ran {steps} steps";
}

static string Outcome(Func<string> run)
{
    try
    {
        return run();
    }
    catch (Exception e)
    {
        return $"throws {e.GetType().Name}";
    }
}

static class Retries
{
    // Calls attempt(1), attempt(2) and so on, until a call returns true or maxAttempts calls have returned false.
    // Before each new try it calls onRetry with the number of the attempt that just failed. Returns the number of the
    // attempt that succeeded, or 0 when none did. An exception thrown by attempt is not a failed attempt: let it go
    // to the caller, without another try. maxAttempts below 1 throws ArgumentOutOfRangeException.
    public static int Retry(Func<int, bool> attempt, int maxAttempts, Action<int> onRetry)
    {
        throw new NotImplementedException();
    }
}

static class Pipelines
{
    // Returns one function that passes its input through every step, in the order given, and returns what the last
    // step returned. With no steps, it returns its input unchanged. The steps run again on every call.
    public static Func<string, string> Pipeline(params Func<string, string>[] steps)
    {
        throw new NotImplementedException();
    }
}

Output (exit status 1)

succeeds on the first attempt: FAIL, throws NotImplementedException instead of attempt 1 succeeded; retried after none
succeeds on the third attempt: FAIL, throws NotImplementedException instead of attempt 3 succeeded; retried after 1, 2
gives up after maxAttempts: FAIL, throws NotImplementedException instead of no attempt succeeded; retried after 1, 2
a single attempt allowed: FAIL, throws NotImplementedException instead of no attempt succeeded; retried after none
maxAttempts of 0: FAIL, throws NotImplementedException instead of throws ArgumentOutOfRangeException
an exception is not retried: FAIL, throws NotImplementedException after 0 calls instead of throws InvalidOperationException after 1 call
steps run in order: FAIL, throws NotImplementedException instead of UPI REF 77
the order of the steps matters: FAIL, throws NotImplementedException instead of [PAID.]
no steps give back the input: FAIL, throws NotImplementedException instead of unchanged
each call runs the steps again: FAIL, throws NotImplementedException instead of 2 calls ran 4 steps
0 of 10 checks pass

Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run retry.cs

Write Retry and Pipeline

Copy retry.cs. In Retry, throw for maxAttempts below 1 with ArgumentOutOfRangeException.ThrowIfLessThan, then loop from 1 to maxAttempts: return the attempt’s number when it succeeds, and call onRetry only when another try follows. In Pipeline, return a lambda that passes its input through each step in turn. Run dotnet run retry.cs until it prints 10 of 10 checks pass.

Then check yourself on delegates: the questions start with the idea you know least, and every answer explains itself, the wrong ones included.

Practice that adapts to you

Practise this lesson

12 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 12 What is the difference between rule(800m) and rule.Invoke(800m)?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: None: both call the method the delegate holds

    rule(800m) is shorthand for rule.Invoke(800m). Writing Invoke matters only with ?., as in rule?.Invoke(800m).

  2. Question 2 of 12 A method takes a PriceRule, and you hold a Func<decimal, decimal> named coupon. How do you pass coupon?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: Pass coupon.Invoke, a method group with the shape of PriceRule

    Delegate types with the same shape do not convert into each other, but the Invoke method of one is a method group that converts to the other, as isLow.Invoke did in shapes.cs.

  3. Question 3 of 12 Which delegate type does List<T>.FindAll take?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Predicate<T>

    List<T> and Array methods such as FindAll, Exists and RemoveAll take Predicate<T>; LINQ takes Func<T, bool>.

  4. Question 4 of 12 Func<decimal, decimal, decimal> addGst = (price, rate) => price + price * rate / 100; What does addGst(500m, 12m) return?

    Type a number.

    Show the answer: Question 4 of 12

    Answer: 560 rupees

    The two type arguments before the last are the parameters: price 500 and rate 12. 500 + 500 * 12 / 100 = 560.

  5. Question 5 of 12 A cache needs a callback that looks a key up and either returns true with the value, or false. Which declaration fits that shape?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: delegate bool TryLoad<TKey, TValue>(TKey key, out TValue value);

    The Try shape, a bool result and an out value, is the classic reason to declare a delegate type of your own.

  6. Question 6 of 12 In Price(cart, Festival), why is Festival written without parentheses?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: It passes the method itself, and Price calls it later

    A method name without parentheses, where a delegate is expected, becomes a delegate that refers to the method.

  7. Question 7 of 12 PercentOff(int percent) returns amount => amount * (100 - percent) / 100. What does Price(2000m, PercentOff(25)) return?

    Type a number.

    Show the answer: Question 7 of 12

    Answer: 1500 rupees

    PercentOff(25) returns a rule that keeps 75 %: 2,000 * 75 / 100 = 1,500, which is above zero, so Price returns it.

  8. Question 8 of 12 Action<string> a = Sms; Action<string> b = a; a += Email; How many methods does b("x") call?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: One: b still refers to the delegate that holds only Sms

    Combining or removing makes a new delegate with its own list; the old delegate keeps the list it had.

  9. Question 9 of 12 What does notify -= Whatsapp do when Whatsapp was never added to notify?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Nothing: removing a method that is not in the list is allowed

    -= removes the last occurrence of the method it names, and does nothing when the method is not there.

  10. Question 10 of 12 Three checks must all pass before an order ships, and support wants every failure message. How do you run the checks?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: Keep them in a List<Func<Order, string?>>, call each one and collect every message

    A multicast delegate suits notifications whose results do not matter. When every result matters, call each delegate yourself.

  11. Question 11 of 12 Action<string>? onSaved = null; onSaved += Log; What does onSaved hold now?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: A delegate that calls Log

    null + d is d, so events and callbacks can start as null and gain their first method with +=.

  12. Question 12 of 12 A method calls Apply(price, this.Round) 10 million times in a loop, and Round reads a field of this. How do you stop the allocations without changing Apply?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: Make the delegate once before the loop, Func<decimal, decimal> round = this.Round, and pass round

    An instance method group converts to a new delegate at every conversion. Converting once and reusing the delegate removes the allocation, the last row of the table in allocations.cs.

Interview questions

Fresher · What is a delegate in C#, and how is it different from an interface with one method?

A delegate is an object that holds a method of a given shape, its parameters and return type, and for an instance method the object to call it on. You store it, pass it and call it later like the method itself. Any method of the right shape fits, static or instance, a method group or a lambda, from any class, with no type to implement. An interface with one method asks the class to declare that it implements it, and in return the object can carry state, more members and a name. One delegate can also hold several methods and call them all in order. Use a delegate for a single operation passed in, such as a comparison or a callback, and an interface when the behaviour needs several members or state.

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    An order service combines three checks into one Func<Order, bool> with +=: stock, address and credit. Orders with a bad address still ship, and nothing logs an error. Explain what the combined delegate returns, why the bad orders pass, and how you would redesign the checks.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A pricing loop runs 2 million times a minute and passes this.ApplyDiscount to a helper on every pass. A memory profile shows millions of short-lived Func<decimal, decimal> objects. Where do they come from, and how do you remove them without making the code harder to read?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A delegate type names a shape of method; a variable of that type holds a method and the object to call it on, and calling the variable, or its Invoke method, calls that method.
  • Func returns a value (its last type argument), Action returns nothing and Predicate<T> tests one value; declare your own delegate type for ref and out parameters, because delegate types with the same shape are still different types.
  • Passing a method as an argument keeps one method for every rule: the rule varies, while the order of the steps and the checks stay in the method that calls it.
  • A multicast delegate calls every target in order with the same arguments, returns only the last result and stops at the first exception; += and -= build new delegates and never change an old one.
  • Call a delegate that may be null with ?.Invoke; converting an instance method group creates a new delegate each time, so make it once when a loop passes it over and over.

Terms in this lesson

delegate
A value that holds a method of a given shape, its parameters and its return type, so the method can be stored, passed to other code and called later. Its type is declared with the delegate keyword, or is a Func, an Action or a Predicate.
invocation list
The methods a delegate calls, in order. += and -= build a new delegate with a longer or shorter list, while the old delegate keeps its own list.
method group
A method's name written without parentheses or arguments, such as Rules.Festival. Where a delegate is expected, the compiler converts it into a delegate that calls the method, choosing the overload that fits.
multicast delegate
A delegate whose invocation list holds more than one method. Calling it runs every method in order with the same arguments, returns the last method's result, and stops at the first exception.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.