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 captures — std::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.

RAII subscription with automatic unsubscribe

Scoping a Connection object to a class member is the cleanest way to tie subscription lifetime to object lifetime. When the owning object is destroyed its Connection members are destroyed too, automatically removing every slot with no explicit remove_observer call.

cpp
// C++11
class AudioMixer {
    Signal<float> on_volume_changed_;

public:
    [[nodiscard]] Signal<float>::Connection subscribe_volume(Signal<float>::Slot fn) {
        return on_volume_changed_.connect(std::move(fn));
    }
    void set_volume(float v) { on_volume_changed_.emit(v); }
};

class VolumeWidget {
    Signal<float>::Connection conn_;  // lifetime tied to VolumeWidget

public:
    explicit VolumeWidget(AudioMixer& mixer) {
        conn_ = mixer.subscribe_volume([this](float v) { update_ui(v); });
    }
    // ~VolumeWidget() — conn_ destructor calls disconnect() automatically
private:
    void update_ui(float v) {}
};

Keep Connection objects non-copyable and move-only so ownership of the subscription token is unambiguous and the RAII guarantee cannot be accidentally broken by a copy that outlives the intended scope.

Thread-safe observer with shared_mutex

Multiple threads may emit notifications concurrently, but connect/disconnect must be exclusive. std::shared_mutex (C++17) allows any number of simultaneous emit readers while serialising structural mutations.

cpp
#include <shared_mutex>  // C++17
#include <mutex>

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

    struct Connection { /* same RAII token as before */ };

    [[nodiscard]] Connection connect(Slot fn) {
        std::unique_lock lock(mtx_);      // exclusive: write
        int id = next_id_++;
        slots_.push_back({id, std::move(fn)});
        return {this, id};
    }

    void emit(Args... args) const {
        std::vector<std::pair<int, Slot>> snapshot;
        {
            std::shared_lock lock(mtx_);  // C++17: shared read
            snapshot = slots_;
        }                                 // lock released before invoking slots
        for (const auto& [id, fn] : snapshot)
            fn(args...);
    }

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

    void erase(int id) {
        std::unique_lock lock(mtx_);
        slots_.erase(
            std::remove_if(slots_.begin(), slots_.end(),
                [id](const Entry& e) { return e.id == id; }),
            slots_.end());
    }
};

Release the lock before invoking callbacks. Holding the mutex during slot invocation causes deadlock if any slot tries to connect or disconnect on the same signal. The snapshot copy ensures the caller sees a consistent view without holding the lock across the entire emit loop.

Observer with std::function

std::function eliminates the virtual-base requirement and accepts any callable — lambda, free function, member-function wrapper, or function object. This makes it trivial to subscribe one-liners and throwaway observers without defining a concrete class.

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

class Sensor {
    std::vector<std::function<void(double)>> listeners_;

public:
    void on_reading(std::function<void(double)> fn) {
        listeners_.push_back(std::move(fn));
    }

    void simulate(double value) {
        for (const auto& fn : listeners_) fn(value);
    }
};

void log_reading(double v) { /* free function */ }

struct Alerter {
    double threshold;
    void check(double v) const {
        if (v > threshold) { /* fire alert */ }
    }
};

Sensor temp;
Alerter alert{98.6};

temp.on_reading(log_reading);                        // free function
temp.on_reading([](double v) { /* inline */ });      // lambda  // C++11
temp.on_reading([&alert](double v) { alert.check(v); }); // capture
// C++20 alternative using std::bind_front:
temp.on_reading(std::bind_front(&Alerter::check, &alert)); // C++20

Prefer std::move_only_function (C++23) on hot paths — it avoids the allocation cost of std::function's heap fallback for large captures and supports move-only callables such as those holding unique_ptr.

Weak reference to prevent dangling observers

Raw-pointer or shared_ptr-capture observers are the leading source of use-after-free in event-driven code; weak_ptr breaks the ownership cycle and makes expiry observable. Lock the weak_ptr inside emit to obtain a temporary shared_ptr that keeps the observer alive for exactly the duration of the call.

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

class Button {
    std::vector<std::weak_ptr<std::function<void()>>> handlers_;

public:
    // caller stores the shared_ptr; weak copy is stored here
    void on_click(std::weak_ptr<std::function<void()>> wfn) {
        handlers_.push_back(std::move(wfn));
    }

    void click() {
        handlers_.erase(
            std::remove_if(handlers_.begin(), handlers_.end(),
                [](const std::weak_ptr<std::function<void()>>& wfn) {
                    auto fn = wfn.lock();
                    if (!fn) return true;  // expired — prune
                    (*fn)();
                    return false;
                }),
            handlers_.end());
    }
};

auto handler = std::make_shared<std::function<void()>>(
    []{ /* do work */ });

Button btn;
btn.on_click(handler);  // weak reference stored

handler.reset();        // owner releases — next click() skips silently
btn.click();            // no crash; dead entry pruned

Prefer the WeakSignal pattern from the examples section for shared_ptr-managed objects (enable_shared_from_this + weak_from_this) rather than wrapping std::function in a shared_ptr, which adds an extra allocation and obscures ownership intent.

Event-driven observer vs polling

Polling couples the observer to the subject's internal clock and wastes CPU when nothing changes; event-driven observer inverts the control and delivers notifications only on actual state transitions.

cpp
// C++11

// ---- Polling (avoid) ----
class TemperatureMonitor {
    double last_temp_ = 0.0;
public:
    void poll_loop(Sensor& sensor) {
        while (true) {
            double t = sensor.read();       // called every iteration
            if (t != last_temp_) {
                on_change(t);
                last_temp_ = t;
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(100));
        }
    }
    void on_change(double t) {}
};

// ---- Event-driven (prefer) ----
class TemperatureMonitor {
public:
    Signal<double>::Connection conn_;

    void subscribe(Signal<double>& on_temp_changed) {
        conn_ = on_temp_changed.connect([this](double t) { on_change(t); });
    }
    // Called only when temperature actually changes — zero busy-wait
    void on_change(double t) {}
};

Polling is sometimes unavoidable — hardware registers, third-party C APIs, and legacy codebases with no notification mechanism. In those cases wrap the polling loop in a dedicated thread and expose a Signal from the wrapper, converting polls to events at the boundary.

Observer and the reactive programming model

The observer pattern is the foundational primitive of reactive programming; libraries like RxCpp and the C++23 std::execution senders/receivers proposal extend it with composable operators (map, filter, merge, throttle).

cpp
// C++20  — lightweight manual reactive chain using Signal
template<typename T, typename F>
auto map_signal(Signal<T>& src, F transform)
    -> std::pair<Signal<std::invoke_result_t<F, T>>, Signal<T>::Connection>
{
    using U = std::invoke_result_t<F, T>;
    auto out = std::make_shared<Signal<U>>();
    auto conn = src.connect([out, transform](T v) {
        out->emit(transform(v));   // transform then re-emit
    });
    return {*out, std::move(conn)};
}

Signal<int> raw_temp;      // source: degrees Celsius
auto [fahrenheit_sig, _conn] = map_signal(raw_temp,
    [](int c) { return c * 9 / 5 + 32; });  // C++20 lambda

fahrenheit_sig.connect([](int f) {
    // receives converted value automatically
});

raw_temp.emit(100);  // downstream sees 212

Reactive chains introduce implicit ownership dependencies — each intermediate Signal and its Connection token must outlive the downstream subscriber. Keep the full chain alive in a single owning struct or use shared_ptr-managed signals to avoid partial destruction ordering bugs.

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