Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 68

Observer Pattern for Event Notification

Recommended resources

Hello! Welcome to your next lesson on C++ design patterns.

In our last session, we explored the Strategy pattern, focusing on how to make an object's internal algorithms interchangeable. We dissected the trade-offs between runtime polymorphism (virtual functions) and compile-time polymorphism (templates/policies), particularly their impact on performance and memory.

Today, we shift our focus from an object's internal behavior to the communication between objects. We will be learning how to implement the Observer pattern to create a one-to-many dependency between objects for event notification. This pattern is the backbone of event-driven systems, GUI frameworks, and any architecture where multiple components need to react to changes in another component without being tightly coupled to it.

Just as with the Strategy pattern, we won't just look at the syntax. We will dig into the mechanics of different implementations, the critical issue of object lifetime management, and the performance implications of design choices, all of which are vital for your goal of writing robust, low-level C++ code.

1. The Problem: The Tangled Web of Dependencies

Imagine you are building a system where a single event needs to trigger actions in multiple, unrelated subsystems. For example, in a game, when a character takes damage, you might need to:

  • Update the health bar on the UI.
  • Play a "grunt" sound effect.
  • Check if an "On the Brink" achievement has been unlocked.
  • Log the event for analytics.

The naive approach is to have the Character class directly call methods on the UI, SoundEngine, AchievementSystem, and AnalyticsManager.

// BAD: Tightly coupled design
class Character {
    // Pointers to every system that cares about us
    UI* ui_;
    SoundEngine* soundEngine_;
    AchievementSystem* achievementSystem_;
    // ...

public:
    void takeDamage(int amount) {
        health_ -= amount;

        // Directly call every dependent system
        ui_->updateHealthBar(health_);
        soundEngine_->playSound("pain_grunt.wav");
        achievementSystem_->checkDamageAchievements(this);
        // ... what if we add another system? We have to change this class!
    }
private:
    int health_;
};

This creates a maintenance nightmare. The Character class is now tightly coupled to every subsystem that observes it. Adding a new subsystem requires modifying the Character class. This violates the Open/Closed Principle and makes the system brittle.

The Observer Design Pattern in Cpp - Mike Shah - CppCon 2022

To visualize this problem of tight coupling, let's watch the beginning of the CppCon 2022 talk 'The Observer Design Pattern in Cpp' by Mike Shah. He uses the excellent example of Angry Birds to show how many different subsystems are involved in a single game event.

Watch from 00:45 to 04:51. Focus on how a single action leads to a cascade of effects (physics, scoring, sound, etc.) and why bundling all that logic into one function creates a coupled, complicated system.


2. The Solution: Decoupling with Publish-Subscribe

The Observer pattern solves this by inverting the dependency. Instead of the Character knowing about all its observers, the observers register their interest with the Character. The Character simply maintains a list of interested observers and notifies them when an event occurs, without needing to know who they are or what they do.

This introduces two key roles:

  1. Subject (also called Publisher or Observable): The object of interest (Character). It exposes methods to allow observers to subscribe (register) and unsubscribe (unregister), and it has a method to notify all subscribers of an event.
  2. Observer (also called Subscriber or Listener): An object that wishes to be notified (UI, SoundEngine, etc.). It defines a common interface with an update() or onNotify() method that the Subject calls.
UML Class Diagram for Weather Station Observer Pattern
This UML diagram illustrates the Observer pattern in a weather station context. `WeatherData` is the Subject, holding data and a list of `IObserver` pointers. Classes like `CurrentConditionDisplay` are concrete Observers that register with `WeatherData` and get updated when the weather changes.

3. The Classic Implementation: Polymorphic Observers

The traditional implementation uses polymorphism, which you're familiar with from our lesson on the Strategy pattern. We define abstract base classes for the ISubject and IObserver.

Observer · Design Patterns Revisited

The excellent book 'Game Programming Patterns' by Bob Nystrom has a chapter on the Observer pattern that provides a very clear, code-driven explanation of this classic structure. It's a great textual companion to the video material.

Read the sections 'The observer' and 'The subject'. This will show you the basic C++ interfaces for both participants and how the Subject manages its list of observers.

Let's see how this structure is built up in practice.

