Event-driven architecture is a fundamental paradigm in software design where components communicate through events rather than direct method calls. This article breaks down a professional implementation of an event system in C++ using two key files:
1.
event_system.h
: Core event management classes.
2.
domain.cpp
: Domain-specific events and usage examples.
The
source codes can be found in github. Here I just want to highlight a few points
that are good to me:
·
Modular Design: Components communicate without tight coupling.
·
Modern C++ Features: Templates, RAII, and lambda expressions.
·
Professional Patterns: Type erasure, publisher-subscriber.
·
Code Flow Visualization:
[EventsManager]
|--- stores -->
[vector<unique_ptr<ISubscription>>]
|
|--- contains -->
[SubscriptionWrapper<Event<int>>]
|
[SubscriptionWrapper<Event<string>>]
|
[SubscriptionWrapper<Event<CustomType>>]
|
|--- On destruction -->
[~ISubscription()] --> [~SubscriptionWrapper()] -->
[~Event<T>::Subscription()]
The
rest of this article is generated by deepseek:
Part 1: Core Concepts (event_system.h)
Part 2: Domain Implementation
(domain.cpp)
Part 3: Type Erasure Explained: The Power of ISubscription with a Single Virtual Destructor
This
design elegantly balances flexibility and type safety, demonstrating how
minimal interfaces can enable powerful abstractions!
Part 1: Core Concepts (event_system.h)
1.1 The Event Template Class
Purpose: Create type-safe, reusable events that can carry data.
template <typename... Args>
class Event { /*...*/ };
- Template Parameters: Args... allow
events to carry any data type.
- Key
Features:
- subscribe(): Registers
callbacks and returns a Subscription.
- trigger(): Broadcasts events to
all subscribers.
- Automatic unsubscription via
RAII.
1.2 The Subscription Class
Purpose: Manage callback lifetimes using RAII (Resource Acquisition Is
Initialization.
class Subscription : public ISubscription { /*...*/ };
- RAII Principle:
Automatically
unsubscribes when destroyed.
- Move Semantics:
Ensures safe ownership transfer (no copying allowed).
1.3 The EventsManager Class
Purpose: Centralize subscription management.
class EventsManager { /*...*/ };
- Type Erasure:
Uses ISubscription and SubscriptionWrapper to
store heterogeneous subscriptions.
- Lifetime Control: All subscriptions are destroyed when the manager
is destroyed.
Part 2: Domain Implementation (domain.cpp)
2.1 Defining Event Types
struct TemperatureEvent { double value; };
struct DoorStatusEvent { bool isOpen; int sensorId; };
- Best Practice:
Use simple structs
for event data to ensure immutability.
2.2 Creating Event Instances
Event<TemperatureEvent> temperatureEvent;
Event<DoorStatusEvent> doorStatusEvent;
- Global vs. Local:
These are global
here for simplicity, but in larger projects, consider encapsulating them
within classes.
2.3 Publisher-Subscriber Pattern
Example: Temperature
Sensor (Publisher)
void update(double temp) {
temperatureEvent.trigger(TemperatureEvent{temp});
}
Example: Climate
Controller (Subscriber)
em.subscribe(temperatureEvent, [this](const TemperatureEvent& e) {
adjustSystem(e.value);
});
Key Design Principles
1. Decoupling Components
- Publishers (e.g., TemperatureSensor)
don’t know about subscribers (e.g., ClimateController).
- All communication happens
through events.
2. RAII for Resource Management
- Subscriptions automatically
clean up when they go out of scope:
~Subscription() { if(event_) event_->unsubscribe(id_); }
3. Type Safety
- Templates ensure compile-time
checks:
// Compiler enforces correct
event data types:
em.subscribe(temperatureEvent, [](const DoorStatusEvent& e) { /*...*/ }); // Error!
4. Scalability
- Add new events without modifying existing code:
Event<NetworkPacket> networkEvent; // New event type
PART 3: Type Erasure Explained: The Power of ISubscription with a Single Virtual Destructor
What is Type Erasure?
Type erasure is a
design pattern that allows you to work with heterogeneous types through a
uniform interface, while hiding ("erasing") their specific type
information. It’s like putting different objects into a black box—the outside
code interacts with the box, not the objects inside.
Key Characteristics:
- Abstraction:
Treat different
types as if they were the same.
- Encapsulation: Hide
type-specific details behind a common interface.
- Runtime Polymorphism: Resolve
behavior at runtime (via virtual functions).
The ISubscription Class: Minimalist Type Erasure
In the event system
code, ISubscription is the cornerstone of type erasure. Let’s break
down its design:
Class Definition:
class ISubscription {
public:
virtual ~ISubscription() = default; // Single virtual destructor
};
Why This Works:
- Common
Interface:
All subscription types (e.g., Event<int>::Subscription, Event<string>::Subscription) inherit from ISubscription. This allows them to be stored in a single container:
std::vector<std::unique_ptr<ISubscription>> subscriptions_;
- Virtual
Destructor:
- Ensures that when a unique_ptr<ISubscription> is
destroyed, the correct derived class destructor is called.
- Triggers the destructor of the
actual Event<T>::Subscription, which unsubscribes the
callback.
How Type Erasure is Achieved
Step 1: Type-Specific Wrapper (SubscriptionWrapper)
For each event
type Event<T>, we create a templated wrapper:
template <typename EventType>
struct SubscriptionWrapper : public ISubscription {
typename EventType::Subscription sub; // Concrete subscription
SubscriptionWrapper(typename EventType::Subscription&& s)
: sub(std::move(s)) {}
};
Step 2: Erase the Type
When
storing subscriptions:
void EventsManager::subscribe(EventType& event, Callback&& cb) {
auto sub = event.subscribe(std::forward<Callback>(cb));
subscriptions_.emplace_back(
std::make_unique<SubscriptionWrapper<EventType>>(std::move(sub))
);
}
- The SubscriptionWrapper<EventType> is
created for a specific event type (e.g., Event<int>).
- It is then upcast to ISubscription* and
stored in the subscriptions_ vector.
Step 3: Type Recovery at Destruction
When subscriptions_ is
cleared:
- The unique_ptr<ISubscription> calls
the virtual destructor of ISubscription.
- Since SubscriptionWrapper inherits
from ISubscription, its destructor is invoked.
- This destroys the wrapped EventType::Subscription,
which in turn unsubscribes the callback.
Why Only a Destructor?
The ISubscription interface
needs only a virtual destructor because:
- Minimal Contract:
The only required
operation is proper destruction.
- No Other Operations:
The EventsManager doesn’t
need to interact with subscriptions—it just needs to manage their
lifetimes.
- Zero Overhead:
No virtual function
calls except during destruction.
Analogy: Library Book Returns
Imagine
a library (EventsManager) that lends books (subscriptions) of various genres
(event types). The librarian doesn’t care what genre a book is—they just need
to know:
- How to shelve it (store in a vector<ISubscription*>).
- How to dispose of it (call the virtual destructor).
The
genre-specific return process (unsubscribing) is handled automatically when the
book is destroyed!
Key Takeaways
- Type
Erasure Pattern:
- Use a base class (ISubscription)
to unify heterogeneous types.
- Leverage virtual destructors
for type-specific cleanup.
- Advantages:
- Decoupling:
The EventsManager doesn’t
know about specific event types.
- Scalability:
Add new event
types without modifying the manager.
- Resource Safety:
RAII ensures no
leaked subscriptions.
- When
to Use:
- Managing objects with
heterogeneous types but common lifecycle needs.
- Hiding implementation details
from client code.