Skip to content
C++
Idiom
since C++98
Intermediate

Observer Pattern

Signal/slot, event buses, and safe observer lifetime in C++ using std::function, RAII tokens, weak_ptr tracking, and C++20 concepts.

Observer Patternsince C++98

A behavioral pattern where a subject maintains a list of observer callbacks and notifies all of them when its state changes, decoupling publishers from subscribers without requiring them to share a common base class.

Overview

Three implementation strategies dominate modern C++ codebases:

ApproachMinimum standardLifetime managementOverhead
Virtual interfaceC++98Manual (raw pointer)Lowest
std::function signal + tokenC++11RAII Connection handleMedium
weak_ptr-tracked signalC++11Automatic on expiryMedium
Type-erased event busC++11None (fire-and-forget)Higher

Virtual interfaces suit polymorphic observers with tightly controlled lifetimes. std::function signals fit lambdas, member functions, and any callable. weak_ptr tracking is essential for GUI widgets and scene-graph nodes that can be destroyed before the subject. Event buses decouple subsystems that should not share headers.

Examples

Classic Virtual Observer (C++98)

The GoF original — pure interface, raw non-owning pointers. Zero allocation overhead, but unregistration before destruction is the caller's responsibility.

cpp
// C++98
class IProgressObserver {
public:
    virtual ~IProgressObserver() = default;
    virtual void on_progress(int percent) = 0;
    virtual void on_complete() = 0;
};

class Downloader {
    std::vector<IProgressObserver*> observers_;  // non-owning

public:
    void add_observer(IProgressObserver* obs) {
        observers_.push_back(obs);
    }
    void remove_observer(IProgressObserver* obs) {
        // C++20: std::erase(observers_, obs);
        observers_.erase(std::remove(observers_.begin(), observers_.end(), obs),
                         observers_.end());
    }

protected:
    void notify_progress(int pct) {
        for (auto* obs : observers_) obs->on_progress(pct);
    }
    void notify_complete() {
        for (auto* obs : observers_) obs->on_complete();
    }
};

Signal with RAII Disconnect (C++11)

A reusable Signal template that issues scoped Connection objects. When a Connection is destroyed or goes out of scope, the slot is automatically removed. The move constructor explicitly zeroes active_ to avoid a double-disconnect, which a defaulted move would not guarantee for the bool flag.

cpp
#include <functional>  // C++11
#include <vector>

template<typename... Args>
class Signal {
public:
    using Slot = std::function<void(Args...)>;  // C++11

    class Connection {
        Signal* sig_;
        int id_;
        bool active_ = false;
    public:
        Connection() = default;
        Connection(Signal* s, int id) : sig_(s), id_(id), active_(true) {}
        ~Connection() { disconnect(); }

        void disconnect() {
            if (active_ && sig_) { sig_->erase(id_); active_ = false; }
        }

        Connection(const Connection&)            = delete;
        Connection& operator=(const Connection&) = delete;

        Connection(Connection&& o) noexcept  // C++11 noexcept
            : sig_(o.sig_), id_(o.id_), active_(o.active_) { o.active_ = false; }

        Connection& operator=(Connection&& o) noexcept {
            disconnect();
            sig_ = o.sig_; id_ = o.id_; active_ = o.active_;
            o.active_ = false;
            return *this;
        }
    };

    [[nodiscard]] Connection connect(Slot fn) {  // [[nodiscard]]: C++17
        int id = next_id_++;
        slots_.push_back({id, std::move(fn)});
        return {this, id};
    }

    void emit(Args... args) const {
        // Snapshot prevents UB if a slot disconnects during emission
        auto snapshot = slots_;
        for (const auto& [id, fn] : snapshot)  // structured bindings: C++17
            fn(args...);
    }

private:
    friend class Connection;

    struct Entry { int id; Slot fn; };
    std::vector<Entry> slots_;
    int next_id_ = 0;

    void erase(int id) {
        // C++20: std::erase_if(slots_, [id](const Entry& e){ return e.id == id; });
        slots_.erase(
            std::remove_if(slots_.begin(), slots_.end(),
                [id](const Entry& e) { return e.id == id; }),
            slots_.end());
    }
};

Usage:

cpp
Signal<int> on_progress;
Signal<std::string_view> on_error;  // std::string_view: C++17

{
    auto conn = on_progress.connect([](int pct) {
        std::println("{}%", pct);  // std::println: C++23
    });
    on_progress.emit(50);   // "50%"
}   // conn destroyed → slot removed

on_progress.emit(75);  // nothing printed

Safe Observer with weak_ptr (C++11)

For objects managed by shared_ptr whose lifetime is independent of the signal's. The signal locks each weak_ptr before calling its slot — this keeps the owner alive for the duration of the call, preventing any race between the liveness check and the invocation.

cpp
#include <memory>  // C++11

template<typename... Args>
class WeakSignal {
public:
    using Slot = std::function<void(Args...)>;

    template<typename Owner>
    void connect(std::weak_ptr<Owner> owner, Slot fn) {
        slots_.emplace_back(std::move(owner), std::move(fn));
    }

