Observer Pattern, Events, and Delegates in C# - The Ultimate Guide
In modern software development, creating systems where components can communicate efficiently without tight coupling is crucial. C# provides powerful tools for implementing such communication patterns through delegates, events, and the observer pattern. This comprehensive guide will explore these concepts in depth, providing you with the knowledge to implement robust, maintainable event-driven architectures in your C# applications.
Table of Contents
- Understanding Delegates in C#
- Working with Events
- The Observer Pattern
- Events vs. Observer Pattern
- Real-World Examples
- Best Practices and Common Pitfalls
- Advanced Topics
Understanding Delegates in C#
Delegates are the foundation of event handling in C#. They are type-safe function pointers that define a method signature, allowing methods to be passed as parameters, stored in variables, and invoked dynamically.
Delegate Basics
// Declaring a delegate
public delegate void NotifyDelegate(string message);
// Using the delegate
public class NotificationService
{
public void SendNotification(string message, NotifyDelegate notifyMethod)
{
// Process the notification
Console.WriteLine("Processing notification...");
// Invoke the delegate
notifyMethod(message);
}
}
// Methods that match the delegate signature
public static void EmailNotify(string message)
{
Console.WriteLine($"Email notification: {message}");
}
public static void SmsNotify(string message)
{
Console.WriteLine($"SMS notification: {message}");
}
// Usage
var service = new NotificationService();
NotifyDelegate emailDelegate = EmailNotify;
NotifyDelegate smsDelegate = SmsNotify;
service.SendNotification("Hello World!", emailDelegate);
service.SendNotification("Hello again!", smsDelegate);
Multicast Delegates
One powerful feature of delegates is their ability to reference multiple methods (multicast delegates). When invoked, all referenced methods are called in sequence.
// Combining delegates
NotifyDelegate multiDelegate = EmailNotify;
multiDelegate += SmsNotify;
// Invoking the multicast delegate
service.SendNotification("Important update!", multiDelegate);
// Output:
// Processing notification...
// Email notification: Important update!
// SMS notification: Important update!
Note: The order of invocation in multicast delegates is the same as the order in which methods were added. If any method in the invocation list throws an exception, subsequent methods won't be called unless you manually handle the exception and continue.
Built-in Delegate Types
.NET provides several built-in delegate types that cover most common scenarios:
Delegate Type | Description | Example Use Case |
---|---|---|
Action |
Encapsulates a method that has no parameters and does not return a value | Simple callbacks |
Action<T> |
Encapsulates a method that has one parameter and does not return a value | Event handlers |
Func<TResult> |
Encapsulates a method that has no parameters and returns a value | Value generators |
Predicate<T> |
Represents a method that defines a set of criteria and determines whether the specified object meets those criteria | Filtering collections |
// Using built-in delegates
Action<string> logAction = message => Console.WriteLine($"LOG: {message}");
Func<int, int, int> addFunc = (a, b) => a + b;
Predicate<int> isEvenPredicate = num => num % 2 == 0;
logAction("Application started");
int result = addFunc(5, 7); // 12
bool even = isEvenPredicate(10); // true
Working with Events
Events in C# are a layer of abstraction on top of delegates, providing a standardized way to implement the publish-subscribe pattern. They enable classes to notify other classes when something of interest occurs.
Event Declaration and Usage
public class TemperatureMonitor
{
// Define the delegate (if not using EventHandler<T>)
public delegate void TemperatureChangedHandler(object sender, TemperatureChangedEventArgs e);
// Declare the event
public event TemperatureChangedHandler TemperatureChanged;
private double _currentTemperature;
public double CurrentTemperature
{
get => _currentTemperature;
set
{
if (_currentTemperature != value)
{
_currentTemperature = value;
OnTemperatureChanged(new TemperatureChangedEventArgs(value));
}
}
}
protected virtual void OnTemperatureChanged(TemperatureChangedEventArgs e)
{
TemperatureChanged?.Invoke(this, e);
}
}
public class TemperatureChangedEventArgs : EventArgs
{
public double NewTemperature { get; }
public TemperatureChangedEventArgs(double newTemperature)
{
NewTemperature = newTemperature;
}
}
// Subscribing to the event
var monitor = new TemperatureMonitor();
monitor.TemperatureChanged += (sender, e) =>
{
Console.WriteLine($"Temperature changed to {e.NewTemperature}°C");
};
monitor.CurrentTemperature = 25.5; // Triggers the event
Best Practice: Always follow the standard event pattern in C# where event handlers have a void
return type and take two parameters: an object sender
and an EventArgs
(or derived class) parameter. This makes your events consistent with the .NET framework conventions.
EventHandler and EventHandler<T>
.NET provides built-in delegate types for events that follow the standard pattern:
public class StockMarket
{
// Using the generic EventHandler<T>
public event EventHandler<StockPriceChangedEventArgs> StockPriceChanged;
public void UpdateStockPrice(string symbol, decimal newPrice)
{
// Business logic...
OnStockPriceChanged(new StockPriceChangedEventArgs(symbol, newPrice));
}
protected virtual void OnStockPriceChanged(StockPriceChangedEventArgs e)
{
StockPriceChanged?.Invoke(this, e);
}
}
public class StockPriceChangedEventArgs : EventArgs
{
public string StockSymbol { get; }
public decimal NewPrice { get; }
public StockPriceChangedEventArgs(string symbol, decimal price)
{
StockSymbol = symbol;
NewPrice = price;
}
}
Event Accessors
Similar to properties, events can have custom add and remove accessors:
public class SecureEventPublisher
{
private EventHandler<EventArgs> _secureEvent;
public event EventHandler<EventArgs> SecureEvent
{
add
{
// Custom logic when a handler is added
if (value != null)
{
_secureEvent += value;
Console.WriteLine("Handler added successfully");
}
}
remove
{
// Custom logic when a handler is removed
_secureEvent -= value;
Console.WriteLine("Handler removed successfully");
}
}
public void TriggerEvent()
{
_secureEvent?.Invoke(this, EventArgs.Empty);
}
}
The Observer Pattern
The Observer pattern is a behavioral design pattern where an object (called the subject) maintains a list of its dependents (observers) and notifies them automatically of any state changes. This pattern is fundamental to event-driven architectures.
Classic Observer Pattern Implementation
// Observer interface
public interface IObserver<T>
{
void Update(T data);
}
// Subject interface
public interface ISubject<T>
{
void Attach(IObserver<T> observer);
void Detach(IObserver<T> observer);
void Notify();
}
// Concrete subject
public class WeatherStation : ISubject<WeatherData>
{
private List<IObserver<WeatherData>> _observers = new List<IObserver<WeatherData>>();
private WeatherData _currentWeather;
public WeatherData CurrentWeather
{
get => _currentWeather;
set
{
_currentWeather = value;
Notify();
}
}
public void Attach(IObserver<WeatherData> observer)
{
if (!_observers.Contains(observer))
{
_observers.Add(observer);
}
}
public void Detach(IObserver<WeatherData> observer)
{
_observers.Remove(observer);
}
public void Notify()
{
foreach (var observer in _observers)
{
observer.Update(_currentWeather);
}
}
}
// Concrete observer
public class WeatherDisplay : IObserver<WeatherData>
{
public void Update(WeatherData data)
{
Console.WriteLine($"Current weather: {data.Temperature}°C, {data.Humidity}% humidity");
}
}
// Weather data class
public class WeatherData
{
public double Temperature { get; set; }
public double Humidity { get; set; }
// Other weather properties...
}
// Usage
var station = new WeatherStation();
var display = new WeatherDisplay();
station.Attach(display);
// This will trigger the update in the display
station.CurrentWeather = new WeatherData { Temperature = 22.5, Humidity = 65 };
IObservable and IObserver in .NET
.NET provides built-in interfaces for implementing the observer pattern in the System namespace:
public class NewsPublisher : IObservable<string>
{
private List<IObserver<string>> _observers = new List<IObserver<string>>();
public IDisposable Subscribe(IObserver<string> observer)
{
if (!_observers.Contains(observer))
{
_observers.Add(observer);
}
return new Unsubscriber(_observers, observer);
}
public void PublishNews(string news)
{
foreach (var observer in _observers)
{
observer.OnNext(news);
}
}
public void EndTransmission()
{
foreach (var observer in _observers.ToArray())
{
observer.OnCompleted();
}
_observers.Clear();
}
private class Unsubscriber : IDisposable
{
private List<IObserver<string>> _observers;
private IObserver<string> _observer;
public Unsubscriber(List<IObserver<string>> observers, IObserver<string> observer)
{
_observers = observers;
_observer = observer;
}
public void Dispose()
{
if (_observer != null && _observers.Contains(_observer))
{
_observers.Remove(_observer);
}
}
}
}
public class NewsSubscriber : IObserver<string>
{
public void OnCompleted()
{
Console.WriteLine("News transmission ended");
}
public void OnError(Exception error)
{
Console.WriteLine($"Error in news transmission: {error.Message}");
}
public void OnNext(string value)
{
Console.WriteLine($"Breaking news: {value}");
}
}
// Usage
var publisher = new NewsPublisher();
var subscriber = new NewsSubscriber();
var subscription = publisher.Subscribe(subscriber);
publisher.PublishNews("New C# version released!");
publisher.PublishNews("Microsoft acquires GitHub");
// Later...
publisher.EndTransmission();
subscription.Dispose();
Events vs. Observer Pattern
While both events and the observer pattern enable publish-subscribe communication, they have different characteristics and use cases:
Feature | Events | Observer Pattern |
---|---|---|
Coupling | Publisher knows about subscribers (through delegate references) | Publisher knows only about the observer interface |
Flexibility | Good for simple scenarios with few subscribers | Better for complex scenarios with many observers |
Lifecycle Management | Manual subscription/unsubscription | Explicit attach/detach or IDisposable pattern |
Error Handling | Exceptions can break the invocation chain | Can implement more robust error handling |
Use Case | UI events, simple notifications | Complex event processing, reactive systems |
Note: In modern C# development, you can often choose between these approaches based on your specific needs. For simple scenarios, events are usually sufficient. For more complex systems, especially those requiring advanced features like filtering, transformation, or composition of event streams, the observer pattern or reactive extensions (Rx.NET) might be more appropriate.
Real-World Examples
Example 1: Stock Market Monitoring System
public class StockMarketSimulator
{
private Dictionary<string, decimal> _stockPrices = new Dictionary<string, decimal>();
private Random _random = new Random();
public event EventHandler<StockPriceChangedEventArgs> StockPriceChanged;
public void AddStock(string symbol, decimal initialPrice)
{
_stockPrices[symbol] = initialPrice;
}
public void SimulatePriceChanges()
{
foreach (var symbol in _stockPrices.Keys.ToList())
{
// Simulate price change (between -5% and +5%)
decimal changePercent = (decimal)(_random.NextDouble() * 0.1 - 0.05);
decimal oldPrice = _stockPrices[symbol];
decimal newPrice = oldPrice * (1 + changePercent);
_stockPrices[symbol] = newPrice;
// Notify subscribers
OnStockPriceChanged(new StockPriceChangedEventArgs(
symbol, oldPrice, newPrice, DateTime.Now));
}
}
protected virtual void OnStockPriceChanged(StockPriceChangedEventArgs e)
{
StockPriceChanged?.Invoke(this, e);
}
}
public class StockPriceChangedEventArgs : EventArgs
{
public string Symbol { get; }
public decimal OldPrice { get; }
public decimal NewPrice { get; }
public DateTime ChangeTime { get; }
public StockPriceChangedEventArgs(string symbol, decimal oldPrice,
decimal newPrice, DateTime changeTime)
{
Symbol = symbol;
OldPrice = oldPrice;
NewPrice = newPrice;
ChangeTime = changeTime;
}
}
public class StockDisplay
{
public StockDisplay(StockMarketSimulator market)
{
market.StockPriceChanged += HandleStockPriceChange;
}
private void HandleStockPriceChange(object sender, StockPriceChangedEventArgs e)
{
string trend = e.NewPrice > e.OldPrice ? "↑" : "↓";
Console.WriteLine($"{e.Symbol}: {e.OldPrice:C} → {e.NewPrice:C} {trend} " +
$"({Math.Abs(e.NewPrice/e.OldPrice - 1):P2})");
}
}
// Usage
var market = new StockMarketSimulator();
market.AddStock("MSFT", 250.00m);
market.AddStock("AAPL", 150.00m);
market.AddStock("GOOGL", 1200.00m);
var display = new StockDisplay(market);
// Simulate multiple price changes
for (int i = 0; i < 5; i++)
{
market.SimulatePriceChanges();
Console.WriteLine();
Thread.Sleep(1000);
}
Example 2: Order Processing System with Multiple Observers
public class Order : IObservable<OrderStatus>
{
private List<IObserver<OrderStatus>> _observers = new List<IObserver<OrderStatus>>();
private OrderStatus _currentStatus;
public int OrderId { get; }
public string CustomerName { get; }
public decimal TotalAmount { get; }
public OrderStatus CurrentStatus
{
get => _currentStatus;
private set
{
if (_currentStatus != value)
{
_currentStatus = value;
NotifyObservers();
}
}
}
public Order(int orderId, string customerName, decimal totalAmount)
{
OrderId = orderId;
CustomerName = customerName;
TotalAmount = totalAmount;
CurrentStatus = OrderStatus.Pending;
}
public void Process()
{
CurrentStatus = OrderStatus.Processing;
Thread.Sleep(1000); // Simulate processing time
// Randomly decide if payment succeeds (80% chance)
if (new Random().Next(0, 10) < 8)
{
CurrentStatus = OrderStatus.PaymentReceived;
Thread.Sleep(1000);
CurrentStatus = OrderStatus.Shipped;
}
else
{
CurrentStatus = OrderStatus.PaymentFailed;
}
}
public IDisposable Subscribe(IObserver<OrderStatus> observer)
{
if (!_observers.Contains(observer))
{
_observers.Add(observer);
observer.OnNext(CurrentStatus);
}
return new Unsubscriber(_observers, observer);
}
private void NotifyObservers()
{
foreach (var observer in _observers)
{
observer.OnNext(CurrentStatus);
if (CurrentStatus == OrderStatus.Shipped ||
CurrentStatus == OrderStatus.PaymentFailed)
{
observer.OnCompleted();
}
}
}
private class Unsubscriber : IDisposable
{
private List<IObserver<OrderStatus>> _observers;
private IObserver<OrderStatus> _observer;
public Unsubscriber(List<IObserver<OrderStatus>> observers, IObserver<OrderStatus> observer)
{
_observers = observers;
_observer = observer;
}
public void Dispose()
{
if (_observer != null && _observers.Contains(_observer))
{
_observers.Remove(_observer);
}
}
}
}
public enum OrderStatus
{
Pending,
Processing,
PaymentReceived,
PaymentFailed,
Shipped
}
public class CustomerNotifier : IObserver<OrderStatus>
{
private readonly string _customerEmail;
public CustomerNotifier(string customerEmail)
{
_customerEmail = customerEmail;
}
public void OnCompleted()
{
Console.WriteLine($"Notification sequence completed for {_customerEmail}");
}
public void OnError(Exception error)
{
Console.WriteLine($"Error in order processing for {_customerEmail}: {error.Message}");
}
public void OnNext(OrderStatus value)
{
string message = value switch
{
OrderStatus.Processing => "Your order is being processed",
OrderStatus.PaymentReceived => "Payment received, preparing for shipment",
OrderStatus.Shipped => "Your order has been shipped!",
OrderStatus.PaymentFailed => "Payment failed, please check your payment method",
_ => $"Order status updated: {value}"
};
Console.WriteLine($"Email to {_customerEmail}: {message}");
}
}
public class InventoryManager : IObserver<OrderStatus>
{
public void OnCompleted()
{
Console.WriteLine("Inventory manager: Order processing completed");
}
public void OnError(Exception error)
{
Console.WriteLine($"Inventory manager error: {error.Message}");
}
public void OnNext(OrderStatus value)
{
if (value == OrderStatus.PaymentReceived)
{
Console.WriteLine("Inventory manager: Reserving items for shipping");
}
}
}
// Usage
var order = new Order(123, "John Doe", 199.99m);
var customerNotifier = new CustomerNotifier("john.doe@example.com");
var inventoryManager = new InventoryManager();
using (order.Subscribe(customerNotifier))
using (order.Subscribe(inventoryManager))
{
order.Process();
}
Best Practices and Common Pitfalls
Best Practices
- Always check for null before invoking events: Use the null-conditional operator (
?.Invoke()
) to avoid null reference exceptions. - Follow the standard event pattern: Use
EventHandler
orEventHandler<T>
for consistency with .NET conventions. - Consider thread safety: Events can be raised from any thread. Use synchronization or ensure handlers are thread-safe.
- Implement proper unsubscribe/dispose: Avoid memory leaks by unsubscribing from events when no longer needed.
- Use custom EventArgs classes: For complex event data, create specific EventArgs-derived classes rather than using primitive types.
- Document your events: Clearly document when events are raised and what data they provide.
Common Pitfalls
Memory Leaks: One of the most common issues with events is memory leaks caused by not unsubscribing from events. If an object subscribes to an event from another object with a longer lifetime, it may prevent garbage collection.
// Problematic scenario
public class Publisher
{
public event EventHandler SomethingHappened;
}
public class Subscriber
{
public Subscriber(Publisher publisher)
{
publisher.SomethingHappened += HandleSomething;
}
private void HandleSomething(object sender, EventArgs e)
{
Console.WriteLine("Something happened");
}
}
// Usage that causes memory leak
var publisher = new Publisher(); // Long-lived object
while (true)
{
var subscriber = new Subscriber(publisher); // Short-lived objects
// subscriber is never unsubscribed, so publisher holds references to all subscribers
}
Solution: Implement IDisposable in subscribers to unsubscribe from events:
public class SafeSubscriber : IDisposable
{
private Publisher _publisher;
public SafeSubscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.SomethingHappened += HandleSomething;
}
private void HandleSomething(object sender, EventArgs e)
{
Console.WriteLine("Something happened");
}
public void Dispose()
{
_publisher.SomethingHappened -= HandleSomething;
}
}
Advanced Topics
Weak Events
For scenarios where you can't reliably unsubscribe, weak event patterns can help prevent memory leaks by using weak references:
public class WeakEventSource
{
private List<WeakReference<EventHandler<EventArgs>>> _handlers =
new List<WeakReference<EventHandler<EventArgs>>>();
public event EventHandler<EventArgs> MyEvent
{
add
{
_handlers.Add(new WeakReference<EventHandler<EventArgs>>(value));
}
remove
{
// Find and remove the handler (implementation omitted for brevity)
}
}
public void RaiseEvent()
{
EventArgs args = EventArgs.Empty;
for (int i = _handlers.Count - 1; i >= 0; i--)
{
if (_handlers[i].TryGetTarget(out var handler))
{
handler?.Invoke(this, args);
}
else
{
// Remove dead references
_handlers.RemoveAt(i);
}
}
}
}
Reactive Extensions (Rx.NET)
For advanced event processing, Reactive Extensions provide powerful tools for composing event streams:
// Requires System.Reactive NuGet package
public class RxExample
{
public static void Run()
{
// Create observable from events
var stockMarket = new StockMarketSimulator();
stockMarket.AddStock("MSFT", 250.00m);
IObservable<StockPriceChangedEventArgs> priceChanges =
Observable.FromEventPattern<StockPriceChangedEventArgs>(
h => stockMarket.StockPriceChanged += h,
h => stockMarket.StockPriceChanged -= h)
.Select(ep => ep.EventArgs);
// Subscribe with filtering and throttling
var subscription = priceChanges
.Where(p => p.NewPrice > p.OldPrice) // Only price increases
.Throttle(TimeSpan.FromMilliseconds(500)) // Limit to one every 500ms
.Subscribe(p =>
{
Console.WriteLine($"Significant price increase: {p.Symbol} " +
$"{p.OldPrice:C} → {p.NewPrice:C}");
});
// Simulate changes
for (int i = 0; i < 100; i++)
{
stockMarket.SimulatePriceChanges();
Thread.Sleep(100);
}
subscription.Dispose();
}
}
Async Events
Handling asynchronous operations in event handlers requires careful consideration:
public class AsyncEventExample
{
public event Func<object, EventArgs, Task> AsyncEvent;
public async Task RaiseAsyncEvent()
{
var handlers = AsyncEvent?.GetInvocationList();
if (handlers == null) return;
foreach (Func<object, EventArgs, Task> handler in handlers)
{
try
{
await handler(this, EventArgs.Empty);
}
catch (Exception ex)
{
Console.WriteLine($"Handler failed: {ex.Message}");
// Continue with other handlers
}
}
}
}
// Usage
var example = new AsyncEventExample();
example.AsyncEvent += async (sender, e) =>
{
Console.WriteLine("Handler 1 starting");
await Task.Delay(1000);
Console.WriteLine("Handler 1 completed");
};
example.AsyncEvent += async (sender, e) =>
{
Console.WriteLine("Handler 2 starting");
await Task.Delay(500);
Console.WriteLine("Handler 2 completed");
};
await example.RaiseAsyncEvent();
Conclusion
Understanding delegates, events, and the observer pattern is crucial for building flexible, decoupled applications in C#. These concepts form the foundation of event-driven architectures that are scalable and maintainable. Whether you choose to use simple events for basic notifications or implement the full observer pattern for complex scenarios, these tools will help you create systems where components can communicate effectively without tight coupling.
Remember to follow best practices around memory management, thread safety, and proper event design to avoid common pitfalls. For advanced scenarios, consider exploring Reactive Extensions (Rx.NET) which provide powerful tools for composing and transforming event streams.
By mastering these concepts, you'll be able to design systems that are more responsive, maintainable, and adaptable to changing requirements.
Post a Comment