Understanding Events

There are two key problems with the delegates as you have used them so far in this chapter. To overcome these issues, C# uses the keyword event. In this section, you will see why you would use events and how they work.

Why Events?

This chapter and the preceding one covered all you need to know about how delegates work. Unfortunately, weaknesses in the delegate structure may inadvertently allow the programmer to introduce a bug. These issues relate to encapsulation that neither the subscription nor the publication of events can sufficiently control. Specifically, using events restricts external classes from doing anything other than adding subscribing methods to the publisher via the += operator and then unsubscribing using the -= operator. In addition, they restrict classes, other than the containing class, from invoking the event.

Encapsulating the Subscription

As demonstrated earlier, it is possible to assign one delegate to another using the assignment operator. Unfortunately, this capability introduces a common source of bugs. Consider Listing 14.10.

Listing 14.10: Using the Assignment Operator = Rather Than +=
public class Program
{
    public static void Main()
    {
        Thermostat thermostat = new();
        Heater heater = new(60);
        Cooler cooler = new(80);
 
        thermostat.OnTemperatureChange =
            heater.OnTemperatureChanged;
 
        // Bug: Assignment operator overrides 
        // previous assignment
        thermostat.OnTemperatureChange = 
            cooler.OnTemperatureChanged;
 
        Console.Write("Enter temperature: ");
        string? temperature = Console.ReadLine();
        if (!int.TryParse(temperature, out int currentTemperature))
        {
            Console.WriteLine($"'{temperature}' is not a valid integer.");
            return;
        }
        thermostat.CurrentTemperature = currentTemperature;
    }
}

Listing 14.10 is almost identical to Listing 14.6, except that instead of using the += operator, you use a simple assignment operator. As a result, when the code assigns cooler.OnTemperatureChanged to OnTemperatureChange, heater.OnTemperatureChanged is cleared out because an entirely new chain is assigned to replace the previous one. The potential for mistakenly using an assignment operator, when the += assignment was intended, is so high that it would be preferable if the assignment operator were not even supported for objects except within the containing class. The event keyword provides this additional encapsulation so that you cannot inadvertently cancel other subscribers.

Encapsulating the Publication

The second important difference between delegates and events is that events ensure that only the containing class can trigger an event notification. Consider Listing 14.11.

Listing 14.11: Firing the Event from Outside the Events Container
public class Program
{
    public static void Main()
    {
        Thermostat thermostat = new();
        Heater heater = new(60);
        Cooler cooler = new(80);
 
        thermostat.OnTemperatureChange +=
            heater.OnTemperatureChanged;
 
        thermostat.OnTemperatureChange +=
            cooler.OnTemperatureChanged;
 
        // Bug: Should not be allowed
        thermostat.OnTemperatureChange(42);
    }
}

In Listing 14.11, Program can invoke the OnTemperatureChange delegate even though the CurrentTemperature on thermostat did not change. Program, therefore, triggers a notification to all thermostat subscribers that the temperature changed, even though the thermostat temperature did not change. As before, the problem with the delegate is insufficient encapsulation. Thermostat should prevent any other class from being able to invoke the OnTemperatureChange delegate.

Declaring an Event

C# provides the event keyword to deal with both problems. Although seemingly like a field modifier, event defines a new type of member (see Listing 14.12).

Listing 14.12: Using the event Keyword with the Event-Coding Pattern
public class Thermostat
{
    public class TemperatureArgs : System.EventArgs
    {
        public TemperatureArgs(float newTemperature)
        {
            NewTemperature = newTemperature;
        }
 
        public float NewTemperature { getset; }
    }
 
    // Define the event publisher
    public event EventHandler<TemperatureArgs> OnTemperatureChange =
        delegate { };
 
    public float CurrentTemperature
    // ...
    private float _CurrentTemperature;
}

The new Thermostat class has four changes relative to the original class. First, the OnTemperatureChange property has been removed, and OnTemperatureChange has instead been declared as a public field. This seems contrary to solving the earlier encapsulation problem. It would make more sense to increase the encapsulation, not decrease it by making a field public. However, the second change was to add the event keyword immediately before the field declaration. This simple change provides all the encapsulation needed. By adding the event keyword, you prevent the use of the assignment operator on a public delegate field (e.g., thermostat.OnTemperatureChange = cooler.OnTemperatureChanged). In addition, only the containing class is able to invoke the delegate that triggers the publication to all subscribers (e.g., disallowing thermostat.OnTemperatureChange(42) from outside the class). In other words, the event keyword provides the needed encapsulation that prevents any external class from publishing an event or unsubscribing previous subscribers it did not add. This resolves the two previously mentioned issues with plain delegates and is one of the key reasons for the inclusion of the event keyword in C#.

