Back to Blog
C#

Practical C# Delegate Usage

c# delegate usage: Understand C# delegates through practical examples: syntax, multicast behavior, Func/Action types, and when to use them for callbacks and events.

DelegatesC#Event HandlingLambda ExpressionsMethod Groups
Illustration of a C# delegate linking a method to an event, with abstract method blocks and a connection arrow.

A delegate in C# is a type that holds a reference to a method with a specific signature. When you need to pass a method as an argument, store a callback for later invocation, or implement a simple event mechanism, delegate types give you a type-safe way to do so. The core c# delegate usage pattern involves declaring a delegate type, creating an instance that points to a compatible method, and invoking that method through the delegate instance.

Declaring and Invoking a Delegate

Declaring a delegate is similar to declaring a method signature, but with the delegate keyword. For example:

public delegate int Operation(int a, int b);

This defines a delegate type named Operation that can reference any method taking two int parameters and returning an int. You can then create an instance and invoke it:

public class Calculator { public static int Add(int x, int y) => x + y; public static void Run() { Operation op = Add; int result = op(3, 4); Console.WriteLine(result); // 7 } }

The line Operation op = Add; uses a method group conversion to create a delegate instance. The invocation op(3, 4) calls the referenced method. This is the most basic form of delegate usage, but it already shows the key idea: the delegate decouples the caller from the exact method being called.

Passing Methods as Arguments

One of the most common use cases for delegates is passing behavior into a method. Instead of hard-coding a comparison or a transformation, you can accept a delegate parameter. For instance, consider a method that applies a transformation to each element in a list:

public static List<int> Transform(List<int> input, Operation op) { var result = new List<int>(); foreach (var item in input) { result.Add(op(item, 1)); } return result; }

Callers can then decide what the transformation does:

var numbers = new List<int> { 1, 2, 3 }; var incremented = Transform(numbers, (a, b) => a + b);

Here the lambda expression (a, b) => a + b is converted to the Operation delegate type. This makes the method flexible without needing subclasses or interfaces for every possible behavior.

Multicast Delegates and Chaining

A delegate instance can hold references to multiple methods, forming an invocation list. When you invoke the delegate, each method is called in order. This is useful for event-like notifications where multiple handlers need to respond to the same signal.

public delegate void Notifier(string message); public class Logger { public static void LogToConsole(string msg) => Console.WriteLine($"Console: {msg}"); public static void LogToFile(string msg) => File.AppendAllText("log.txt", $"{msg}{Environment.NewLine}"); }

You can combine them using the + operator or the += syntax:

Notifier notify = Logger.LogToConsole; notify += Logger.LogToFile; notify("Application started");

This invokes both LogToConsole and LogToFile. The += syntax is commonly used when subscribing to events. Removing a method from the invocation list is done with -=. Be aware that if any method in the chain throws an exception, the remaining methods are not executed. This behavior is critical when using multicast delegates for logging or notifications where one failure should not stop the rest.

Func and Action: Built-in Generic Delegates

Instead of declaring custom delegate types, you can use the built-in generic delegates. Action is for methods that return void, and Func is for methods that return a value. Func takes up to 16 input parameters, with the last type parameter being the return type.

Func<int, int, int> addFunc = (a, b) => a + b; Action<string> printAction = msg => Console.WriteLine(msg); int sum = addFunc(5, 6); printAction($"Sum is {sum}");

These types are part of the .NET base class library and are widely used in LINQ methods such as Where, Select, and Aggregate. For example, Enumerable.Select<TSource, TResult> takes a Func<TSource, TResult> as a parameter. Using Func and Action reduces the need for custom delegate declarations, making the code cleaner and more discoverable.

Delegate Compatibility and Covariance

Method groups and lambda expressions must have a return type that matches the delegate's return type, but there is some flexibility with reference types. Starting with C# 2.0, delegates support covariance and contravariance. Covariance allows a method with a more derived return type to be assigned to a delegate that expects a less derived return type. Contravariance allows a method with a less derived parameter type to be assigned to a delegate that expects a more derived parameter type.

public class Animal { } public class Dog : Animal { } public static Dog GetDog() => new Dog(); Func<Animal> getAnimal = GetDog; // covariance

Contravariance is useful when you have a delegate expecting a Dog parameter and a method that can handle Animal:

public static void HandleAnimal(Animal a) { } Action<Dog> handleDog = HandleAnimal; // contravariance

This compatibility rules are enforced at compile time, preventing many runtime casting errors.

Closures and Captured Variables

Lambda expressions can capture variables from the surrounding scope. These captured variables are called closures. The delegate retains access to those variables even after the method that created the lambda has returned. This is a powerful feature but requires attention: each captured variable is stored in a compiler-generated class, and the timing of the closure's lifetime can affect memory usage.

public static Action CreateCounter() { int count = 0; return () => count++; } var counter = CreateCounter(); counter(); // 0 counter(); // 1

Here the lambda captures count. Even though CreateCounter has returned, the Action instance still references that variable and can modify it. This behavior is essential for implementing stateful callbacks, but it also means the captured variable lives as long as the delegate does, so be mindful if you capture large objects.

Delegate Usage in Events

Events are built on delegates but add a layer of encapsulation. When you declare an event, the delegate is protected from being invoked or reassigned by external code; only the containing class can raise the event, while subscribers can only add or remove handlers.

public class Button { public event EventHandler? Clicked; public void SimulateClick() { Clicked?.Invoke(this, EventArgs.Empty); } }

Using ?.Invoke is a thread-safe way to check for subscribers and raise the event, avoiding a NullReferenceException when no handler is attached. In this model, the delegate (EventHandler) is the underlying mechanism, but the event keyword enforces proper access patterns. Understanding c# delegate usage is crucial for working with events because events are essentially specialized delegates.

Performance and Allocation Considerations

Delegates introduce a small overhead compared to direct method calls, mainly due to the indirection and the allocation of the delegate instance itself. In hot paths or performance-critical code, you should avoid creating a new delegate on every loop iteration if possible. For example, if you have a loop that invokes a lambda capturing a variable, the compiler may allocate a closure object each time.

for (int i = 0; i < 100; i++) { // Each iteration creates a new closure if the lambda captures 'i' Task.Run(() => Console.WriteLine(i)); }

Here, each Task.Run call receives a different Action that captures the current value of i. While this works, it allocates a new delegate and closure per iteration. If you are doing this millions of times, it can add pressure on the garbage collector. In many cases, the overhead is negligible, but for extremely tight loops, consider reusing a delegate that does not capture variables or using a struct-based approach.

Common Pitfalls When Using Delegates

One frequent mistake is assuming that a delegate instance is non-null. Invoking a null delegate throws a NullReferenceException. Always check for null or use the null-conditional operator as shown earlier. Another pitfall is not understanding the multicast behavior: when you combine delegates with +=, the return value of the invoked delegate is the last method's return value; the other return values are discarded. This is a problem if you expect all results to be aggregated.

Func<int> a = () => 1; Func<int> b = () => 2; Func<int> combined = a + b; int result = combined(); // result is 2, not 1+2

To get all results, you must invoke the invocation list manually using GetInvocationList(). Also, if a method in the invocation list throws an exception, subsequent methods are skipped, which can be surprising in logging scenarios.

Delegates are also not interchangeable with interfaces. While both allow runtime dispatch, interfaces offer a richer contract with multiple members and are often more maintainable for large sets of related operations. Use delegates for lightweight callbacks or when you need to pass a single method as a strategy.

Choosing Between Delegate and Interface

When deciding between a delegate and an interface, consider the number of methods in the contract. If you only need a single method, a delegate is typically simpler. If you need to model a set of related operations, an interface is clearer. For example, a repository interface with Get, Save, and Delete methods would be awkward to express as a delegate. On the other hand, a validation callback that takes an entity and returns a boolean is a natural fit for Func<T, bool>.

Delegates also support anonymous methods and lambda expressions, which can be written inline, improving readability when the logic is short. Interfaces force you to create a named class, which adds boilerplate. Yet, interfaces support polymorphism and can be implemented by multiple classes, which is useful when the behavior is complex.

The choice often comes down to the scope and complexity of the behavior. For a one-off callback, use a delegate. For a reusable abstraction with multiple related operations, use an interface.

c# delegate usage: Practical Usage and Code Examples | RYUSLOG DEV