Event handlers form the nervous system of modern C++ applications, managing communication between components while balancing efficiency and safety. Let's explore four progressively sophisticated event handler implementations that demonstrate this evolution in design philosophy.
Part 1: 4 Event Handlers
1. The Minimalist: CSimpleEvent
This class implements a
basic event-driven
system using the Observer pattern. Below is a detailed
breakdown for a junior software engineer, covering design principles, patterns,
and code functionality.
template <typename... Args>
class CSimpleEvent {
public:
void subscribe(Callback callback) {
callbacks_.push_back(std::move(callback));
}
void trigger(Args... args) {
for (const auto& callback : callbacks_) {
callback(args...);
}
}
};
Core Functionality
·
Template Class (template <typename... Args>
):
o Supports events with
any number/type of arguments (e.g., CSimpleEvent<>
for no
args, CSimpleEvent<int>
for an int
argument).
o Example: CSimpleEvent<int,
std::string>
accepts callbacks with signature void(int,
std::string)
.
·
Callback Type:
using Callback = std::function<void(Args...)>;
o Stores callbacks
(functions/lambdas) that match the event signature.
·
Subscribe:
void subscribe(Callback callback) {
callbacks_.push_back(std::move(callback));
}
o Adds a callback to the
list. Uses std::move
for efficient ownership transfer.
·
Trigger:
void trigger(Args... args) {
for (const auto& callback : callbacks_) {
callback(args...);
}
}
o Invokes all subscribed
callbacks with the provided arguments.
·
Storage:
std::vector<Callback> callbacks_;
o Holds callbacks in
a std::vector
.
Key Design Principles
& Patterns
·
Observer Pattern:
o Subject (Event): CSimpleEvent
maintains a list
of observers (callbacks).
o Observers (Callbacks): Functions/lambdas
that react to the event.
o When trigger()
is called, all
observers are notified.
·
Open/Closed Principle:
o Open for extension: New callbacks can be
added via subscribe()
.
o Closed for modification: The event class
doesn’t need changes to support new callbacks.
·
Encapsulation:
o The callback list (callbacks_
) is private
. External code
interacts only via subscribe()
/trigger()
.
·
Resource Management:
o std::move
in subscribe()
avoids copying
callbacks (efficient for large captures).
Summary
This barebones
implementation excels in simplicity:
·
Pros: Zero overhead, intuitive interface
·
Cons: No unsubscription, not thread-safe
·
Memory: 24 bytes (typical vector overhead)
- Data
Pointer (8 bytes): Address of the heap-allocated callback array
- Size Counter (8 bytes): Tracks callbacks_.size() (number of
active callbacks)
- Capacity Tracker (8 bytes):
Records callbacks_.capacity() (allocated memory)
·
Performance: O(n) triggers, no allocation latency
Use Case: Perfect for static
single-threaded setups where callbacks persist for the application's lifetime,
like startup configuration events.
2. The Optimized Global: CGlobalEvent
class CGlobalEvent {
void trigger(Args... args) {
std::atomic_thread_fence(std::memory_order_acquire);
for (const auto& callback : callbacks_) {
if (callback) callback(args...);
}
}
};
This version adds
thread safety for a specific scenario:
·
Innovation: Uses memory fences instead of locks
·
Assumption: Subscriptions happen before concurrent
access
·
Trigger Cost: Same as simple version + fence overhead (~5-20 cycles)
·
Safety: Read-only triggers safe across threads
Use Case: Global application
events (e.g., shutdown notifications) where subscriptions occur during
initialization.
class CDevStatusHandler
{
CGlobalEvent<std::string> m_onStatusToGuiUpdate;
CDevStatusHandler();
};
CDevStatusHandler::CDevStatusHandler()
{
m_onStatusToGuiUpdate.subscribe([this](std::string info) {
std::cout << "onStatusToGuiUpdate
Event triggered: " << info;
});
}
void CEventDev::SetState(int nState)
{
if (m_nState == nState) {
return;
}
m_nState = nState;
if (nState == DEV_STATE_OK) {
snprintf(acLog, sizeof(acLog), "%s_%d Ok", m_acName, m_nId);
} else {
snprintf(acLog, sizeof(acLog), "%s_%d Down", m_acName, m_nId);
}
g_pLog->LogInfo(LOG_SYS, acLog);
if (g_pDevStatus) {
g_pDevStatus->m_onStatusToGuiUpdate.trigger(acLog);
}
}
3. The Lifetime Manager: CEvent
class CEvent {
Subscription subscribe(Callback callback) {
callbacks_.emplace_back(/*...*/);
return Subscription(weakSelf, id);
}
~Subscription() {
if (auto event = event_.lock())
event->unsubscribe(id_);
}
};
This RAII-based
solution solves critical lifetime issues:
·
Key Innovation: Subscription
objects auto-unsubscribe
·
Lazy Cleanup: Marks entries inactive instead of immediate removal
·
Memory Overhead: ~40 bytes per callback (ID, active flag, weak_ptr)
·
Safety: Prevents dangling pointers via weak_ptr
The Lazy Cleanup Trade-off:
If triggers are infrequent, unsubscribed callbacks accumulate as "dead
entries," causing:
·
Memory bloat (up to 2-3× actual needed space)
·
Slower triggers (iterating dead entries)
·
Cleanup spikes when finally triggered
Mitigation Strategy:
// Periodic manual cleanup
void cleanup() {
callbacks_.erase(std::remove_if(/*...*/), callbacks_.end());
}
Use Case: Single-threaded
applications with dynamic callback lifetimes like UI systems.
4. The Thread-Safe Powerhouse: CEventSafe
class CEventSafe {
Subscription subscribe(Callback callback) {
std::lock_guard<std::mutex> lock(mutex_);
// ...
}
void trigger(Args... args) {
std::vector<std::shared_ptr<CallbackEntry>> activeEntries;
{
std::lock_guard<std::mutex> lock(mutex_);
// Copy active entries
}
// Execute outside lock
}
};
This industrial-grade
solution adds full thread safety:
·
Concurrency Strategy:
o Mutex-protected
subscription/unsubscription
o Callback execution
outside locks
o Shared ownership via
shared_ptr
·
Memory Overhead: ~64 bytes per callback (mutex, control blocks)
·
Throughput Impact: 10-25% slower than non-threaded versions
Critical Optimization:
The dual-phase trigger prevents lock contention during callback execution:
1.
Locked Phase: Quickly copy active entries
2.
Unlocked Phase: Execute callbacks safely
You may find the full source codes in GitHub.
Part 2: Design Evolution Insights
1.
Safety Progression:
o Level 0: No lifetime
management (CSimpleEvent
)
o Level 1: Static thread
safety (CGlobalEvent
)
o Level 2: Dynamic
ownership (CEvent
)
o Level 3: Full
concurrency (CEventSafe
)
2.
The Cleanup Paradox:
Eager removal risks iterator invalidation; lazy cleanup risks memory bloat. The
optimal solution depends on your event's subscription/trigger ratio.
When to Use Which
1.
Embedded Systems: CSimpleEvent
(predictable callbacks)
2.
Service Initialization: CGlobalEvent
(one-time setup)
3.
Desktop Applications: CEvent
(dynamic UI
events)
4.
Server Backends: CEventSafe
(high-concurrency APIs)
The Future: Beyond These Implementations
Modern enhancements
could include:
·
Lock-free trigger operations
·
Callback prioritization
·
One-shot subscriptions
·
Subscription pools (reuse IDs)
Conclusion
From CSimpleEvent
's 24-byte overhead
to CEventSafe
's robust threading model, we've seen how event handlers evolve
to address real-world constraints. The simplest solution isn't
"worse"—it's contextually optimal. Understanding these
implementations' trade-offs empowers you to select the right event handling
strategy for your project's specific needs, balancing performance, safety, and
resource consumption in an increasingly concurrent programming landscape.
"Good
design is about making wise trade-offs, not chasing perfect solutions." -
Chandler Carruth
This article was enhanced with DeepSeek.
No comments:
Post a Comment