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 Func delegate 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 a decimal; Func<string, int, bool> takes a string and an int and returns a bool.
  • An Action delegate returns nothing (void). All type arguments are parameter types: Action takes nothing, Action<string> takes a string.

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.

The publisher SmartMeter declares the event ThresholdExceeded. Two subscribers have attached a handler with plus-equals: an alert lambda and a LogUsage method. When the meter raises the event, it calls both handlers in order, passing itself as sender and a UsageEventArgs object with the reading. Publisher SmartMeter event ThresholdExceeded Subscriber 1 alert lambda Subscriber 2 LogUsage method (sender, e)
Subscribers attach handlers with +=; 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, Select and OrderBy takes a Func — 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 as options => { … }.