Events
In Chapter 13, you saw how to reference a method with an instance of a delegate type and invoke that method via the delegate. Delegates are the building blocks of a larger pattern called publish–subscribe or observer. The use of delegates for the publish–subscribe pattern is the focus of this chapter. Almost everything described in this chapter can be done using delegates alone. However, the event constructs that this chapter highlights provide additional encapsulation, making the publish–subscribe pattern easier to implement and less error-prone.
In Chapter 13, all delegates referenced a single method. More broadly, a single delegate value can reference a whole collection of methods to be called in sequence; such a delegate is called a multicast delegate. Its application enables scenarios where notifications of single events, such as a change in object state, are published to multiple subscribers.
Generics significantly changed coding conventions because using a generic delegate data type meant that it was no longer necessary to declare a delegate for every possible event signature.1
Consider a temperature control, where a heater and a cooler are hooked up to the same thermostat. For each unit to turn on and off appropriately, you must notify the units of changes in temperature. One thermostat publishes temperature changes to multiple subscribers—the heating and cooling units. The next section investigates the code.2
Begin by defining the Heater and Cooler objects (see Listing 14.1).
The two classes are essentially identical except for the temperature comparison. (In fact, you could eliminate one of the classes if you used a delegate to a comparison method within the OnTemperatureChanged method.) Each class stores the temperature at which the unit should be turned on. 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 (also called listener) methods. They must have the parameters and a return type that matches the delegate from the Thermostat class, which we discuss next.
The Thermostat class is responsible for reporting temperature changes to the heater and cooler object instances. The Thermostat class code appears in Listing 14.2.
The Thermostat includes a property called OnTemperatureChange that is of the Action<float> delegate type. OnTemperatureChange stores a list of subscribers. Notice that only one delegate property is required to store all the subscribers. In other words, both the Cooler and the Heater instances receive notifications of a change in the temperature from this single publisher.
The last member of Thermostat is the CurrentTemperature property. This property sets and retrieves the value of the current temperature reported by the Thermostat class.
Finally, we put all these pieces together in a Main() method. Listing 14.3 shows a sample of what Main() could look like.
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.
By taking the temperature value the user has entered as input, you can set the CurrentTemperature of thermostat. However, you have not yet written any code to publish the change temperature event to subscribers.
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 achieve this goal, you must modify the CurrentTemperature property to save the new value and publish a notification to each subscriber. The code modification appears in Listing 14.4.
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 both 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, as discussed later in the chapter.
In C# 8.0, however, invoking the CurrentTemperature delegate directly triggers a nullable dereference warning, indicating that a null check is required.
One important part of event publishing code is missing from Listing 14.4. If no subscriber has registered to receive the notification, OnTemperatureChange would be null, and executing the OnTemperatureChange(value) statement would throw a NullReferenceException. To avoid this scenario, it is necessary to check for null before firing the event. Listing 14.5 demonstrates how to do this using the null-conditional operator before calling Invoke().3
Notice the call to the Invoke() method that follows the null-conditional operator. Although this method may be called using only a dot operator, there is little point, since that is the equivalent of calling the delegate directly (see OnTemperatureChange(value) in Listing 14.4). An important advantage underlying the use of the null-conditional operator is special logic to ensure that after checking for null, there is no possibility that a subscriber might invoke a stale handler (one that has changed after checking for null), leaving the delegate null again.
Given that a delegate is a reference type, it is perhaps somewhat surprising that assigning a local variable and then using that local variable is sufficient for making the null check thread-safe. Since localOnChange refers to the same location as OnTemperatureChange does, you might imagine that any changes in OnTemperatureChange would be reflected in localOnChange as well.
This is not the case because, effectively, any calls made to OnTemperatureChange -= <subscriber> will not simply remove a delegate from OnTemperatureChange so that it contains one less delegate than before. Rather, such a call will assign an entirely new multicast delegate without having any effect on the original multicast delegate to which localOnChange also refers.
If subscribers can be added and removed from the delegate on different threads, it is wise (as noted earlier) to conditionally invoke the delegate or copy the delegate reference into a local variable before checking it for null. Although this approach prevents the invocation of a null delegate, it does not avoid all possible race conditions. For example, one thread could make the copy, and then another thread could reset the delegate to null, and then the original thread could invoke the previous value of the delegate, thereby notifying a subscriber that is no longer on the list of subscribers. Subscribers in multithreaded programs should ensure that their code remains robust in this scenario; it is always possible that a “stale” subscriber will be invoked.
To combine the two subscribers in the Thermostat example, you used the += operator. This operator takes the first delegate and adds the second delegate to the chain. Now, after the first delegate’s method returns, the second delegate is called. To remove delegates from a delegate chain, use the -= operator, as shown in Listing 14.6 with Output 14.1.
You can also use the + and – operators to combine delegates, as shown in Listing 14.7.
Using 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, is described in the “Understanding Events” section later in this chapter.
Both the + and - operators and their assignment equivalents, += and -=, are implemented internally using the static methods System.Delegate.Combine() and System.Delegate.Remove(), respectively. These methods take two parameters of type delegate. The first method, Combine(), joins the two parameters so that the first parameter refers 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. And, since the Remove() method could potentially return null, we use the C# 8.0 null-forgiveness operator to tell the compiler to assume a valid delegate instance remains.
One interesting thing to note about the Combine() method is that either or both of its parameters can be null. If one of them is null, Combine() returns the non-null parameter. If both are null, 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 still null.
Figure 14.1 highlights the sequential notification of both heater and cooler.
Although you coded only a single call to OnTemperatureChange(), the call is broadcast to both subscribers. Thus, with just 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, the subscribers are still called sequentially, not simultaneously, because they are all called on the same thread of execution.
To understand how events work, you need to revisit the first examination of the System.Delegate type internals. Recall that the delegate keyword is an alias for a type derived from System.MulticastDelegate. In turn, System.MulticastDelegate is derived from System.Delegate, which, for its part, is composed of an object reference (needed for nonstatic methods) and a method reference. When you create a delegate, the compiler automatically employs the System.MulticastDelegate type rather than the System.Delegate type. The MulticastDelegate class includes an object reference and a method reference, just like its Delegate base class, but it also contains a reference to another System.MulticastDelegate object.
When you add a method to a multicast delegate, the MulticastDelegate class creates a new instance of the delegate type, stores the object reference and the method reference for the added method into the new instance, and adds the new delegate instance as the next item in a list of delegate instances. In effect, the MulticastDelegate class maintains a list of Delegate objects. Conceptually, you can represent the thermostat example as shown in Figure 14.2.
When invoking a multicast delegate, each delegate instance in the list is called sequentially. Generally, delegates are called in the order they were added, but this behavior is not specified within the Common Language Infrastructure (CLI) specification. Furthermore, it can be overridden. Therefore, programmers should not depend on the invocation order.
Error handling makes awareness of the sequential notification critical. If one subscriber throws an exception, later subscribers will not receive the notification. Consider, for example, what would happen if you changed Heater’s OnTemperatureChanged() method so that it threw an exception, as shown in Listing 14.8.
Figure 14.3 shows an updated sequence diagram. Even though cooler and heater subscribed to receive messages, the lambda expression exception terminates the chain and prevents the cooler object from receiving notification.
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 14.9 shows the updates required in the CurrentTemperature property. The results appear in Output 14.2.
Listing 14.9 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 example, even though the delegate subscriber throws an exception, cooler still receives notification of the temperature change. After all notifications have been sent, Listing 14.9 reports any exceptions by throwing an AggregateException, which wraps a collection of exceptions that are accessible by the InnerExceptions property. In this way, all exceptions are still reported and, at the same time, all subscribers are notified.
There is another scenario in which it is useful to iterate over the delegate invocation list instead of simply invoking a delegate directly. This scenario relates to delegates that either do not return void or have ref or out parameters. In the thermostat example, the OnTemperatureChange delegate is of type Action<float>, which returns void and has no out or ref parameters. As a result, no data is returned to the publisher. This consideration is important, because an invocation of a delegate potentially triggers notification to multiple subscribers. If each of the subscribers returns a value, it is ambiguous as to which subscriber’s return value would be used.
If you changed OnTemperatureChange to return an enumeration value indicating whether the device was on because of the temperature change, the new delegate would be of type Func<float, Status>, where Status was an enum with elements On and Off. All subscriber methods would have to use the same method signature as the delegate, and thus each would be required to return a status value. Also, since OnTemperatureChange might potentially correspond to a chain of delegates, 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. However, although it is possible to use this approach in exceptional circumstances, the best advice is to avoid this scenario entirely by only returning void.
________________________________________