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.
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