Another potential pitfall with plain delegates is that it is all too easy to forget to check for null4 before invoking the delegate. This omission may result in an unexpected NullReferenceException. Fortunately, the encapsulation that the event keyword provides an alternative possibility during declaration (or within the constructor), as shown in Listing 14.12. Notice that when declaring the event, we assign delegate { }—a non-null delegate, which does nothing. By assigning the empty delegate, we can raise the event without checking whether there are any subscribers. (This behavior is similar to assigning an array of zero items to a variable. Doing so allows the invocation of an array member without first checking whether the variable is null.) Of course, if there is any chance that the delegate could be reassigned to have a null value, a check is still required. However, because the event keyword restricts assignment to occur only within the class, any reassignment of the delegate could occur only from within the class. Assuming null is never assigned, there will be no need to check for null whenever the code invokes the delegate.5

Coding Conventions

All you need to do to gain the desired functionality is to change the original delegate variable declaration to a field and add the event keyword. With these two changes, you provide the necessary encapsulation; all other functionality remains the same. However, an additional change occurs in the delegate declaration in the code in Listing 14.12. To follow standard C# coding conventions, you should replace Action<float> with a new delegate type: EventHandler<TemperatureArgs>, a Common Language Runtime (CLR) type whose declaration is shown in Listing 14.13.

Listing 14.13: The Generic Event Handler Type
public delegate void EventHandler<TEventArgs>(
    object sender, TEventArgs e)
    where TEventArgs : EventArgs;

The result is that the single temperature parameter in the Action<TEventArgs> delegate type is replaced with two new parameters—one for the publisher or “sender” and a second for the event data. This change is not something that the C# compiler will enforce, but passing two parameters of these types is the norm for a delegate intended for an event.

The first parameter, sender, contains an instance of the class that invoked the delegate. This is especially helpful if the same subscriber method registers with multiple events—for example, if the heater.OnTemperatureChanged event subscribes to two different Thermostat instances. In such a scenario, either Thermostat instance can trigger a call to heater.OnTemperatureChanged. To determine which instance of Thermostat triggered the event, you use the sender parameter from inside Heater.OnTemperatureChanged(). If the event is static, this option will not be available, so you would pass null for the sender argument value.

The second parameter, TEventArgs e, is specified as type Thermostat.TemperatureArgs. The important thing about TemperatureArgs, at least as far as the coding convention goes, is that it derives from System.EventArgs. (In fact, derivation from System.EventArgs is something that the framework forced with a generic constraint until Microsoft .NET Framework 4.5.) The only significant property on System.EventArgs is Empty, which is used to indicate that there is no event data. When you derive TemperatureArgs from System.EventArgs, however, you add an additional property, NewTemperature, to pass the temperature from the thermostat to the subscribers.

Let’s summarize the coding convention for events: The first argument, sender, is of type object and contains a reference to the object that invoked the delegate or null if the event is static. The second argument is of type System.EventArgs or something that derives from System.EventArgs but contains additional data about the event. You invoke the delegate exactly as before, except for the additional parameters. Listing 14.14 shows an example.

Listing 14.14: Firing the Event Notification
public class Thermostat
// ...
 
    public float CurrentTemperature
    {
        get { return _CurrentTemperature; }
        set
        {
            if(value != CurrentTemperature)
            {
                _CurrentTemperature = value;
                // If there are any subscribers,
                // notify them of changes in 
                // temperature by invoking said subscribers
                OnTemperatureChange?.Invoke(
                      thisnew TemperatureArgs(value));
            }
        }
    }
    private float _CurrentTemperature;
}

You usually specify the sender using the container class (this) because it is the only class that can invoke the delegate for events.

In this example, the subscriber could cast the sender parameter to Thermostat and access the current temperature that way, as well as via the TemperatureArgs instance. However, the current temperature on the Thermostat instance may change via a different thread. When events may occur due to state changes, passing the previous value along with the new value is a pattern frequently used to control which state transitions are allowable.

Guidelines
DO check that the value of a delegate is not null before invoking it (possibly by using the null-conditional operator in C# 6.0).
DO pass the instance of the class as the value of the sender for nonstatic events.
DO pass null as the sender for static events.
DO NOT pass null as the value of the eventArgs argument.
DO use System.EventArgs or a type that derives from System.EventArgs for a TEventArgs type.
CONSIDER using a subclass of System.EventArgs as the event argument type (TEventArgs) unless you are sure the event will never need to carry any data.
AdVanced Topic
Event Internals

As mentioned earlier, events provide encapsulation that restricts external classes to only subscribing and unsubscribing methods to the publisher via the += and -= operators respectively. In addition, they restrict classes, other than the containing class, from invoking the event. To do so, the C# compiler takes the public delegate variable with its event keyword modifier and declares the delegate as private. In addition, it adds a couple of methods and two special event blocks. Essentially, the event keyword is a C# shortcut for generating the appropriate encapsulation logic. Consider the example in the event declaration shown in Listing 14.15.

Listing 14.15: Declaring the OnTemperatureChange Event
public class Thermostat
// ...
    public event EventHandler<TemperatureArgs>? OnTemperatureChange;
}