The Observer Design Pattern in Cpp - Mike Shah - CppCon 2022

The CppCon talk continues by building the pattern from the ground up. First, it shows a very basic version with concrete classes, and then refines it using the abstract interfaces we just read about. This step-by-step approach is great for understanding the design.

Watch from 12:28 to 29:14. Pay close attention to the transition from the simple, concrete implementation to the more flexible one using ISubject and IObserver interfaces. This leverages polymorphism to decouple the concrete classes.

Under the Hood: Performance and Lifetime

As you know from our previous lesson, this polymorphic approach comes with trade-offs:

  • Virtual Dispatch: The call observer->onNotify(...) is an indirect function call through the vtable. This has a slight performance cost and, more importantly, can prevent compiler optimizations like inlining.
  • Object Lifetime & Dangling Pointers: The most critical issue in C++ is managing the lifetimes of the observers. If a Subject holds a raw pointer to an Observer that gets destroyed, the Subject now has a dangling pointer. Calling onNotify() on it will lead to undefined behavior and likely a crash.

This "lapsed listener problem" is so common and dangerous that it requires a robust solution. The answer lies in the RAII (Resource Acquisition Is Initialization) idiom. An Observer should automatically unregister itself from its Subject when it is destroyed.

The Observer Design Pattern in C++ - Part 3 of n - Registration and Lifetime

To truly grasp this crucial C++ concept, Mike Shah has a separate, focused video on solving the observer lifetime problem using RAII. This is one of the most important parts of implementing the Observer pattern correctly in C++.

Please watch this entire video (it's split into logical sections for you). It will clearly demonstrate the dangling pointer problem and then walk you through the elegant RAII-based solution where the observer's constructor subscribes and its destructor unsubscribes. This is a practical application of core C++ principles.


4. Advancing the Pattern: Event Filtering and Data Passing

A simple onNotify() call is often not enough. Observers might need to know what event happened or receive data associated with it. We can extend the pattern to support this.

There are two main models for passing data:

  • Push Model: The Subject pushes all relevant data to the Observer in the onNotify call (e.g., onNotify(Event::PlayerDied, new_score, dead_player_id)). This is simple for the observer but can be inefficient if observers only need a fraction of the data.
  • Pull Model: The Subject passes a reference to itself (onNotify(this)), and the Observer is then responsible for querying (pulling) the data it needs from the Subject. This is more flexible but requires an extra step for the observer.

A common and powerful approach is to allow observers to subscribe to specific types of events.

The Observer Design Pattern in Cpp - Mike Shah - CppCon 2022

Let's return to the CppCon talk, which now presents a more advanced implementation. The Subject is enhanced to categorize observers by event type, allowing for much more granular and efficient notifications.

Watch from 29:14 to 43:26. Notice how a std::map is used to store lists of observers, with an enum as the key. This lets the subject notify only the observers interested in a specific event (e.g., notify(EventType::SOUND)), which is a much more scalable design.

Test your understanding!

You are designing a FileWatcher class (the Subject) that monitors a file for changes. It can detect three types of events: MODIFIED, RENAMED, and DELETED.

You have two observers:

  1. CacheManager: Needs to be notified about MODIFIED and DELETED events to invalidate a data cache.
  2. UI_Updater: Needs to be notified about RENAMED events to update a file name displayed in a user interface.

How would you design the subscription method(s) in your FileWatcher subject to allow these observers to register for only the specific events they care about? Sketch the class and method signatures.

Show answer

A good approach is to use an enum for the event types and pass it during subscription. The FileWatcher would then store observers in separate lists or in a map keyed by the event type.

#include <vector>
#include <map>
#include <functional>

// Forward declaration
class FileWatcher;

// 1. Define the event types
enum class FileEventType {
    MODIFIED,
    RENAMED,
    DELETED
};

// 2. Define the Observer interface
class IFileObserver {
public:
    virtual ~IFileObserver() = default;
    // Pass the event type and subject to the observer
    virtual void onFileEvent(FileWatcher* source, FileEventType eventType) = 0;
};

// 3. Define the Subject
class FileWatcher {
private:
    // Use a map to store lists of observers for each event type
    std::map<FileEventType, std::vector<IFileObserver*>> observers_;
    std::string filePath_;

public:
    // Subscription method takes the event type
    void subscribe(FileEventType eventType, IFileObserver* observer) {
        observers_[eventType].push_back(observer);
    }

    void unsubscribe(FileEventType eventType, IFileObserver* observer) {
        // Logic to find and remove the observer from the correct vector
    }

    // Notify method for a specific event
    void notify(FileEventType eventType) {
        if (observers_.count(eventType)) {
            for (IFileObserver* observer : observers_[eventType]) {
                observer->onFileEvent(this, eventType);
            }
        }
    }

    void simulateModification() {
        // ... some logic ...
        notify(FileEventType::MODIFIED);
    }
};

This design allows CacheManager to call subscribe(FileEventType::MODIFIED, ...) and subscribe(FileEventType::DELETED, ...) while UI_Updater only calls subscribe(FileEventType::RENAMED, ...). The notify method is now much more efficient.


5. Further Considerations and Modern Alternatives

Your goal is to understand C++ deeply, so let's explore some advanced considerations that separate a textbook implementation from a production-ready one.

The Observer Pattern - further considerations and generalised C++ implementation

A highly-rated Stack Overflow post provides a fantastic, in-depth analysis of the Observer pattern, going far beyond the basics. It discusses many of the practical issues you'd face in a real-world project.

This is a dense but rewarding read. Please review these three key sections: 'Arbitrary Handlers' (in Part 1): This discusses moving beyond a fixed OnEvent virtual function to using arbitrary callbacks (like member function pointers), which is a precursor to std::function. 'Where to Store Observers?' (in Part 2): This analyzes the performance trade-offs (memory vs. speed) of different storage strategies, which is a classic systems design problem. 'Post Subscription Consistency' (in Part 2): This covers an often-overlooked problem: ensuring an observer's state is correct immediately after it subscribes, not just on subsequent notifications.

Function-Based Observers with std::function

The classic Observer pattern is class-heavy. For many use cases, creating an entire class just to receive a notification is overkill. Modern C++ offers a more lightweight approach using std::function, which can store any callable entity (a free function, a lambda, a member function).

Instead of a list of IObserver*, the Subject can hold a list of std::function<void(EventType, ...)>. This allows clients to subscribe with a simple lambda, which is often much cleaner.

// Subject
class Notifier {
    std::vector<std::function<void(std::string)>> listeners_;
public:
    void subscribe(std::function<void(std::string)> listener) {
        listeners_.push_back(listener);
    }
    void send(std::string message) {
        for(const auto& listener : listeners_) {
            listener(message);
        }
    }
};

// Client code
Notifier notifier;
int x = 10;

// Subscribe with a lambda that captures state
notifier.subscribe([&x](std::string msg) {
    std::cout << "Lambda received: " << msg << " (x is " << x << ")\n";
});

This approach is more flexible but shares a potential downside with virtual functions: type erasure. The call to a std::function also involves an indirect call, which can have performance overhead.


Conclusion

The Observer pattern is a powerful tool for building loosely coupled systems. By inverting dependencies, it allows subjects to notify observers without being statically bound to them, leading to more flexible and extensible code.

Key Takeaways:

  • Intent: Define a one-to-many dependency so that when one object (Subject) changes state, all its dependents (Observers) are notified and updated automatically.
  • Classic Implementation: Uses polymorphism with ISubject and IObserver interfaces. The notification happens via a virtual function call.
  • Critical C++ Concern: Lifetime management is paramount. Use the RAII idiom (subscribe in constructor, unsubscribe in destructor) to prevent dangling pointers and crashes.
  • Advanced Implementations: Enhance the pattern by allowing observers to subscribe to specific event types and by passing data via push or pull models.
  • Modern C++ Alternative: Use std::function to create lightweight, function-based observers, often simplifying client code by using lambdas instead of full-blown observer classes.

Next Lesson Preview:
In our next lesson, we'll explore the Command pattern. Where the Observer pattern deals with broadcasting an event to many listeners, the Command pattern is about encapsulating a single action or request into a standalone object. This powerful concept allows you to parameterize clients with different requests, queue or log requests, and support undoable operations. It's a key building block for many complex applications and will be directly applicable to creating the task queue for our Thread Pool project.