Programming Concept: Events

Posted On: 2022-07-11

By Mark

Throughout the day, humans solve problems and navigate situations by reacting to external prompts. From waiting for a traffic light to answering a knock at the door, our everyday lives are filled with responding to mundane events. Much like humans, computers often need to respond correctly to external prompts: from responding to a button press to waiting for a file to become available, most computer programs spend more time listening for and responding to events than anything else.

Despite how common they are, many developers aren't comfortable creating their own events. From a problem solving standpoint, this can be quite limiting: events are useful for much more than reacting to user interactions - they are a powerful abstraction that allows designers to focus on making the program do the right thing at the right time. Fortunately, many modern languages/frameworks provide built-in support for events, so learning to use them is mostly just familiarizing oneself with the tools.

Anatomy of an Event

Events are usually designed in two separate pieces, the "listener" and the "sender". To explain those pieces and their roles, I'll use an analogy: consider a visitor ringing the doorbell. When a visitor arrives, they use the doorbell to signal their arrival, and the home owner (or whoever is currently inside) will respond to it. In this analogy, the visitor is the "sender" of the event: they're in complete control of detecting the event occurred, and deciding when to pass along that information (maybe they want to check their hair first before ringing the doorbell.) The home owner is the "listener" of the event: they don't know when the event will happen, but they are in complete control of how to respond to it (open the door, invite the visitor in, etc.) The doorbell, in this case, is something like the event framework itself: both the sender (visitor) and listener (home owner) are counting on the doorbell correctly working - neither of them knows how the doorbell works, but they don't need to: as long as it works, everyone's happy.

Listeners

Event listeners are fairly simple: any method that conforms to the expected signature (ie. same parameters) can be defined as a listener. The listener (generally) also has to be registered with the sender - and often more than one listener can be registered with a single sender. How exactly a listener registers with the sender may vary based on language/framework, but it generally involves "adding" the listener*. This allows a single sender to reach multiple listeners - which can be very convenient for doing multiple different things in response to one event (to re-use the earlier analogy: when the visitor rings the doorbell, one person answers the door, and a different person pulls dinner out of the oven.)

Senders

Event senders are also fairly simple, albeit only if the language/framework makes it so. A sender generally has two responsibilities: make itself available so that listeners can register with it, and notify the registered listeners when the event occurs. To make itself available, a sender generally just indicates itself as an event to the language/framework (ie. using the event keyword), and the language/framework automatically takes care of all the implementation details (providing methods to add/remove listeners, etc.) To notify the listeners, the sender must validate that it's the right time to do so (ie. make sure it's the right address and their hair's in order), and then simply use the language/framework to "raise" the event.

Code Example

As mentioned previously, the semantics for defining an event vary by language/framework, but I thought a concrete example would help nonetheless. C# has some particularly good support for this; a sender can be defined in a single line:

public event Action<Visitor> OnArrival;
This is an event that will provide the visitor as its parameter when it's raised*. Of course, that single line doesn't actually raise the event - that needs to be done elsewhere (ie. in the method that runs when the Visitor reaches a location.

To raise an event, one needs to call the Invoke method. When that code runs, it will stop processing the current code, and execute the code of all the listeners before returning control to the current method - that is, it runs single-threaded.

//If no listeners have registered, C# treats the event as null
if (OnArrival != null)
{
    //Raise the event to any listeners, telling them which visitor has arrived ("this")
    OnArrival.Invoke(this);
    //At this point, all the listeners have completed handling the event
}

On the listener side of things, C# uses some very concise (if a bit unclear) syntax for registering a new listener:

visitor.OnArrival += InviteIndoors;
Here, the InviteIndoors method is being registered with the OnArrival event on an instance of Visitor named "visitor". Notably, there's nothing special about how events are referenced: if OnArrival is an instance member, the code only registers to listen to events on that instance. If it's static, then listeners are registering with one sender that's used by all Visitor instances (in which case, a parameter is useful for distinguishing between different visitors).

As far as defining the actual listener goes - any function that matches the event's signature will automatically work - no additional syntax required. Thus, for a listener that uses Action<Visitor>, any method that has a single Visitor as a parameter and returns void can be used:

private void InviteIndoors(Visitor whosAtTheDoor){

While events in C# mostly follow syntax and conventions found elsewhere in the language, that also includes garbage collection - which can sometimes catch developers by surprise. By adding an event listener, you're telling the garbage collector that the listener will live at least as long as the sender. Sometimes this is fine (ie. if the listener is intended to be long-lived, or the sender is shorter-lived than the listener) but if you try to use static events for short-lived listeners, you'll need to remember to remove the listeners - otherwise they'll keep on living (and listening) forever. In the complete example (below), I've accounted for this detail.

public class Visitor
{
    //In order for a listener to register with a sender, it needs a reference to the sender.
    //For simplicity, I've made the sender static (so it's always available), and the individual visitor is provided to the listener as a parameter.
    public static event Action<Visitor> OnArrival;

    public void Visit()
    {
        //Logic validation (ie. address and hair checking) omitted for simplicity

        //If no listeners have registered, C# treats the event as null
        if (OnArrival != null)
        {
            //Raise the event to any listeners, telling them which visitor has arrived ("this")
            OnArrival.Invoke(this);
            //At this point, all the listeners have completed handling the event
        }
    }
}
//In a separate file...
public class FriendlyResident
{
    //Listeners can register with an event anywhere that they can get a valid reference to the sender.
    public void StartListening()
    {
        //Register the listener by adding it (the += operation)
        Visitor.OnArrival += InviteIndoors;
    }

    private void InviteIndoors(Visitor whosAtTheDoor)
    {
        //Implementation omitted for brevity
    }

    //A registered event listener will keep any relevant objects ("this") alive for as long as the sender is alive
    //Since the sender is static in this example, that means it's alive as long as the application is running.
    private void StopListening()
    {
        //Remove the listener when it's no longer needed to avoid memory leaks
        Visitor.OnArrival -= InviteIndoors;
    }
}

When To Use Events

It is (hopefully) clear how to use events, but a bit more should be said about when to use them. Typically, events are most useful when you have an action that you want the code to perform, but it's not obvious when is the right time to perform that action. Events make it simple to decompose that task into two smaller tasks: one to perform the action (the listener) and one to determine when is the right time (the sender.) This makes it easier to write clean code to solve each of those individual tasks - and events form the essential connection that allows those two (individually clean) solutions to communicate with each other.

From an architecture standpoint, events can also be a handy way of separating concerns. Separate libraries/classes may use encapsulation to isolate their internals from each other, so events can be an essential tool for allowing one piece of code to respond to conditions that emerge from the internals of a different piece of code. Consider, for example, simulating food preparation: one part of the code handles cooking food while another handles cleaning and organizing dishware. If the dishware code tries to test the oven temperature or the chemical mixture of the ingredients, to know when's the time to hand off a plate, it will become a maintenence nightmare. Instead, the cooking code can provide a "baking done" event that describes what was made (ie. as a parameter), and the dish-controlling code can listen for it and respond by providing a clean, perfectly sized dish of it's own.

Conclusion

Events are a fundamental part of modern programming - but many developers only ever work with listeners, and miss out on the advantages of making their own events. Languages and tooling often make events simple and elegant to use - as I hope this post has demonstrated. Additionally, while the examples provided focus on C#, support for events has been around for a long time (the 30+ year old VB was designed for events) and even languages that don't have events in their spec have updates/libraries to make them easier to use (ie. C++ has Boost Signals). If you're a developer that's not yet comfortable creating your own events, I hope this post encouraged you to give it a try - I'm confident it's worth it.