Building Responsive Applications with Delegates
Delegates are type-safe function pointers that let you pass methods as arguments, store them in variables, and invoke them later. They're the foundation of event-driven programming in C#, enabling components to communicate without tight coupling. When a button gets clicked or data finishes loading, delegates make the notification system work.
Event-driven programming means your code responds to things that happen rather than following a predetermined sequence. User interactions, data arrivals, timer ticks, and system notifications all trigger events. Delegates provide the mechanism to register callbacks that execute when these events occur.
You'll learn how delegates work, how to use them for callbacks and events, when to use built-in delegate types, and how lambda expressions simplify delegate usage in modern C# code.
Understanding Delegate Fundamentals
A delegate defines a signature for methods it can reference. You declare a delegate type, create instances that point to methods matching that signature, and invoke the delegate to call those methods. This indirection lets you change behavior at runtime by swapping which method the delegate references.
// Declare a delegate type
public delegate void ProcessDataDelegate(string data);
public delegate int CalculateDelegate(int x, int y);
public class Calculator
{
// Methods that match the delegate signature
public static int Add(int x, int y) => x + y;
public static int Subtract(int x, int y) => x - y;
public static int Multiply(int x, int y) => x * y;
public static void DemonstrateBasicDelegates()
{
// Create delegate instances pointing to methods
CalculateDelegate calc = Add;
int result = calc(10, 5); // Invokes Add method
Console.WriteLine($"Add: {result}"); // Output: Add: 15
// Reassign to point to different method
calc = Subtract;
result = calc(10, 5); // Invokes Subtract method
Console.WriteLine($"Subtract: {result}"); // Output: Subtract: 5
// Pass delegate as parameter
int addResult = PerformCalculation(20, 10, Add);
int multiplyResult = PerformCalculation(20, 10, Multiply);
Console.WriteLine($"20 + 10 = {addResult}");
Console.WriteLine($"20 * 10 = {multiplyResult}");
}
// Method that accepts a delegate as parameter
public static int PerformCalculation(int x, int y, CalculateDelegate operation)
{
Console.WriteLine($"Performing calculation on {x} and {y}");
return operation(x, y);
}
}
public class DataProcessor
{
public void ProcessItems(List items, ProcessDataDelegate processor)
{
foreach (var item in items)
{
processor(item); // Call the delegate for each item
}
}
public static void LogData(string data)
{
Console.WriteLine($"[LOG] {data}");
}
public static void SaveData(string data)
{
Console.WriteLine($"[SAVE] {data}");
}
public static void DemonstrateCallback()
{
var processor = new DataProcessor();
var items = new List { "Item1", "Item2", "Item3" };
// Process items with logging
processor.ProcessItems(items, LogData);
// Process same items with saving
processor.ProcessItems(items, SaveData);
}
}
The delegate acts as a contract. Any method matching the signature can be assigned to it. This lets ProcessItems work with any processing logic without knowing the implementation details, making your code flexible and reusable.
Using Built-in Delegate Types
C# provides Action and Func delegates for common scenarios. Action represents methods that return void, while Func represents methods that return a value. These generic delegates eliminate most needs for custom delegate declarations.
public class DelegateExamples
{
// Action delegates - no return value
public void DemonstrateAction()
{
// Action with no parameters
Action greet = () => Console.WriteLine("Hello!");
greet();
// Action with one parameter
Action printMessage = message => Console.WriteLine(message);
printMessage("Welcome to delegates");
// Action with multiple parameters
Action logWithLevel = (message, level) =>
Console.WriteLine($"[Level {level}] {message}");
logWithLevel("Processing data", 2);
}
// Func delegates - return a value
public void DemonstrateFunc()
{
// Func with no parameters, returns int
Func getRandomNumber = () => Random.Shared.Next(1, 100);
Console.WriteLine($"Random: {getRandomNumber()}");
// Func with one parameter, returns bool
Func isEven = number => number % 2 == 0;
Console.WriteLine($"Is 4 even? {isEven(4)}");
// Func with multiple parameters, returns string
Func combine = (first, second) =>
$"{first} {second}";
Console.WriteLine(combine("Hello", "World"));
}
// Practical examples with collections
public void FilterAndTransform()
{
var numbers = new List { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
// Predicate delegate (Func) for filtering
Func isEven = n => n % 2 == 0;
var evenNumbers = numbers.Where(isEven).ToList();
Console.WriteLine($"Even numbers: {string.Join(", ", evenNumbers)}");
// Func delegate for transformation
Func square = n => n * n;
var squares = numbers.Select(square).ToList();
Console.WriteLine($"Squares: {string.Join(", ", squares)}");
// Action delegate for processing
Action printSquare = n => Console.WriteLine($"{n}² = {n * n}");
numbers.ForEach(printSquare);
}
// Higher-order functions with delegates
public List Filter(List items, Func predicate)
{
var result = new List();
foreach (var item in items)
{
if (predicate(item))
result.Add(item);
}
return result;
}
public void Transform(List items, Action transformer)
{
foreach (var item in items)
{
transformer(item);
}
}
public void UseHigherOrderFunctions()
{
var products = new List { "Apple", "Banana", "Cherry", "Date" };
// Filter products starting with 'C'
var filtered = Filter(products, p => p.StartsWith("C"));
Console.WriteLine($"Filtered: {string.Join(", ", filtered)}");
// Transform all products to uppercase
Transform(products, p => Console.WriteLine(p.ToUpper()));
}
}
Action and Func cover nearly all callback scenarios. Action handles up to 16 parameters with no return value, while Func handles up to 16 parameters plus a return value. These built-in types make your code cleaner and more maintainable.
Implementing Events with Delegates
Events build on delegates to provide a safe publish-subscribe pattern. They prevent external code from clearing subscribers or invoking the event inappropriately. Only the class that declares an event can raise it, while other classes can only subscribe or unsubscribe.
// Custom event arguments
public class PriceChangedEventArgs : EventArgs
{
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public decimal Change => NewPrice - OldPrice;
public PriceChangedEventArgs(decimal oldPrice, decimal newPrice)
{
OldPrice = oldPrice;
NewPrice = newPrice;
}
}
public class Product
{
private string name;
private decimal price;
public string Name
{
get => name;
set => name = value;
}
public decimal Price
{
get => price;
set
{
if (value != price)
{
decimal oldPrice = price;
price = value;
OnPriceChanged(new PriceChangedEventArgs(oldPrice, value));
}
}
}
// Event declaration using EventHandler delegate
public event EventHandler PriceChanged;
// Protected method to raise the event
protected virtual void OnPriceChanged(PriceChangedEventArgs e)
{
PriceChanged?.Invoke(this, e);
}
}
public class PriceMonitor
{
public void MonitorProduct(Product product)
{
// Subscribe to event
product.PriceChanged += OnProductPriceChanged;
}
private void OnProductPriceChanged(object sender, PriceChangedEventArgs e)
{
var product = sender as Product;
Console.WriteLine($"{product.Name} price changed:");
Console.WriteLine($" Old: ${e.OldPrice:F2}");
Console.WriteLine($" New: ${e.NewPrice:F2}");
Console.WriteLine($" Change: ${e.Change:F2}");
if (e.Change > 0)
Console.WriteLine(" Price increased!");
else
Console.WriteLine(" Price decreased!");
}
public void StopMonitoring(Product product)
{
// Unsubscribe from event
product.PriceChanged -= OnProductPriceChanged;
}
}
// Multiple subscribers example
public class EventDemo
{
public static void DemonstrateEvents()
{
var product = new Product { Name = "Laptop", Price = 999.99m };
// Multiple subscribers
product.PriceChanged += (sender, e) =>
Console.WriteLine($"Logger: Price changed by ${e.Change:F2}");
product.PriceChanged += (sender, e) =>
{
if (e.NewPrice < e.OldPrice)
Console.WriteLine("Alert: Price drop detected!");
};
var monitor = new PriceMonitor();
monitor.MonitorProduct(product);
// Trigger events by changing price
product.Price = 899.99m; // All subscribers notified
product.Price = 799.99m; // All subscribers notified again
// Unsubscribe one handler
monitor.StopMonitoring(product);
product.Price = 699.99m; // Only lambda handlers notified
}
}
Events provide encapsulation. External code can subscribe to notifications but can't invoke the event or clear other subscribers. This prevents accidental misuse and creates a clear separation between publishers and subscribers.
Multicast Delegates and Invocation
Delegates can reference multiple methods simultaneously. When you use the += operator, you add a method to the invocation list. Invoking a multicast delegate calls all referenced methods in the order they were added. This is how events notify multiple subscribers.
public delegate void LogDelegate(string message);
public class MulticastDemo
{
public static void LogToConsole(string message)
{
Console.WriteLine($"[Console] {message}");
}
public static void LogToFile(string message)
{
Console.WriteLine($"[File] Writing: {message}");
}
public static void LogToDatabase(string message)
{
Console.WriteLine($"[Database] Storing: {message}");
}
public static void DemonstrateMulticast()
{
// Create multicast delegate
LogDelegate logger = LogToConsole;
logger += LogToFile;
logger += LogToDatabase;
// Invoke calls all three methods
logger("Application started");
Console.WriteLine("\nRemoving file logger:\n");
// Remove one handler
logger -= LogToFile;
logger("User logged in");
// Check if delegate is null before invoking
LogDelegate emptyLogger = null;
emptyLogger?.Invoke("This won't execute");
}
// Return values with multicast delegates
public delegate int CalculateDelegate(int x, int y);
public static void DemonstrateReturnValues()
{
CalculateDelegate calc = (x, y) => x + y;
calc += (x, y) => x * y;
calc += (x, y) => x - y;
// Only the last method's return value is available
int result = calc(10, 5);
Console.WriteLine($"Result: {result}"); // Output: 5 (from subtraction)
// To get all results, use GetInvocationList
Console.WriteLine("\nAll results:");
foreach (CalculateDelegate d in calc.GetInvocationList())
{
int individualResult = d(10, 5);
Console.WriteLine($" {individualResult}");
}
}
// Exception handling with multicast delegates
public static void DemonstrateExceptionHandling()
{
Action handlers = msg => Console.WriteLine($"Handler 1: {msg}");
handlers += msg => throw new Exception("Handler 2 failed!");
handlers += msg => Console.WriteLine($"Handler 3: {msg}");
try
{
handlers("Test");
}
catch (Exception ex)
{
Console.WriteLine($"Exception caught: {ex.Message}");
Console.WriteLine("Handler 3 was never called due to exception");
}
// Safe invocation of all handlers
Console.WriteLine("\nSafe invocation:");
foreach (Action handler in handlers.GetInvocationList())
{
try
{
handler("Test");
}
catch (Exception ex)
{
Console.WriteLine($"Handler failed: {ex.Message}");
}
}
}
}
When a multicast delegate has a return value, only the last invoked method's return value is accessible. If you need all return values, iterate through GetInvocationList(). Similarly, exceptions from one handler prevent subsequent handlers from executing unless you handle them individually.
Asynchronous Operations with Callbacks
Delegates work naturally with async operations. You can pass async methods to delegates, use them as completion callbacks, and combine them with Task-based patterns. This makes delegates essential for responsive applications that perform background work.
public class AsyncOperations
{
// Delegate for progress reporting
public delegate void ProgressCallback(int percentComplete, string status);
// Async operation with progress callbacks
public async Task ProcessDataAsync(
List items,
ProgressCallback onProgress,
Action onComplete)
{
int total = items.Count;
for (int i = 0; i < total; i++)
{
// Simulate processing
await Task.Delay(500);
// Report progress
int percent = (i + 1) * 100 / total;
onProgress?.Invoke(percent, $"Processing {items[i]}");
}
onComplete?.Invoke("All items processed successfully");
}
// Using Func for async callbacks
public async Task ExecuteWithRetry(
Func operation,
int maxAttempts,
Action onRetry)
{
int attempt = 0;
while (attempt < maxAttempts)
{
try
{
await operation();
return; // Success
}
catch (Exception ex)
{
attempt++;
if (attempt >= maxAttempts)
throw;
onRetry?.Invoke(attempt);
await Task.Delay(1000 * attempt); // Exponential backoff
}
}
}
// Async event handlers
public event Func DataReceived;
public async Task RaiseDataReceivedAsync(string data)
{
if (DataReceived != null)
{
// Invoke all async handlers
var handlers = DataReceived.GetInvocationList()
.Cast>();
foreach (var handler in handlers)
{
await handler(data);
}
}
}
// Practical example
public static async Task DemonstrateAsyncCallbacks()
{
var processor = new AsyncOperations();
var items = new List { "File1", "File2", "File3", "File4" };
await processor.ProcessDataAsync(
items,
onProgress: (percent, status) =>
Console.WriteLine($"{percent}%: {status}"),
onComplete: message =>
Console.WriteLine($"\n{message}")
);
// Async retry with callbacks
int retryCount = 0;
await processor.ExecuteWithRetry(
operation: async () =>
{
Console.WriteLine("Attempting operation...");
await Task.Delay(100);
if (++retryCount < 3)
throw new Exception("Simulated failure");
Console.WriteLine("Operation succeeded!");
},
maxAttempts: 5,
onRetry: attempt =>
Console.WriteLine($"Retry attempt {attempt}")
);
// Subscribe to async event
processor.DataReceived += async (data) =>
{
Console.WriteLine($"Handler 1 processing: {data}");
await Task.Delay(500);
Console.WriteLine("Handler 1 complete");
};
processor.DataReceived += async (data) =>
{
Console.WriteLine($"Handler 2 processing: {data}");
await Task.Delay(300);
Console.WriteLine("Handler 2 complete");
};
await processor.RaiseDataReceivedAsync("Sample data");
}
}
Async delegates enable progress reporting, retry logic, and concurrent event handling. The key is using Func<Task> or Func<T, Task> instead of Action when you need async callbacks. This lets you await all handlers properly instead of fire-and-forget execution.
Lambda Expressions and Closures
Lambda expressions provide concise syntax for creating delegate instances. They can capture variables from their surrounding scope, creating closures that maintain access to those variables even after the enclosing method returns. This is powerful but requires understanding captured variable lifetimes.
public class ClosureExamples
{
// Simple lambda capturing local variable
public Action CreateCounter()
{
int count = 0; // Captured variable
// Lambda captures 'count' by reference
return () =>
{
count++;
Console.WriteLine($"Count: {count}");
};
}
public void DemonstrateClosure()
{
var counter = CreateCounter();
counter(); // Output: Count: 1
counter(); // Output: Count: 2
counter(); // Output: Count: 3
// Each invocation sees the same 'count' variable
}
// Creating multiple closures
public List CreateMultipleCounters()
{
var counters = new List();
for (int i = 0; i < 3; i++)
{
int captured = i; // Capture loop variable correctly
counters.Add(() =>
Console.WriteLine($"Counter {captured}"));
}
return counters;
}
// Common closure pitfall
public void DemonstratePitfall()
{
var actions = new List();
// Wrong way - all closures capture same variable
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i));
}
Console.WriteLine("Wrong way:");
foreach (var action in actions)
action(); // All print 3
// Right way - each closure gets its own variable
actions.Clear();
for (int i = 0; i < 3; i++)
{
int captured = i;
actions.Add(() => Console.WriteLine(captured));
}
Console.WriteLine("\nRight way:");
foreach (var action in actions)
action(); // Prints 0, 1, 2
}
// Practical closure example: Event filtering
public void DemonstrateEventFiltering()
{
var items = new List
{
"apple", "banana", "cherry", "apricot", "blueberry"
};
// Create filter based on user input
string searchTerm = "a";
Func filter = item => item.Contains(searchTerm);
var filtered = items.Where(filter).ToList();
Console.WriteLine($"Items containing '{searchTerm}':");
filtered.ForEach(item => Console.WriteLine($" {item}"));
// Change search term and create new filter
searchTerm = "berry";
filter = item => item.Contains(searchTerm);
filtered = items.Where(filter).ToList();
Console.WriteLine($"\nItems containing '{searchTerm}':");
filtered.ForEach(item => Console.WriteLine($" {item}"));
}
// Building a simple event system with closures
public class EventBus
{
private readonly List> handlers = new();
public void Subscribe(string eventType, Action handler)
{
// Closure captures eventType
handlers.Add(data =>
{
if (data.StartsWith(eventType))
handler(data);
});
}
public void Publish(string data)
{
foreach (var handler in handlers)
handler(data);
}
}
public void DemonstrateEventBus()
{
var eventBus = new EventBus();
// Subscribe to different event types
eventBus.Subscribe("error", msg =>
Console.WriteLine($"Error handler: {msg}"));
eventBus.Subscribe("info", msg =>
Console.WriteLine($"Info handler: {msg}"));
// Publish events
eventBus.Publish("error: Something went wrong");
eventBus.Publish("info: Processing complete");
eventBus.Publish("warning: Low memory"); // No handler
}
}
Closures extend variable lifetimes beyond their original scope. The captured variables live as long as the delegate referencing them exists. This enables powerful patterns but can also create memory leaks if you're not careful about keeping references to closures.