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.
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:
// 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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- 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.
- 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.
- 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.
- 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:
// 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); 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
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:
// 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
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:
// 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:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- 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.
- 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.
- Then the coupon. The coupon takes Rs 100 off what it receives, 1,800, and the left total is Rs 1,700.
- The coupon first. On the right, the coupon runs first, on the whole Rs 2,000, and leaves 1,900.
- 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.
- 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
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:
// 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:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- 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.
- Sms runs. Sms prints "SMS: Order 4520 shipped", the only line this call printed, and returns. The delegate moves on to the next method.
- FailingEmail throws. FailingEmail throws InvalidOperationException. Nothing inside it catches the exception, so it leaves the delegate call at once.
- 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.
- 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
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:
// 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
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:
// 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.
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.
-
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.
-
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?
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
Invokemethod, calls that method. Funcreturns a value (its last type argument),Actionreturns nothing andPredicate<T>tests one value; declare your own delegate type forrefandoutparameters, 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
- Introduction to delegates and events (Microsoft Learn)
- System.Delegate and the delegate keyword (Microsoft Learn)
- Strongly typed delegates (Microsoft Learn)
- Common patterns for delegates (Microsoft Learn)
- C# language specification: delegates (Microsoft Learn)
- Lambda expressions: natural type of a lambda expression (Microsoft Learn)
- Member access operators: thread-safe delegate invocation (Microsoft Learn)
- What's new in C# 14: simple lambda parameters with modifiers (Microsoft Learn)
- The history of C#: version 11 (Microsoft Learn)
- C# language specification: method group conversions (Microsoft Learn)
- List<T>.FindAll(Predicate<T>) method (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress