Delegates, Lambdas and Events
Treating code as data in C#: delegates, the Func and Action types, lambda expressions and closures, and events for notifying other code.
Methods as values
Some methods need to be told not only what data to work on,
but also what to do with it: a sort needs to know how to
compare two items, a filter needs a test, a button needs to know
what to run when it is clicked. Languages solve this by letting you
pass code as an argument — a
callback — to a
higher-order function. In Python and JavaScript, functions are simply objects you can
pass around. C# is statically typed, so it needs a type for
"a method that takes a decimal and returns a
decimal". That type is a delegate.
Delegates
A delegate is a type that describes
a method signature — its parameter types and return type — and a
delegate instance holds a reference to a method with that signature.
Calling the delegate calls the method. The
delegate keyword declares a delegate type:
PriceRule rule = StandardPrice;
Console.WriteLine(rule(250m).ToString("F2"));
rule = NightPrice; // the same variable, another method
Console.WriteLine(rule(250m).ToString("F2"));
static decimal StandardPrice(decimal kwh) => kwh * 0.32m;
static decimal NightPrice(decimal kwh) => kwh * 0.24m;
delegate decimal PriceRule(decimal kwh);
80.00 60.00
PriceRule accepts any method that takes one
decimal and returns a decimal, and nothing
else: assigning a method with a different signature is a compile
error. Because the delegate is a normal value, it can be stored in a
field, put in a list, or passed to another method.
Method groups
In rule = NightPrice; the method name is written
without parentheses. That is a
method group: the name of a
method (with all its overloads) used as a value. The compiler picks
the overload that matches the delegate type and creates the delegate
for you. Writing NightPrice() instead would
call the method, which is a different thing.
string[] customers = ["mei chen", "amara okafor"];
// Method groups: a method name used where a delegate is expected
Func<decimal, decimal> round = Math.Round; // picks Round(decimal)
Action<string> print = Console.WriteLine; // picks WriteLine(string)
Console.WriteLine(round(12.345m));
Array.ForEach(customers, print);
Array.ForEach(customers, Shout);
static void Shout(string text) => Console.WriteLine(text.ToUpperInvariant());
12 mei chen amara okafor MEI CHEN AMARA OKAFOR
Func and Action
Declaring a delegate type for every signature would be tedious, so .NET provides generic ones (see Generics) that cover almost every case:
-
A
Funcdelegate returns a value. The last type argument is the return type, the ones before it are the parameter types:Func<decimal>takes nothing and returns adecimal;Func<string, int, bool>takes astringand anintand returns abool. -
An
Actiondelegate returns nothing (void). All type arguments are parameter types:Actiontakes nothing,Action<string>takes astring.
Both exist with up to 16 parameters. A few specialized delegates
remain common in older APIs: Predicate<T> (a test
that returns bool, used by
List<T>.FindAll) and
Comparison<T> (used by
List<T>.Sort).
decimal[] usage = [180m, 240m, 410m];
Report(usage, StandardPrice, Console.WriteLine);
Report(usage, NightPrice, line => Console.WriteLine($"[night] {line}"));
static void Report(decimal[] kwhValues, Func<decimal, decimal> price, Action<string> output)
{
foreach (decimal kwh in kwhValues)
{
output($"{kwh} kWh costs {price(kwh):F2}");
}
}
static decimal StandardPrice(decimal kwh) => kwh * 0.32m;
static decimal NightPrice(decimal kwh) => kwh * 0.24m;
180 kWh costs 57.60 240 kWh costs 76.80 410 kWh costs 131.20 [night] 180 kWh costs 43.20 [night] 240 kWh costs 57.60 [night] 410 kWh costs 98.40
Report does not know how prices are calculated or where
the lines go; the caller decides by passing two delegates. That is
the main use of delegates: separating what a method does
from details that the caller should choose.
Lambda expressions
Writing a named method for every small piece of behavior is clumsy.
A
lambda expression
creates an anonymous method right where it is needed. The
=> ("goes to") separates the parameters from the
body. The compiler turns it into a delegate of the type the context
expects, and infers the parameter types from that type.
kwh => kwh * 0.32m one parameter, expression body
(kwh, tariff) => kwh * tariff several parameters: parentheses
() => DateTime.UtcNow no parameters
(decimal kwh) => kwh * 0.32m explicit parameter type
reading => { var c = reading - 2; return c * 1.8; } statement body with return
var tickets = new List<string> { "INC-10233", "REQ-2207", "INC-10231", "INC-10232" };
List<string> incidents = tickets.FindAll(t => t.StartsWith("INC-"));
incidents.Sort((a, b) => b.CompareTo(a)); // newest (highest) first
Console.WriteLine(string.Join(", ", incidents));
Func<double, double> toFahrenheit = celsius => celsius * 9 / 5 + 32;
Console.WriteLine(toFahrenheit(21.5));
Func<decimal, decimal, string> describe = (kwh, tariff) =>
{
decimal cost = kwh * tariff;
return cost > 100m ? $"high: {cost:F2}" : $"normal: {cost:F2}";
};
Console.WriteLine(describe(410m, 0.32m));
var isWeekend = (DateOnly d) => d.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday;
Console.WriteLine(isWeekend(new DateOnly(2026, 10, 3)));
INC-10233, INC-10232, INC-10231 70.7 high: 131.20 True
The last lambda is assigned to var. Since C# 10 that
works when the parameter types are written out: the compiler gives
the lambda a "natural type", here
Func<DateOnly, bool>. Without explicit parameter
types it cannot know what d is. Lambdas are everywhere
in modern C#, above all in LINQ:
Where(t => …), Select(r => …),
OrderBy(c => …).
Closures: capturing variables
A lambda can use local variables and parameters of the method it is written in. It does not copy their values: it captures the variable itself, which stays alive as long as the delegate does — a closure. A later change to the variable is visible inside the lambda, and the lambda can change it too.
decimal tariff = 0.32m;
Func<decimal, decimal> cost = kwh => kwh * tariff;
Console.WriteLine(cost(100m));
tariff = 0.35m; // the lambda sees the new value
Console.WriteLine(cost(100m));
Func<string> nextTicket = MakeCounter("INC-", 10231);
Console.WriteLine(nextTicket());
Console.WriteLine(nextTicket());
static Func<string> MakeCounter(string prefix, int start)
{
int next = start; // lives on after MakeCounter returns
return () => $"{prefix}{next++}";
}
32.00 35.00 INC-10231 INC-10232
var fromFor = new List<Action>();
for (int i = 1; i <= 3; i++)
{
fromFor.Add(() => Console.Write($"meter {i} "));
}
foreach (Action show in fromFor) show();
Console.WriteLine();
var fromForeach = new List<Action>();
foreach (int meter in new[] { 1, 2, 3 })
{
fromForeach.Add(() => Console.Write($"meter {meter} "));
}
foreach (Action show in fromForeach) show();
Console.WriteLine();
meter 4 meter 4 meter 4 meter 1 meter 2 meter 3
Multicast delegates
A delegate can hold more than one method. The
+= operator adds a method to its
invocation list and -= removes it; calling the
delegate calls them all, in the order they were added.
Action<string>? notify = null;
notify += message => Console.WriteLine($"SMS: {message}");
notify += message => Console.WriteLine($"Email: {message}");
notify += LogMessage;
notify?.Invoke("Planned maintenance on GW-07 at 22:00");
notify -= LogMessage;
notify?.Invoke("Maintenance finished");
static void LogMessage(string message) => Console.WriteLine($"Log: {message}");
SMS: Planned maintenance on GW-07 at 22:00 Email: Planned maintenance on GW-07 at 22:00 Log: Planned maintenance on GW-07 at 22:00 SMS: Maintenance finished Email: Maintenance finished
A delegate variable with no methods is null, and
calling null throws a
NullReferenceException. That is why the code uses
notify?.Invoke(…): Invoke is the explicit
form of the call, and ?. skips it when nobody is
listening (see
Nullable Reference Types and Null Safety). Multicasting fits Action best: with a
Func, all methods run but only the return value of the
last one is kept.
Events
Multicast delegates are exactly what "tell everyone who is
interested that something happened" needs — but a public delegate
field would let any code overwrite it with = (removing
all other subscribers) or call it, pretending the event happened. A
C# event, declared with the
event keyword, is a delegate with those doors closed:
outside the class you can only subscribe with += and
unsubscribe with -=. Only the declaring class can raise
it. This is the publisher–subscriber pattern.
+=; when the
publisher raises the event, every handler is called with the
sender and the event data.
.NET has a convention for event signatures: the delegate type is
EventHandler (no data) or
EventHandler<TEventArgs>, and each handler
receives two arguments — sender, the object that raised
the event, and e, an object with the event data, by
convention a class derived from EventArgs.
var meter = new SmartMeter("GW-07");
meter.ThresholdExceeded += (sender, e) => Console.WriteLine($" ALERT: {e.Kwh} kWh");
meter.ThresholdExceeded += LogUsage;
meter.Record(3.2m);
meter.Record(5.8m);
meter.ThresholdExceeded -= LogUsage; // unsubscribe
meter.Record(6.1m);
static void LogUsage(object? sender, UsageEventArgs e)
{
var source = (SmartMeter)sender!;
Console.WriteLine($" log: {source.Id} above {source.Threshold} kWh");
}
class UsageEventArgs : EventArgs
{
public UsageEventArgs(decimal kwh) => Kwh = kwh;
public decimal Kwh { get; }
}
class SmartMeter
{
public SmartMeter(string id) => Id = id;
public string Id { get; }
public decimal Threshold { get; } = 5m;
public event EventHandler<UsageEventArgs>? ThresholdExceeded;
public void Record(decimal kwh)
{
Console.WriteLine($"{Id}: {kwh} kWh");
if (kwh > Threshold)
{
ThresholdExceeded?.Invoke(this, new UsageEventArgs(kwh));
}
}
}
GW-07: 3.2 kWh GW-07: 5.8 kWh ALERT: 5.8 kWh log: GW-07 above 5 kWh GW-07: 6.1 kWh ALERT: 6.1 kWh
The meter does not know who listens or what they do with the
information; it only raises the event. New subscribers can be added
without changing SmartMeter. And the compiler enforces
the rule that only the meter may raise it:
var meter = new SmartMeter();
meter.ThresholdExceeded?.Invoke(meter, EventArgs.Empty);
class SmartMeter
{
public event EventHandler? ThresholdExceeded;
}
example.cs(2,7): error CS0070: The event 'SmartMeter.ThresholdExceeded' can only appear on the left hand side of += or -= (except when used from within the type 'SmartMeter')
You will meet events in desktop user interfaces (button.Click += …
in Windows Forms and WPF), in
FileSystemWatcher (Changed,
Created), and in many libraries. The idea matches
events in the browser, where addEventListener plays the role of
+=.
Where you meet delegates
-
LINQ: every
Where,SelectandOrderBytakes aFunc— see LINQ. -
Collections:
List<T>.Sort,FindAll,RemoveAll,ConvertAll,Array.ForEach. -
Asynchronous code:
Task.Run(() => …)takes the work to run as a delegate — see Asynchronous Programming with async and await. -
Web APIs: in ASP.NET Core minimal APIs, a route
is a lambda:
app.MapGet("/status", () => "OK"); -
Configuration: many libraries take an
Action<TOptions>to configure themselves, written asoptions => { … }.