In this chapter, you've seen how to store a single method inside an instance of a delegate type and invoke that method via the delegate. Delegates are more than storage mechanisms for a single method, however. A single delegate variable can reference a series of delegates in which each successive one points to a succeeding delegate in the form of a chain, sometimes known as a multicast delegate. All delegates are multicast delegates, but so far, they have supported only a single callback (a multiplicity of one).
The C# implementation of multicast delegates is a common pattern that would otherwise require significant manual code. Known as the observer or publish-subscribe pattern, it represents scenarios where notifications of single events, such as a change in object state, are broadcast to multiple subscribers.
Coding the Observer Pattern with Delegates
Consider a temperature control example, where a heater and a cooler are hooked up to the same thermostat. In order for a unit to turn on and off appropriately, you notify the unit of changes in temperature. One thermostat publishes temperature changes to multiple subscribersthe heating and cooling units. The next section investigates the code.
Defining Subscriber Methods
Begin by defining the Heater and Cooler objects (see Listing 13.16).
Listing 13.16. Heater and Cooler Event Subscriber Implementations
The two classes are essentially identical, with the exception of the temperature comparison. (In fact, you could eliminate one of the classes if you used a delegate as a method pointer for comparison within the OnTemperatureChanged method.) Each class stores the temperature for when to turn on the unit. In addition, both classes provide an OnTemperatureChanged() method. Calling the OnTemperatureChanged() method is the means to indicate to the Heater and Cooler classes that the temperature has changed. The method implementation uses newTemperature to compare against the stored trigger temperature to determine whether to turn on the device.
The OnTemperatureChanged() methods are the subscriber methods. It is important that they have the parameters and a return type that matches the delegate from the Thermostat class, which is discussed next.
Defining the Publisher
The Thermostat class is responsible for reporting temperature changes to the heater and cooler object instances. The Thermostat class listing appears in Listing 13.17.
Listing 13.17. Defining the Event Publisher, Thermostat
The first member of the Thermostat class is the TemperatureChangeHandler delegate. Although not a requirement, Thermostat.TemperatureChangeHandler is a nested delegate because its definition is specific to the Thermostat class. The delegate defines the signature of the subscriber methods. Notice, therefore, that in both the Heater and Cooler classes, the OnTemperatureChanged() methods match the signature of TemperatureChangeHandler.
In addition to defining the delegate type, Thermostat also includes a property called OnTemperatureChange that is of the OnTemperatureChangeHandler delegate type. OnTemperatureChange stores a list of subscribers. Notice that only one delegate field is required to store all the subscribers. In other words, both the Cooler and the Heater classes will receive notifications of a change in the temperature from this single publisher.
The last member of Thermostat is the CurrentTemperature property. This sets and retrieves the value of the current temperature reported by the Thermostat class.
Hooking Up the Publisher and Subscribers
Finally, put all these pieces together in a Main() method. Listing 13.18 shows a sample of what Main() could look like.
Listing 13.18. Hooking Up the Publisher and Subscribers
The code in this listing has registered two subscribers (heater.OnTemperatureChanged and cooler.OnTemperatureChanged) to the OnTemperatureChange delegate by directly assigning them using the += operator. As noted in the comment, you need to use the new operator with the TemperatureChangeHandler constructor if you are not using C# 2.0.
By taking the temperature value the user has entered, you can set the CurrentTemperature of thermostat. However, you have not yet written any code to publish the change temperature event to subscribers.
Invoking a Delegate
Every time the CurrentTemperature property on the Thermostat class changes, you want to invoke the delegate to notify the subscribers (heater and cooler) of the change in temperature. To do this, modify the CurrentTemperature property to save the new value and publish a notification to each subscriber. The code modification appears in Listing 13.19.
Listing 13.19. Invoking a Delegate without Checking for null
Now the assignment of CurrentTemperature includes some special logic to notify subscribers of changes in CurrentTemperature. The call to notify all subscribers is simply the single C# statement, OnTemperatureChange(value). This single statement publishes the temperature change to the cooler and heater objects. Here, you see in practice that the ability to notify multiple subscribers using a single call is why delegates are more specifically known as multicast delegates.
Check for Null
One important part of publishing an event code is missing from Listing 13.19. If no subscriber registered to receive the notification, then OnTemperatureChange would be null and executing the OnTemperatureChange(value) statement would throw a NullReferenceException. To avoid this, it is necessary to check for null before firing the event. Listing 13.20 demonstrates how to do this.
Listing 13.20. Invoking a Delegate
Instead of checking for null directly, first assign OnTemperatureChange to a second delegate variable, handlerCopy. This simple modification ensures that if all OnTemperatureChange subscribers are removed (by a different thread) between checking for null and sending the notification, you won't fire a NullReferenceException.
One more time: Remember to check the value of a delegate for null before invoking it.
To combine the two subscribers in the Thermostat example, you used the += operator. This takes the first delegate and adds the second delegate to the chain so that one delegate points to the next. Now, after the first delegate's method is invoked, it calls the second delegate. To remove delegates from a delegate chain, use the -= operator, as shown in Listing 13.21.
Listing 13.21. Using the += and -= Delegate Operators
The results of Listing 13.21 appear in Output 13.3.
Furthermore, you can also use the + and operators to combine delegates, as Listing 13.22 shows.
Listing 13.22. Using the + and - Delegate Operators
Use of the assignment operator clears out all previous subscribers and allows you to replace them with new subscribers. This is an unfortunate characteristic of a delegate. It is simply too easy to mistakenly code an assignment when, in fact, the += operator is intended. The solution, called events, appears in the Events section, later in this chapter.
It should be noted that both the + and operators and their assignment equivalents, += and -=, are implemented internally using the static methods System.Delegate.Combine() and System.Delegate.Remove(). Both methods take two parameters of type delegate. The first method, Combine(), joins the two parameters so that the first parameter points to the second within the list of delegates. The second, Remove(), searches through the chain of delegates specified in the first parameter and then removes the delegate specified by the second parameter.
One interesting thing to note about the Combine() method is that either or both of the parameters can be null. If one of them is null, then Combine() returns the non-null parameter. If both are null, then Combine() returns null. This explains why you can call thermostat.OnTemperatureChange += heater.OnTemperatureChanged; and not throw an exception, even if the value of thermostat.OnTemperatureChange is not yet assigned.
The process of notifying both heater and cooler appears in Figure 13.2.
Figure 13.2. Delegate Invocation Sequence Diagram
Although you coded only a single call to OnTemperatureChange(), the call is broadcast to both subscribers so that from that one call, both cooler and heater are notified of the change in temperature. If you added more subscribers, they too would be notified by OnTemperatureChange().
Although a single call, OnTemperatureChange(), caused the notification of each subscriber, they are still called sequentially, not simultaneously, because a single delegate can point to another delegate that can, in turn, point to additional delegates.
Error handling makes awareness of the sequential notification critical. If one subscriber throws an exception, later subscribers in the chain do not receive the notification. Consider, for example, if you changed the Heater's OnTemperatureChanged() method so that it threw an exception, as shown in Listing 13.23.
Listing 13.23. OnTemperatureChanged() Throwing an Exception
Figure 13.4 shows an updated sequence diagram. Even though cooler subscribed to receive messages, the heater exception terminates the chain and prevents the cooler object from receiving notification.
Figure 13.4. Delegate Invocation with Exception Sequence Diagram
To avoid this problem so that all subscribers receive notification, regardless of the behavior of earlier subscribers, you must manually enumerate through the list of subscribers and call them individually. Listing 13.24 shows the updates required in the CurrentTemperature property. The results appear in Output 13.4.
Listing 13.24. Handling Exceptions from Subscribers
This listing demonstrates that you can retrieve a list of subscribers from a delegate's GetInvocationList() method. Enumerating over each item in this list returns the individual subscribers. If you then place each invocation of a subscriber within a try/catch block, you can handle any error conditions before continuing with the enumeration loop. In this sample, even though heater.OnTemperatureChanged() throws an exception, cooler still receives notification of the temperature change.
Method Returns and Pass-By-Reference
There is another scenario where it is useful to iterate over the delegate invocation list instead of simply activating a notification directly. This scenario relates to delegates that either don't return void or have ref or out parameters. In the thermostat example so far, the OnTemperatureHandler delegate had a return type of void. Furthermore, it did not include any parameters that were ref or out type parameters, parameters that return data to the caller. This is important because an invocation of a delegate potentially triggers notification to multiple subscribers. If the subscribers return a value, it is ambiguous which subscriber's return value would be used.
If you changed OnTemperatureHandler to return an enumeration value, indicating whether the device was on because of the temperature change, the new delegate would look like Listing 13.25.
Listing 13.25. Declaring a Delegate with a Method Return
All subscriber methods would have to use the same method signature as the delegate, and therefore, each would be required to return a status value. Assuming you invoke the delegate in a similar manner as before, what will the value of status be in Listing 13.26, for example?
Listing 13.26. Invoking a Delegate Instance with a Return
Since OnTemperatureChange potentially corresponds to a chain of delegates, status reflects only the value of the last delegate. All other values are lost entirely.
To overcome this issue, it is necessary to follow the same pattern that you used for error handling. In other words, you must iterate through each delegate invocation list, using the GetInvocationList() method, to retrieve each individual return value. Similarly, delegate types that use ref and out parameters need special consideration.