C# Delegates
A delegate in C# is a type-safe object that stores a reference to a method. Delegates let you pass behavior around like data, which is the foundation of callbacks, events, LINQ predicates, and many clean extension points in .NET.
A delegate does not mean any method at all; it means a method with a specific parameter list and return type. That type checking is what makes delegates safer than loosely named callbacks or reflection-based calls.
Overview: How Delegates Work
A delegate type describes a method signature. If a method has the same return type and compatible parameters, an instance of that delegate can point to the method. Later, calling the delegate invokes the stored method.
Internally, delegate instances are reference types derived from System.MulticastDelegate. A delegate object stores two important pieces of information: the method to call and, for instance methods, the target object whose method should be called. For a static method, there is no target object; for an instance method, the delegate keeps a reference to that object.
Delegates are immutable. When you combine delegates with + or remove one with -, C# creates a new delegate instance with a changed invocation list. This matters for events and callback chains because assigning the result is what changes the variable.
Most delegates are single-cast, meaning the invocation list contains one method. Delegates can also be multicast, meaning several methods are called in order. Multicast delegates are useful for notifications, but they should usually return void, because only the final return value is returned to the caller.
Delegates are commonly created from method groups, anonymous methods, or lambda expressions. Modern C# code often uses built-in generic delegate types such as Func<TResult>, Func<T, TResult>, Action<T>, and Predicate<T> instead of declaring a custom delegate every time.
Syntax
delegate TResult Transformer<TInput, TResult>(TInput value);
Transformer<int, string> formatter = FormatNumber;
string text = formatter(42);
| Part | Meaning |
|---|---|
delegate |
Declares a delegate type rather than a method body. |
TResult |
The return type expected from any compatible method. |
Transformer<TInput, TResult> |
The delegate type name and its generic type parameters. |
(TInput value) |
The parameter list that compatible methods must match. |
formatter(42) |
Invokes the method currently stored in the delegate variable. |
Delegate compatibility is based on the method signature, not the method name. A method named FormatNumber, Convert, or MakeLabel can all be assigned if the parameters and return type match the delegate type.
Examples
A Simple Custom Delegate
using System;
public delegate int Operation(int left, int right);
class Program
{
static int Add(int left, int right)
{
return left + right;
}
static int Multiply(int left, int right)
{
return left * right;
}
static void Main()
{
Operation operation = Add;
Console.WriteLine(operation(3, 4));
operation = Multiply;
Console.WriteLine(operation(3, 4));
}
}
Output:
7
12
The variable operation can hold any method that takes two int values and returns an int. The call syntax stays the same even when the stored method changes, which is what makes delegates useful for choosing behavior at runtime.
Multicast Delegates for Notifications
using System;
public delegate void Notifier(string message);
class Program
{
static void WriteToConsole(string message)
{
Console.WriteLine($"Console: {message}");
}
static void WriteToAudit(string message)
{
Console.WriteLine($"Audit: {message.ToUpper()}");
}
static void Main()
{
Notifier notify = WriteToConsole;
notify += WriteToAudit;
notify("order shipped");
}
}
Output:
Console: order shipped
Audit: ORDER SHIPPED
Here, notify contains an invocation list with two methods. Calling the delegate calls WriteToConsole first, then WriteToAudit. This pattern is common in event-like notification code because each subscriber can react independently.
Using a Delegate as a Callback
using System;
using System.Collections.Generic;
public delegate bool ProductFilter(Product product);
public record Product(string Name, decimal Price, bool InStock);
class Program
{
static List<Product> FindProducts(List<Product> products, ProductFilter filter)
{
List<Product> matches = new List<Product>();
foreach (Product product in products)
{
if (filter(product))
{
matches.Add(product);
}
}
return matches;
}
static void Main()
{
List<Product> products = new List<Product>
{
new Product("Keyboard", 49.99m, true),
new Product("Monitor", 219.00m, false),
new Product("Mouse", 24.50m, true)
};
List<Product> availableBudgetItems = FindProducts(
products,
product => product.InStock && product.Price < 50m);
foreach (Product product in availableBudgetItems)
{
Console.WriteLine(product.Name);
}
}
}
Output:
Keyboard
Mouse
FindProducts does not hard-code the rule for selecting products. Instead, it receives a delegate named filter. The caller supplies a lambda expression, and the method calls that lambda for each product. This is the same idea used by LINQ methods such as Where.
Built-in Delegate Types
using System;
class Program
{
static void Main()
{
Func<int, int, int> add = (left, right) => left + right;
Action<string> log = message => Console.WriteLine($"LOG: {message}");
Predicate<int> isEven = value => value % 2 == 0;
Console.WriteLine(add(8, 5));
log("calculation complete");
Console.WriteLine(isEven(13));
Console.WriteLine(isEven(14));
}
}
Output:
13
LOG: calculation complete
False
True
Func represents methods that return a value. Its last generic type argument is the return type. Action represents methods that return void. Predicate<T> is a specialized delegate for a method that takes a T and returns bool.
How Delegates Work Step by Step
- The compiler sees a delegate declaration and emits a sealed delegate type derived from
MulticastDelegate. - When you assign a method group such as
Add, the compiler verifies that the method signature is compatible with the delegate type. - At runtime, a delegate object is created with a method pointer and, when needed, a target object reference.
- When you invoke the delegate, the CLR dispatches the call to the stored method using the invocation list.
- If the delegate is multicast, each method in the list is invoked in order. If one method throws an exception, later methods are not called unless you handle the exception manually.
Lambda expressions usually compile to delegate instances too. If a lambda captures a local variable, the compiler creates a hidden closure object to store that variable. This is powerful, but it means captured variables can outlive the method call where they were originally declared.
Common Mistakes
Assigning an Incompatible Method
using System;
public delegate int Calculator(int left, int right);
class Program
{
static string Join(int left, int right)
{
return $"{left},{right}";
}
static void Main()
{
Calculator calculator = Join;
}
}
This does not compile because Calculator requires an int return value, but Join returns string. The fix is to use a compatible method or change the delegate type.
using System;
public delegate int Calculator(int left, int right);
class Program
{
static int Add(int left, int right)
{
return left + right;
}
static void Main()
{
Calculator calculator = Add;
Console.WriteLine(calculator(10, 5));
}
}
Output:
15
Expecting Captured Loop Variables to Freeze Automatically
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<Action> actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
foreach (Action action in actions)
{
action();
}
}
}
Output:
3
3
3
The lambda captures the variable i, not a separate value for each iteration. By the time the actions run, the loop has finished and i is 3. Create a local copy inside the loop when each delegate needs its own value.
using System;
using System.Collections.Generic;
class Program
{
static void Main()
{
List<Action> actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
int captured = i;
actions.Add(() => Console.WriteLine(captured));
}
foreach (Action action in actions)
{
action();
}
}
}
Output:
0
1
2
Forgetting Null Checks
A delegate variable can be null if no method has been assigned. Calling a null delegate throws NullReferenceException. Use the null-conditional invocation operator when a callback is optional: completed?.Invoke().
Best Practices
- Use custom delegate types when the name communicates a domain concept, such as
PriceRuleorProductFilter. - Use
Func,Action, andPredicatefor small local callbacks where a custom name would add little value. - Prefer
voiddelegates for multicast scenarios so callers do not accidentally depend on the last return value. - Keep delegate signatures small and clear. If a delegate needs many parameters, consider passing a request object or record.
- Use
?.Invoke(...)for optional callbacks, especially when a delegate may have no subscribers. - Avoid surprising closures over mutable variables. Copy loop values when each delegate should remember a separate value.
- For public APIs, document when the callback is called, whether it may be called more than once, and how exceptions are handled.
Practice Exercises
- Declare a delegate named
TemperatureFormatterthat takes adoubleand returns astring. Assign it two different methods: one for Celsius and one for Fahrenheit. - Write a method named
Repeatthat takes anint countand anAction<int>. It should call the action once for each number from1throughcount. - Create a list of names and use a
Predicate<string>to print only names that start with the letterA. Hint:StartsWithreturns a Boolean value.
Summary
- A delegate is a type-safe reference to a method with a specific signature.
- Delegates can point to static methods, instance methods, anonymous methods, or lambda expressions.
- Multicast delegates call several methods in order and are best suited for
voidnotifications. Func,Action, andPredicatecover many everyday delegate needs.- Closures capture variables, not just values, so loop captures need care.
- Delegates are the core mechanism behind callbacks, many LINQ operations, and C# events.