    void emit(Args&&... args) {
        slots_.erase(
            std::remove_if(slots_.begin(), slots_.end(),
                [&](Entry& e) {
                    auto alive = e.owner.lock();  // shared_ptr keeps owner alive
                    if (!alive) return true;       // expired → remove
                    e.fn(args...);                 // safe: owner pinned by alive
                    return false;
                }),
            slots_.end());
    }

private:
    struct Entry { std::weak_ptr<void> owner; Slot fn; };
    std::vector<Entry> slots_;
};

class Panel : public std::enable_shared_from_this<Panel> {  // C++11
public:
    void subscribe(WeakSignal<int>& sig) {
        sig.connect(
            weak_from_this(),            // C++17; pre-C++17: shared_from_this()
            [this](int val) { render(val); });
    }
private:
    void render(int val) {}
};

WeakSignal<int> on_resize;
{
    auto panel = std::make_shared<Panel>();
    panel->subscribe(on_resize);
}                         // Panel destroyed
on_resize.emit(1080);     // silently skipped — no dangling pointer, no crash

Type-Erased Event Bus (C++11)

For fully decoupled subsystems. Events are plain aggregates; no inheritance needed. The bus uses std::type_index to route by event type.

cpp
#include <typeindex>      // C++11
#include <unordered_map>

class EventBus {
public:
    template<typename Event>
    void subscribe(std::function<void(const Event&)> fn) {
        handlers_[typeid(Event)].push_back(
            [fn = std::move(fn)](const void* raw) {
                fn(*static_cast<const Event*>(raw));
            });
    }

    template<typename Event>
    void publish(const Event& ev) {
        if (auto it = handlers_.find(typeid(Event));  // if-init: C++17
                it != handlers_.end())
            for (const auto& h : it->second) h(&ev);
    }

private:
    using Handler = std::function<void(const void*)>;
    std::unordered_map<std::type_index, std::vector<Handler>> handlers_;
};

struct ResolutionChanged { int width, height; };
struct ThemeChanged      { bool dark_mode; };

EventBus bus;
bus.subscribe<ResolutionChanged>([](const ResolutionChanged& e) {
    // reconfigure renderer — no include of ThemeChanged needed here
});
bus.publish(ResolutionChanged{1920, 1080});

C++20 Concepts-Constrained Signal

Constrain callables at the point of connection for precise error messages:

cpp
#include <concepts>  // C++20

template<typename F, typename... Args>
concept SlotFor =
    std::invocable<F, Args...> &&
    std::is_void_v<std::invoke_result_t<F, Args...>>;

template<typename... Args>
class Signal {
public:
    template<SlotFor<Args...> F>
    [[nodiscard]] Connection connect(F&& fn) {
        return connect_impl(Slot{std::forward<F>(fn)});
    }
    // ...
};

Signal<int, int> on_resize;
on_resize.connect([](int w, int h) { /* ok */ });
// on_resize.connect([](int w) { });
// error: constraints not satisfied for 'SlotFor' — clear, not a template wall

Best Practices

Always [[nodiscard]] connect() so callers cannot accidentally discard the Connection, which would immediately disconnect the slot with no warning.

Snapshot slots_ before iterating in emit() — a slot can legally call connect or disconnect on the same signal during emission. Without a snapshot, you modify the container during range-for iteration, which is undefined behaviour.

Use weak_ptr tracking whenever the observer owns its own lifetime (widgets, actors, scene nodes). Shared-pointer captures in lambdas form ownership cycles; raw-pointer captures dangle.

Thread-safe signals require std::shared_mutex (C++17): emit holds a shared lock (multiple readers allowed), connect/disconnect hold an exclusive lock. For lowest lock contention, copy slots_ under a brief exclusive lock and release before iterating.

Prefer aggregate event types over deep hierarchies in event buses. Flat structs are trivially copyable and require no virtual dispatch. Inheritance hierarchies in event buses force every subscriber to cast and complicate slicing rules.

Common Pitfalls

Forgetting to unregister with virtual observers is the most common source of use-after-free. If the subject outlives any registered observer, the next emit calls through a dangling pointer. Always call remove_observer in the observer's destructor, or switch to RAII tokens or weak_ptr tracking.

Dropping the Connection token is the [[nodiscard]]-invisible footgun with RAII signals:

cpp
// Bug: slot disconnected immediately, never fires
signal.connect([](int v) { handle(v); });

// Fix: extend Connection lifetime to match intended subscription duration
auto conn = signal.connect([](int v) { handle(v); });

Re-entrant emission without snapshot — a slot that calls signal.disconnect(own_token) while emit is iterating over the same std::vector causes iterator invalidation. The snapshot idiom in emit prevents this entirely.

Capturing this by raw pointer in a long-lived signal creates a dangling reference the moment the object is destroyed. This is especially common with member-function wrappers:

cpp
// Dangerous if 'this' is destroyed before the signal
auto conn = signal.connect([this](int v) { on_value(v); });
// Safe alternative: use weak_ptr tracking (WeakSignal above)

std::function allocation on large capturesstd::function uses small-buffer optimisation (typically 16–24 bytes); captures exceeding this threshold allocate on the heap. For hot paths (audio, physics, rendering), profile and consider alternatives: std::move_only_function (C++23), inplace function libraries, or direct virtual dispatch.

See Also

  • RAII — the idiom behind scoped Connection handles
  • Type Erasure — technique underpinning std::function and the event bus
  • Policy-Based Design — compile-time alternative to runtime observer dispatch