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.
Th 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 signature1.
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 will 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 further later in the chapter.
In C# 8.0, however, invoking the CurrentTemperature delegate directly will trigger 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.
Unfortunately, no such special uninterruptable null-checking logic exists prior to C# 6.0. As such, the implementation is significantly more verbose in earlier C# versions, as shown in Listing 14.6.
Instead of checking for null directly, this code first assigns OnTemperatureChange to a second local delegate variable, localOnChange. This simple modification ensures that if all OnTemperatureChange subscribers are removed (by a different thread) between checking for null and sending the notification, you will not raise a NullReferenceException.
For the remainder of the book, all examples rely on the C# 6.0 null-conditional operator for delegate invocation.
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.7 with Output 14.1.
You can also use the + and – operators to combine delegates, as shown in Listing 14.8.
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.