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++98A 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:
| Approach | Minimum standard | Lifetime management | Overhead |
|---|---|---|---|
| Virtual interface | C++98 | Manual (raw pointer) | Lowest |
std::function signal + token | C++11 | RAII Connection handle | Medium |
weak_ptr-tracked signal | C++11 | Automatic on expiry | Medium |
| Type-erased event bus | C++11 | None (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.
// 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.
#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:
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 printedSafe 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.
#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 crashType-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.
#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:
#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 wallBest 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:
// 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:
// 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.
// 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.
#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.
#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++20Prefer 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.
#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 prunedPrefer 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.
// 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).
// 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 212Reactive 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
Connectionhandles - Type Erasure — technique underpinning
std::functionand the event bus - Policy-Based Design — compile-time alternative to runtime observer dispatch