Multicast Delegates and the Observer Pattern

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.[1]

[1] In alpha versions of the C# 2.0 compiler, yield was a keyword rather than a contextual keyword. However, such a change could result in an incompatibility between C# 1.0 and C# 2.0. Instead, yield became a contextual keyword that must appear before return. As a result, no code-breaking change occurred because C# 1.0 did not allow any text (besides comments) prior to the return keyword.

Defining Subscriber Methods

Begin by defining the Heater and Cooler objects (see Listing 13.16).

Listing 13.16. Heater and Cooler Event Subscriber Implementations

 class Cooler {   public Cooler(float temperature)   {       Temperature = temperature;   }     public float Temperature   {       get{return _Temperature;}       set{_Temperature = value;}   }   private float _Temperature;   public void OnTemperatureChanged(float newTemperature)   {       if (newTemperature > Temperature)       {           System.Console.WriteLine("Cooler: On");       }       else       {           System.Console.WriteLine("Cooler: Off");       }   } } class Heater {   public Heater(float temperature)   {       Temperature = temperature;   }     public float Temperature   {       get       {           return _Temperature;       }       set       {           _Temperature = value;       }  }  private float _Temperature;  public void OnTemperatureChanged(float newTemperature)  {      if (newTemperature < Temperature)      {          System.Console.WriteLine("Heater: On");      }      else      {             System.Console.WriteLine("Heater: Off");      }   } } 

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

 public class Thermostat {   // Define the delegate data type   public delegate void TemperatureChangeHandler(       float newTemperature);     // Define the event publisher   public TemperatureChangeHandler OnTemperatureChange   {       get{ return _OnTemperatureChange;}       set{ _OnTemperatureChange = value;}   }   private TemperatureChangeHandler _OnTemperatureChange;     public float CurrentTemperature   {       get{return _CurrentTemperature;}       set       {           if (value != CurrentTemperature)           {               _CurrentTemperature = value;           }        }    }    private float _CurrentTemperature; } 

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

 class Program {   public static void Main()   {       Thermostat thermostat = new Thermostat();       Heater heater = new Heater(60);       Cooler cooler = new Cooler(80);       string temperature;       // Using C# 2.0 syntax.                                                     thermostat.OnTemperatureChange +=                                               heater.OnTemperatureChanged;                                            thermostat.OnTemperatureChange +=                                               cooler.OnTemperatureChanged;                                            Console.Write("Enter temperature: ");       temperature = Console.ReadLine();       thermostat.CurrentTemperature = int.Parse(temperature);   } } 

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

 public class Thermostat {   ...   public float CurrentTemperature   {       get{return _CurrentTemperature;}       set       {           if (value != CurrentTemperature)                                          {               _CurrentTemperature = value;                                              // INCOMPLETE: Check for null needed                                      // Call subscribers                                                       OnTemperatureChange(value);                                           }        }    }    private float _CurrentTemperature; } 

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

 public class Thermostat {   ...   public float CurrentTemperature   {       get{return _CurrentTemperature;}       set       {           if (value != CurrentTemperature)           {               _CurrentTemperature = value;               // If there are any subscribers               // then notify them of changes in               // temperature               TemperatureChangeHandler localOnChange =                                        OnTemperatureChange;                                                    if(localOnChange != null)                                                   {                                                                               // Call subscribers                                                         localOnChange(value);                                                   }                                                                        }         }    }    private float _CurrentTemperature; } 

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.

Advanced Topic: -= Operator for a Delegate Returns a New Instance

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 points at the same location that OnTemperatureChange points, one would think that any changes in OnTemperatureChange would be reflected in localOnChange as well.

This is not the case because effectively, any calls to OnTemperatureChange -= <listener> will not add a new delegate to OnTemperatureChange, but rather, will assign it an entirely new multicast delegate without having any effect on the original multicast delegate to which localOnChange also points.

Delegate Operators

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

 // ... Thermostat thermostat = new Thermostat(); Heater heater = new Heater(60); Cooler cooler = new Cooler(80); Thermostat.TemperatureChangeHandler delegate1; Thermostat.TemperatureChangeHandler delegate2; Thermostat.TemperatureChangeHandler delegate3; // use Constructor syntax prior to C# 2.0. delegate1 = heater.OnTemperatureChanged; delegate2 = cooler.OnTemperatureChanged; Console.WriteLine("Invoke both delegates:"); delegate3 = delegate1; delegate3 += delegate2;                                                     delegate3(90); Console.WriteLine("Invoke only delegate2"); delegate3 -= delegate1;                                                     delegate3(30); // ... 

The results of Listing 13.21 appear in Output 13.3.

Output 13.3.

 Invoke both delegates: Heater: Off Cooler: On Invoke only delegate2 Cooler: Off 

Furthermore, you can also use the + and operators to combine delegates, as Listing 13.22 shows.

Listing 13.22. Using the + and - Delegate Operators

 // ... Thermostat thermostat = new Thermostat(); Heater heater = new Heater(60); Cooler cooler = new Cooler(80); Thermostat.TemperatureChangeHandler delegate1; Thermostat.TemperatureChangeHandler delegate2; Thermostat.TemperatureChangeHandler delegate3; // Note: Use new Thermostat.TemperatureChangeHandler( //       cooler.OnTemperatureChanged) for versions //       of C# prior to version 2.0. delegate1 = heater.OnTemperatureChanged; delegate2 = cooler.OnTemperatureChanged; Console.WriteLine("Combine delegates using + operator:"); delegate3 = delegate1 + delegate2;                                          delegate3(60); Console.WriteLine("Uncombine delegates using - operator:"); delegate3 = delegate3 - delegate2;                                          delegate3(60); // ... 

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.

Sequential Invocation

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.

Advanced Topic: Multicast Delegate Internals

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, comprises an object reference and a method pointer (of type System.Reflection.MethodInfo). 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 method pointer, 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 pointer 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 linked list of Delegate objects. Conceptually, you can represent the thermostat example as shown in Figure 13.3.

Figure 13.3. Multicast Delegates Chained Together

When invoking the multicast, each delegate instance in the linked list is called sequentially. Generally, delegates are called in the order they were added, but this behavior is not specified within the CLI specification, and furthermore, it can be overridden. Therefore, programmers should not depend on an invocation order.

Error Handling

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

 class Heater {   ...   public void OnTemperatureChanged(float newTemperature)   {         throw new NotImplementedException();                                }   ... } 

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

 public class Thermostat {   // Define the delegate data type   public delegate void TemperatureChangeHandler(       float newTemperature);     // Define the event publisher   public event TemperatureChangeHandler OnTemperatureChange;     public float CurrentTemperature   {       get{return _CurrentTemperature;}       set       {           if (value != CurrentTemperature)           {               _CurrentTemperature = value;               if(OnTemperatureChange != null)               {                   foreach(                                                                        TemperatureChangeHandler handler in                                        OnTemperatureChange.GetInvocationList() )                              {                                                                              try                                                                        {                                                                              handler(value);                                                        }                                                                          catch(Exception exception)                                                 {                                                                              Console.WriteLine(exception.Message);                                    }                                                                        }                                                                          }            }        }    }    private float _CurrentTemperature; } 

Output 13.4.

 Enter temperature: 45 The method or operation is not implemented. Cooler: Off 

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

 public enum Status {     On,     Off } // Define the delegate data type public delegate Status TemperatureChangeHandler(   float newTemperature); 

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

 Status status = OnTemperatureChange(value); 

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.

Essential C# 2.0
Essential C# 2.0
ISBN: 0321150775
EAN: 2147483647
Year: 2007
Pages: 185 © 2008-2017.
If you may any questions please contact us: