The Observer Pattern in C++: weak_ptr Subscribers, Typed Events and Signal/Slot Designs

Key takeaways

Observer pattern in C++: decouple publishers and subscribers, weak_ptr, typed events, signal/slot style—patterns, pitfalls, and production examples.

Behavioral patterns—including Observer—are surveyed in C++ behavioral patterns #20-1 and overview #20-2. For event/listener-heavy APIs elsewhere, compare JavaScript patterns.

What is the Observer pattern? Why use it?

Problem Scenario: State Change Notification

Problem: When the data model changes, multiple UI components need to be updated. Directly calling each component creates strong coupling.

// Bad example: strong coupling
class DataModel {
public:
    void setValue(int v) {
        value = v;
        // Call UI component directly
        chart->update(value);
        label->update(value);
        logger->log(value);
    }
private:
    int value;
    Chart* chart;
    Label* label;
    Logger* logger;
};

Problem:

  • strong coupling: DataModel must know all UI components
  • Difficult to expand: DataModel needs to be modified when adding a new component
  • No reuse: Difficult to reuse DataModel in other projects Solution: Observer Pattern separates Subject and Observer. Subject only manages the Observer list and notifies with notify() when state changes.

The dependency is inverted rather than removed: DataModel now depends only on an abstract Observer interface, and the chart, label and logger depend on the model. New views can be added without touching the model, which is the point. What you give up is visibility. With direct calls, reading setValue tells you exactly what happens on a change; with observers, the answer depends on who subscribed at runtime, in what order, and whether any of them is still alive. Almost every pitfall in this article (lifetimes, re-entrancy, ordering, threads) comes from that loss of a fixed, visible call sequence.

// Good example: loose coupling
class DataModel {
public:
    void setValue(int v) {
        value = v;
        notify(value);  // Notify all Observers
    }
    
    void attach(std::shared_ptr<Observer> obs) {
        observers.push_back(obs);
    }
    
private:
    int value;
    std::vector<std::weak_ptr<Observer>> observers;
    
    void notify(int value) {
        for (auto& obs : observers) {
            if (auto ptr = obs.lock()) {
                ptr->update(value);
            }
        }
    }
};
flowchart TD
    subject["Subject (DataModel)"]
    obs1["Observer 1 (Chart)"]
    obs2["Observer 2 (Label)"]
    obs3["Observer 3 (Logger)"]
    
    subject -->|notify| obs1
    subject -->|notify| obs2
    subject -->|notify| obs3
    
    obs1 -.->|attach| subject
    obs2 -.->|attach| subject
    obs3 -.->|attach| subject

Basic structure

Minimum Observer

#include <iostream>
#include <vector>
#include <memory>
class Observer {
public:
    virtual void update(int value) = 0;
    virtual ~Observer() = default;
};
class Subject {
public:
    void attach(std::shared_ptr<Observer> obs) {
        observers.push_back(obs);
    }
    
    void notify(int value) {
        for (auto& obs : observers) {
            obs->update(value);
        }
    }
    
private:
    std::vector<std::shared_ptr<Observer>> observers;
};
class ConcreteObserver : public Observer {
public:
    ConcreteObserver(const std::string& name) : name_(name) {}
    
    void update(int value) override {
        std::cout << name_ << " received: " << value << '\n';
    }
    
private:
    std::string name_;
};
int main() {
    Subject subject;
    
    auto obs1 = std::make_shared<ConcreteObserver>("Observer1");
    auto obs2 = std::make_shared<ConcreteObserver>("Observer2");
    
    subject.attach(obs1);
    subject.attach(obs2);
    
    subject.notify(42);
    // Observer1 received: 42
    // Observer2 received: 42
}

This minimal version works, but it makes a strong decision silently: the Subject holds shared_ptrs, so it co-owns every observer. Destroying obs1 in main does nothing while the subject still holds its copy; the observer keeps receiving notifications long after the code that created it considers it gone. This is the classic “lapsed listener” problem, and in UI code it shows up as a closed window that still reacts to model changes, or as memory that grows with every window opened. The subject also has no detach, so there is no way to stop the notifications at all. The next section fixes ownership; unsubscription comes up in the problems section and the complete example.


Prevent memory leaks with weak_ptr

Problem: Circular references

// ❌ Misuse: Circular reference with shared_ptr
class Subject {
    std::vector<std::shared_ptr<Observer>> observers;  // strong reference
};
// Subject owns Observer, Observer owns Subject → circular reference

A true cycle forms when the observer also keeps a shared_ptr back to its subject, which is common because observers often need to query the subject when notified. Then neither reference count can reach zero, and both objects leak. Even without a back-pointer, strong references from the subject extend observer lifetimes, as described above. std::weak_ptr solves both: the subject can reach an observer while it exists, but does not keep it alive.

Solved: weak_ptr

#include <algorithm>
#include <iostream>
#include <vector>
#include <memory>
class Observer {
public:
    virtual void update(int value) = 0;
    virtual ~Observer() = default;
};
class Subject {
public:
    void attach(std::shared_ptr<Observer> obs) {
        observers.push_back(obs);  // Save as weak_ptr
    }
    
    void notify(int value) {
        // Remove expired Observers
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                [](const std::weak_ptr<Observer>& wp) {
                    return wp.expired();
                }),
            observers.end()
        );
        
        // Notify
        for (auto& obs : observers) {
            if (auto ptr = obs.lock()) {
                ptr->update(value);
            }
        }
    }
    
private:
    std::vector<std::weak_ptr<Observer>> observers;
};
class ConcreteObserver : public Observer {
public:
    ConcreteObserver(const std::string& name) : name_(name) {}
    
    ~ConcreteObserver() {
        std::cout << name_ << " destroyed\n";
    }
    
    void update(int value) override {
        std::cout << name_ << " received: " << value << '\n';
    }
    
private:
    std::string name_;
};
int main() {
    Subject subject;
    
    {
        auto obs1 = std::make_shared<ConcreteObserver>("Observer1");
        subject.attach(obs1);
        subject.notify(42);  // Observer1 received: 42
    } // destroy obs1
    
    subject.notify(100);  // Expired Observer will not be notified
}

Output:

Observer1 received: 42
Observer1 destroyed

attach still takes a shared_ptr, but the vector stores weak_ptrs, so the conversion happens on push_back. Two details make this safe. lock() returns a temporary shared_ptr that keeps the observer alive for the duration of the call, so an observer that is released by another thread, or by its own update, is not destroyed halfway through its method. And expired entries are pruned at the start of notify, which keeps the list from growing without bound; pruning only on notify means a subject that rarely notifies can accumulate many dead entries, so some implementations also prune in attach.

The limitation is that observers must be owned by shared_ptr. Objects on the stack, members of other objects, or instances managed by unique_ptr cannot subscribe this way. The usual alternatives are an explicit detach called from the observer’s destructor, or a connection/token object returned by attach that unsubscribes when it is destroyed (RAII for subscriptions), which is how most signal libraries work.


Observer by event type

Various event handling

#include <iostream>
#include <vector>
#include <memory>
#include <string>
class Event {
public:
    virtual ~Event() = default;
};
class ValueChangedEvent : public Event {
public:
    ValueChangedEvent(int v) : value(v) {}
    int value;
};
class ErrorEvent : public Event {
public:
    ErrorEvent(const std::string& m) : message(m) {}
    std::string message;
};
class Observer {
public:
    virtual void onEvent(const Event& event) = 0;
    virtual ~Observer() = default;
};
class Subject {
public:
    void attach(std::shared_ptr<Observer> obs) {
        observers.push_back(obs);
    }
    
    void notifyEvent(const Event& event) {
        for (auto& obs : observers) {
            if (auto ptr = obs.lock()) {
                ptr->onEvent(event);
            }
        }
    }
    
private:
    std::vector<std::weak_ptr<Observer>> observers;
};
class ConcreteObserver : public Observer {
public:
    void onEvent(const Event& event) override {
        if (auto* ve = dynamic_cast<const ValueChangedEvent*>(&event)) {
            std::cout << "Value changed: " << ve->value << '\n';
        } else if (auto* ee = dynamic_cast<const ErrorEvent*>(&event)) {
            std::cout << "Error: " << ee->message << '\n';
        }
    }
};
int main() {
    Subject subject;
    auto obs = std::make_shared<ConcreteObserver>();
    subject.attach(obs);
    
    subject.notifyEvent(ValueChangedEvent(42));
    subject.notifyEvent(ErrorEvent("Something went wrong"));
}

One observer interface for all events keeps the subject simple, but the dynamic_cast chain in every observer is the price: each new event type means touching every observer that cares, the compiler cannot tell you which events an observer ignores, and every notification pays for RTTI checks. Alternatives that scale better are one observer interface per event type (onValueChanged, onError as separate virtual functions with empty defaults), std::variant<ValueChangedEvent, ErrorEvent> with std::visit, which makes the set of events closed and checked at compile time, or a dispatcher keyed by std::type_index that calls only the handlers registered for that type. Which one fits depends on whether the set of events is fixed (variant) or extended by plugins (type-indexed dispatch).


Signal/Slot Pattern

Qt-style signals/slots

#include <iostream>
#include <vector>
#include <functional>
template<typename... Args>
class Signal {
public:
    using Slot = std::function<void(Args...)>;
    
    void connect(Slot slot) {
        slots.push_back(slot);
    }
    
    void emit(Args... args) {
        for (auto& slot : slots) {
            slot(args...);
        }
    }
    
private:
    std::vector<Slot> slots;
};
class Button {
public:
    Signal<> clicked;
    
    void click() {
        std::cout << "Button clicked\n";
        clicked.emit();
    }
};
int main() {
    Button button;
    
    button.clicked.connect([]() {
        std::cout << "Handler 1: Button was clicked\n";
    });
    
    button.clicked.connect([]() {
        std::cout << "Handler 2: Logging click event\n";
    });
    
    button.click();
    // Button clicked
    // Handler 1: Button was clicked
    // Handler 2: Logging click event
}

Signals replace the observer base class with std::function, so any callable can subscribe: a lambda, a free function, or a member function bound to an object. That removes inheritance and lets one object handle several signals with different signatures. It also removes the lifetime safety net that weak_ptr gave us. A lambda that captures this or a reference keeps a raw pointer, and if the object dies while the connection remains, the next emit calls into freed memory. This minimal Signal has no disconnect, so there is no way to prevent that. Production signal libraries solve it with connection objects: Boost.Signals2 returns a connection and offers scoped_connection that disconnects in its destructor, and can track a shared_ptr so a slot is skipped once its object expires; Qt disconnects automatically when a QObject receiver is destroyed. Note also that emit(Args... args) copies each argument for the call; for large arguments, const Args&... avoids that.


Frequently occurring problems and solutions

Problem 1: Circular references

Symptom: Memory leak. Cause: Subject and Observer refer to each other as shared_ptr.

// ❌ Misuse: Circular reference with shared_ptr
class Subject {
    std::vector<std::shared_ptr<Observer>> observers;
};
// ✅ Correct usage: weak_ptr
class Subject {
    std::vector<std::weak_ptr<Observer>> observers;
};

Issue 2: Observer removal during notification

Symptom: Iterator invalidation, crash. Cause: Observer calls detach() during notify().

// ❌ Incorrect use: Removed during notification
void notify() {
    for (auto& obs : observers) {
        obs->update();  // call detach() in update() → invalidate iterator
    }
}
// ✅ Correct use: Notify with copy
void notify() {
    auto copy = observers;  // copy
    for (auto& obs : copy) {
        if (auto ptr = obs.lock()) {
            ptr->update();
        }
    }
}

Removing an element from a std::vector while a range-for loop iterates over it invalidates the loop’s iterators, and the result is undefined behavior: skipped observers, a double call, or a crash inside std::vector. Adding an observer during notification is just as dangerous, since push_back may reallocate. Iterating over a copy makes both safe at the cost of copying the list on every notification, which is cheap for a handful of observers. It changes semantics slightly: an observer detached during the loop may still receive the current notification, and one attached during the loop receives it only next time. Document which behavior callers can expect. Alternatives for hot paths are deferring removals (mark as removed, compact after the loop) or iterating by index with care. Symptom: Infinite loop. Cause: Calling Subject’s setValue() in Observer’s update() → notify() again.

// ❌ Misuse: Reentrancy
void Observer::update(int value) {
    subject->setValue(value + 1);  // infinite loop
}
// ✅ Correct use: Prevent re-entrancy
class Subject {
    void notify() {
        if (notifying) return;  // Prevent re-entry
        notifying = true;
        // ... notify observers ...
        notifying = false;
    }
private:
    bool notifying = false;
};

A boolean guard stops the recursion, but it does so by silently dropping the nested notification: the value changes to value + 1 and no observer hears about it, so observers now hold stale data. That is sometimes acceptable and often a subtle bug. Two alternatives are more honest. One is to queue nested changes and deliver them after the current round finishes (a flag plus a pending queue), which preserves all notifications in order. The other is to make the rule explicit and assert on re-entry in debug builds, so an observer that modifies the subject during notification fails loudly. Also make the guard exception-safe: if an observer throws, notifying stays true forever and the subject never notifies again, so reset it with a small RAII helper rather than by assignment at the end.

In my experience the re-entrancy cases that are hard to debug are the indirect ones: observer A updates object B, whose own observer updates the original subject. Nothing in any single class looks recursive, and the stack trace is the only place the cycle is visible.


Production patterns

Pattern 1: Priority Observer

#include <map>
#include <memory>
class Subject {
public:
    void attach(std::shared_ptr<Observer> obs, int priority = 0) {
        observers[priority].push_back(obs);
    }
    
    void notify(int value) {
        // Notify in order of highest priority first
        for (auto it = observers.rbegin(); it != observers.rend(); ++it) {
            for (auto& obs : it->second) {
                if (auto ptr = obs.lock()) {
                    ptr->update(value);
                }
            }
        }
    }
    
private:
    std::map<int, std::vector<std::weak_ptr<Observer>>> observers;
};

std::map keeps priorities sorted, so iterating from rbegin() visits the highest priority first, and observers with equal priority are notified in attach order. If you find yourself needing priorities, it is worth asking why: usually one observer depends on another having already reacted (a cache must refresh before the view reads it). That dependency is hidden in two numbers, and it tends to break when someone adds a third observer. Making the dependency explicit, for example by having the cache notify the view itself, is often more robust than tuning priorities.

Pattern 2: Asynchronous notification

#include <thread>
#include <future>
class Subject {
public:
    void notifyAsync(int value) {
        auto copy = observers;
        std::thread([copy, value]() {
            for (auto& obs : copy) {
                if (auto ptr = obs.lock()) {
                    ptr->update(value);
                }
            }
        }).detach();
    }
    
private:
    std::vector<std::weak_ptr<Observer>> observers;
};

This sketch shows the idea (copy the list, deliver on another thread), and it is the pattern with the most hidden costs. A detached thread per notification means unbounded thread creation under load, no ordering guarantee between two notifications (the second thread may run first), and no way to wait for delivery or to shut down cleanly: at program exit, a detached thread may still be calling into observers whose modules are being torn down. Observers now run on an arbitrary thread, so any state they touch needs synchronization, and UI observers typically must not run off the main thread at all. The copy of weak_ptrs is what keeps it memory-safe, since each observer is locked at delivery time. In production, the same idea is usually built on a single dispatch thread or a thread pool with a queue, which preserves order and supports a clean shutdown, or on the UI framework’s own “post to main thread” mechanism.


Complete Example: Stock Market Monitor

#include <iostream>
#include <map>
#include <vector>
#include <memory>
#include <string>
#include <algorithm>
class StockObserver {
public:
    virtual void onPriceChanged(const std::string& symbol, double price) = 0;
    virtual ~StockObserver() = default;
};
class StockMarket {
public:
    void attach(std::shared_ptr<StockObserver> obs) {
        observers.push_back(obs);
    }
    
    void detach(std::shared_ptr<StockObserver> obs) {
        observers.erase(
            std::remove_if(observers.begin(), observers.end(),
                [&obs](const std::weak_ptr<StockObserver>& wp) {
                    auto sp = wp.lock();
                    return !sp || sp == obs;
                }),
            observers.end()
        );
    }
    
    void setPrice(const std::string& symbol, double price) {
        prices[symbol] = price;
        notifyPriceChanged(symbol, price);
    }
    
private:
    std::map<std::string, double> prices;
    std::vector<std::weak_ptr<StockObserver>> observers;
    
    void notifyPriceChanged(const std::string& symbol, double price) {
        auto copy = observers;
        for (auto& obs : copy) {
            if (auto ptr = obs.lock()) {
                ptr->onPriceChanged(symbol, price);
            }
        }
    }
};
class PriceDisplay : public StockObserver {
public:
    PriceDisplay(const std::string& name) : name_(name) {}
    
    void onPriceChanged(const std::string& symbol, double price) override {
        std::cout << "[" << name_ << "] " << symbol << ": $" << price << '\n';
    }
    
private:
    std::string name_;
};
class PriceAlert : public StockObserver {
public:
    PriceAlert(const std::string& symbol, double threshold)
        : symbol_(symbol), threshold_(threshold) {}
    
    void onPriceChanged(const std::string& symbol, double price) override {
        if (symbol == symbol_ && price > threshold_) {
            std::cout << "ALERT: " << symbol << " exceeded $" << threshold_ << '\n';
        }
    }
    
private:
    std::string symbol_;
    double threshold_;
};
int main() {
    StockMarket market;
    
    auto display = std::make_shared<PriceDisplay>("MainDisplay");
    auto alert = std::make_shared<PriceAlert>("AAPL", 150.0);
    
    market.attach(display);
    market.attach(alert);
    
    market.setPrice("AAPL", 145.0);  // [MainDisplay] AAPL: $145
    market.setPrice("AAPL", 155.0);  // [MainDisplay] AAPL: $155
                                     // ALERT: AAPL exceeded $150
}

The example combines the earlier fixes: weak_ptr storage so observers are not kept alive, a copy of the list during notification so an observer can detach itself safely, and a detach that also prunes expired entries in the same pass. Notification order is attach order, which is why the display line prints before the alert. PriceAlert fires on every update above the threshold, so a price that stays at 155 alerts repeatedly; a real alert would remember that it already fired and reset when the price drops below the threshold again (edge-triggered rather than level-triggered). In an actual market-data feed, updates arrive far faster than a display can usefully redraw, and the usual design adds coalescing, keeping only the latest price per symbol and notifying on a timer, rather than calling observers for every tick.


Notification order and cost

Notification order in these implementations is attach order, which is an implementation detail rather than a contract; code that relies on it breaks when subscription order changes. The cost of a notification is one call per observer, so it grows linearly with subscribers, and in event-heavy systems the observer list quietly becomes a hot path. Further reading: Design Patterns (Gamma et al.) for the original formulation, and the Boost.Signals2 documentation for a production-quality C++ design. A related pattern worth reading next is the Strategy Pattern.