When the C# compiler encounters the event keyword, it generates CIL code equivalent to the C# code shown in Listing 14.16.

Listing 14.16: C# Conceptual Equivalent of the Event CIL Code Generated by the Compiler
public class Thermostat
// ...
    // Declaring the delegate field to save the 
    // list of subscribers
    private EventHandler<TemperatureArgs>? _OnTemperatureChange;
 
    public void add_OnTemperatureChange(
        EventHandler<TemperatureArgs> handler)
    {
        System.Delegate.Combine(_OnTemperatureChange, handler);
    }
 
    public void remove_OnTemperatureChange(
        EventHandler<TemperatureArgs> handler)
    {
        System.Delegate.Remove(_OnTemperatureChange, handler);
    }
 
    #if ConceptualEquivalentCode
    public event EventHandler<TemperatureArgs> OnTemperatureChange
    {
        //Would cause a compiler error
        add
        {
            add_OnTemperatureChange(value);
        }
        //Would cause a compiler error
        remove
        {
            remove_OnTemperatureChange(value);
        }
    } 
    #endif // ConceptualEquivalentCode

In other words, the code shown in Listing 14.15 is (conceptually) the C# shorthand that the compiler uses to trigger the code expansion shown in Listing 14.16. (The “conceptually” qualifier is needed because some details regarding thread synchronization have been eliminated for elucidation.)

The C# compiler first takes the original event definition and defines a private delegate variable in its place. As a result, the delegate becomes unavailable to any external class—even to classes derived from it.

Next, the C# compiler defines two methods, add_OnTemperatureChange() and remove_OnTemperatureChange(), in which the OnTemperatureChange suffix is taken from the original name of the event. These methods are responsible for implementing the += and -= assignment operators, respectively. As Listing 14.16 shows, these methods are implemented using the static System.Delegate.Combine() and System.Delegate.Remove() methods, discussed earlier in the chapter. The first parameter passed to each of these methods is the private EventHandler<TemperatureArgs> delegate instance, OnTemperatureChange.

Perhaps the most curious part of the code generated from the event keyword is the last segment. The syntax is very similar to that of a property’s getter and setter methods, except that the methods are called add and remove, respectively. The add block takes care of handling the += operator on the event by passing the call to add_OnTemperatureChange(). In a similar manner, the remove block operator handles the -= operator by passing the call on to remove_OnTemperatureChange.

Take careful note of the similarities between this code and the code generated for a property. Recall that the C# implementation of a property is to create get_<propertyname> and set_<propertyname> and then to pass calls to the get and set blocks on to these methods. Clearly, the event syntax in such cases is very similar.

Another important characteristic to note about the generated CIL code is that the CIL equivalent of the event keyword remains in the CIL. In other words, an event is something that the CIL code recognizes explicitly; it is not just a C# construct. By keeping an equivalent event keyword in the CIL code, all languages and editors can provide special functionality because they can recognize the event as a special class member.

Customizing the Event Implementation

You can customize the code that the compiler generates for += and -=. Consider, for example, changing the scope of the OnTemperatureChange delegate so that it is protected rather than private. This, of course, would allow classes derived from Thermostat to access the delegate directly instead of being limited to the same restrictions as external classes. To enable this behavior, C# allows the same property as the syntax shown in Listing 14.17. In other words, C# allows you to define custom add and remove blocks to provide a unique implementation for each aspect of the event encapsulation.

Listing 14.17: Custom add and remove Handlers
public class Thermostat
{
    public class TemperatureArgs : System.EventArgs
    // ...
    // Define the event publisher
    public event EventHandler<TemperatureArgs> OnTemperatureChange
    {
        add
        {
            _OnTemperatureChange = 
                (EventHandler<TemperatureArgs>)
                    System.Delegate.Combine(value, _OnTemperatureChange);
        }
        remove
        {
            _OnTemperatureChange = 
                (EventHandler<TemperatureArgs>?)
                    System.Delegate.Remove(_OnTemperatureChange, value);
        }
    }
    protected EventHandler<TemperatureArgs>? _OnTemperatureChange;
 
    public float CurrentTemperature
    // ...
    private float _CurrentTemperature;
}

Here, the delegate that stores each subscriber, _OnTemperatureChange, was changed to protected. In addition, implementation of the add block switches around the delegate storage so that the last delegate added to the chain is the first delegate to receive a notification. However, code should not rely on this implementation.

________________________________________

4. Using the null conditional operator in C# 6.0.
5. While rare, note that this pattern doesn’t work when the event is contained within a struct.
{{ snackbarMessage }}