1. Callback and asynchronous programming
Callback (callback) is a mechanism for passing a method that should be invoked when some operation completes. It's often used in asynchronous operations, timers, data processing, and user interfaces.
Example 1: Asynchronous operation with a callback
Say we have an app where the user types a query and we get the result with some delay (for example, from the internet). After receiving the data we want to update the screen.
// Delegate for the callback
public delegate void DataReceivedHandler(string result);
// Mechanism to download data asynchronously (simulation)
public void DownloadDataAsync(DataReceivedHandler callback)
{
// Suppose the download takes time (simulate with a timer)
Task.Delay(1000).ContinueWith(_ =>
{
string data = "Search results: <data>";
callback(data); // Invoke the callback delegate
});
}
// Usage:
DownloadDataAsync(result =>
{
Console.WriteLine("Received: " + result);
});
This approach lets you write very flexible code where the logic after getting the result is completely separated from the mechanics of getting the data.
2. Delegates as method parameters: strategy and comparators
A common task: let the user pass "logic" (a function) into your method so they can define how to compare, filter, or transform items.
Example 2: Implementing the Strategy pattern via delegates
Suppose we have a sorting routine, but we want the user to be able to sort differently — by name, by date, by size, etc.
public delegate bool CompareFunc(int a, int b);
public void BubbleSort(int[] arr, CompareFunc compare)
{
for (int i = 0; i < arr.Length; i++)
{
for (int j = 0; j < arr.Length - 1; j++)
{
if (compare(arr[j], arr[j + 1]))
{
// Swap
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}
// Sort from larger to smaller
CompareFunc descending = (a, b) => a < b;
// Usage
int[] numbers = { 3, 1, 4, 2 };
BubbleSort(numbers, descending);
Console.WriteLine(string.Join(", ", numbers)); // Outputs: 4, 3, 2, 1
This technique is a universal way to inject your "strategy" into someone else's code without changing its source.
3. Anonymous methods, lambda expressions and delegates
As C# evolved it became annoying to declare a separate class or method for every task. Fortunately, anonymous methods and lambda expressions appeared, allowing you to create delegates "on the fly".
Example 3: Lambda as a delegate
Func<int, int, int> operation = (x, y) => x * y;
int result = operation(3, 5); // 15
Example 4: Choosing an operation by name (switch + Delegates)
Func<int, int, int> op;
string userInput = "sum"; // "sub", "mul", "div"
switch (userInput)
{
case "sum": op = (a, b) => a + b; break;
case "sub": op = (a, b) => a - b; break;
case "mul": op = (a, b) => a * b; break;
case "div": op = (a, b) => a / b; break;
default: throw new Exception("Unknown operation!");
}
Console.WriteLine(op(6, 2));
Don't forget input validation — delegates give flexibility and readability here.
4. Delegates and chains of handlers ("chain of responsibility")
Since delegates support multicast, they make it easy to build chains of handlers.
Example 5: A chain of filters
Imagine we have "filters" that should process a string.
public delegate string StringFilter(string input);
string RemoveDigits(string input) => new string(input.Where(ch => !char.IsDigit(ch)).ToArray());
string ToUpper(string input) => input.ToUpper();
StringFilter filters = RemoveDigits;
filters += ToUpper;
// Delegate will pass the string through all filters
string text = "Privet123";
foreach (StringFilter filter in filters.GetInvocationList())
{
text = filter(text);
}
Console.WriteLine(text); // Outputs: "PRIVET"
Important: if you call just filters(text), you'll get the return value of only the last handler, not the whole chain! If you need value "flow", iterate explicitly via GetInvocationList() as shown above.
5. Delegates for dynamically binding behavior at runtime
Before, replacing one behavior with another required creating separate classes and interfaces. With delegates and lambda expressions you can express a lot of "small" polymorphism as functions.
Example 6: Robot behavior with a dynamic command
public class Robot
{
public event Action<string>? OnCommandReceived;
public void ReceiveCommand(string command)
{
OnCommandReceived?.Invoke(command);
}
}
// Usage:
var robot = new Robot();
robot.OnCommandReceived += cmd => Console.WriteLine($"Robot executes: {cmd}");
robot.OnCommandReceived += cmd =>
{
if (cmd == "TurnOn")
Console.WriteLine("Booting system...");
};
// Try
robot.ReceiveCommand("TurnOn");
robot.ReceiveCommand("Move forward");
This pattern is often used in tests, prototypes, DI (dependency injection) containers and for passing business logic via parameters.
6. Delegates as subscriptions to state
Suppose we have a class that holds some state, and when it changes we want to notify all subscribers. Thanks to delegates (and events) — it's trivial.
Example 7: A class with change subscription
public class Notifier<T>
{
private T _value = default!;
public event Action<T>? ValueChanged;
public T Value
{
get => _value;
set
{
if (!Equals(_value, value))
{
_value = value;
ValueChanged?.Invoke(_value);
}
}
}
}
// Usage:
var intValue = new Notifier<int>();
intValue.ValueChanged += v => Console.WriteLine($"New value: {v}");
intValue.Value = 5; // Event is triggered
intValue.Value = 10;
This approach is basically "reactive programming in a tiny form", the basis for MVVM, data binding, and many modern UI frameworks.
7. Delegates, closures and lexical scope
Lambda expressions and anonymous methods can capture variables from the surrounding context (closure). That's convenient, but sometimes leads to unexpected bugs.
Example 8: Capturing a variable and the "loop trap"
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var a in actions)
a(); // Will print 3 three times (!)
Why? Because the closure references the same variable i, which after the loop equals 3. What if you need to remember the values 0, 1, 2?
for (int i = 0; i < 3; i++)
{
int loopValue = i; // "freeze" the current value
actions[i] = () => Console.WriteLine(loopValue);
}
Now the code works as expected. These traps are among the most common mistakes for beginners when using lambda expressions!
8. Combining delegates
Multicast delegates contain a list of methods, and you can add (+=) or remove (-=) handlers.
Quirk: removal is by reference and signature
void Handler1() => Console.WriteLine("1");
void Handler2() => Console.WriteLine("2");
Action a = Handler1;
a += Handler2;
a -= Handler1; // Leaves only Handler2
a?.Invoke(); // Prints "2"
Example: dynamic control of handlers
Action a = Handler1;
a += Handler1;
a -= Handler1; // Now one Handler1 remains in the list!
9. Delegates for extensibility and inversion of control (IoC)
In large apps components often need to "call" external code while staying independent. Delegates help to plug in "extensions", plugins and callbacks without tight coupling.
Example: Injecting behavior through the constructor
public class Greeter
{
private readonly Func<string> _getName;
public Greeter(Func<string> getName)
{
_getName = getName;
}
public void Greet() => Console.WriteLine($"Hello, {_getName()}!");
}
// Inject different behavior:
var greeter1 = new Greeter(() => "Anya");
var greeter2 = new Greeter(() => DateTime.Now.ToShortTimeString());
greeter1.Greet(); // "Hello, Anya!"
greeter2.Greet(); // "Hello, 14:35!"
In real life this approach is used when writing testable and maintainable code.
10. Useful nuances
Delegates in standard interfaces and LINQ
You'll inevitably meet delegates if you work with LINQ, collections, or asynchrony.
- Many methods like List<T>.Find, Array.Sort, Where, Select accept delegates (Func<T, bool>, Comparison<T> and others).
- LINQ methods let you pass filtering, transformation, aggregation logic — without creating separate classes.
Example: Comparator for sorting objects
var people = new[] { "Ivan", "Maria", "Petr" };
Array.Sort(people, (a, b) => a.Length.CompareTo(b.Length));
Console.WriteLine(string.Join(", ", people)); // Ivan, Petr, Maria
Delegates and currying (partial application)
With anonymous methods/lambda expressions you can "fix" some parameters and get a new function.
Example: Partial application
Func<int, int, int> sum = (x, y) => x + y;
// Create a function that always adds 10
Func<int, int> add10 = y => sum(10, y);
Console.WriteLine(add10(5)); // 15
Quirk of delegate comparison
In C# delegates can be compared for equality (==) if they have the same invocation list.
void Handler1() { }
void Handler2() { }
Action a1 = Handler1;
Action a2 = Handler1;
Console.WriteLine(a1 == a2); // True
Action a3 = Handler1; a3 += Handler2;
Action a4 = Handler1; a4 += Handler2;
Console.WriteLine(a3 == a4); // True
But if a delegate is built from an anonymous method or a lambda — it's the instances that are compared.
Serialization of delegates
Delegates can be serialized, but only if the methods they reference are defined in serializable classes and all types are available. Starting from .NET 8, BinaryFormatter is disabled by default and considered obsolete, and in future versions will be removed; serializing delegates in production is practically unused.
Interaction between delegates and events: where a delegate, and where an event?
- A delegate is a type/variable that can be invoked explicitly.
- An event (event) is a way to restrict access to a delegate: from the outside you can only subscribe/unsubscribe (+=/-=), but invoking is only allowed from inside the class.
- An event is always of a delegate type, but not every delegate is an event.
How to apply? If you want logic to be defined "outside" the class, use delegates. If you need to control subscription/unsubscription and protect the variable — use an event.
11. Common mistakes when working with delegates
Mistake #1: Confusing a delegate with an event.
Using a public delegate field (public Action MyAction;) instead of an event (public event Action MyAction;) breaks encapsulation. External code can accidentally or intentionally overwrite all subscribers (instance.MyAction = null;) or invoke them directly, which breaks the class logic.
Mistake #2: Incorrect handling of return values for multicast delegates.
If a delegate returns a value (for example, Func<string, int>), a normal call (myDelegate("test")) will return only the result of the last method in the chain. To get results from all subscribers, iterate over the invocation list via GetInvocationList().
// Example of iterating results from all subscribers
var list = myDelegate.GetInvocationList();
foreach (var d in list)
{
var r = ((Func<string, int>)d)("test");
Console.WriteLine(r);
}
Mistake #3: Capturing the loop variable in a closure.
The classic trap for beginners: a lambda created inside a for loop captures the iterator variable itself, not its current value.
// Wrong: all actions will print the last value of i
for (int i = 0; i < 3; i++)
{
actions[i] = () => Console.WriteLine(i);
}
// Right: create a local copy for each iteration
for (int i = 0; i < 3; i++)
{
int copy = i;
actions[i] = () => Console.WriteLine(copy);
}
Mistake #4: Creating memory leaks.
If an instance method subscribes to a delegate of a long-lived object but doesn't unsubscribe, a leak occurs. The long-lived object holds a reference to the subscriber and the garbage collector can't collect it. Watch for unsubscribing, especially in classes with limited lifetimes (for example, UI components).
GO TO FULL VERSION