System::EventHandler
Understand the native type that deliberately combines a callback signature with event subscription and dispatch storage.
Role and deliberate design difference
System::EventHandler<TEventArgs> is a header-only event collection in Core.Base. Its name is familiar, but its role is intentionally broader than .NET's type. In .NET, EventHandler<TEventArgs> describes one delegate signature and the C# event language feature manages a multicast subscription list. Sharp Runtime combines both jobs: one object owns copied callables, issues removal tokens, and invokes subscribers.
The callback signature is:
using HandlerType =
std::function<void(System::Object* sender,
const TEventArgs& args)>;
The sender is always Object* and may be null. Event arguments are borrowed as a const reference for the duration of each call. Instantiate EventHandler<System::EventArgs> for the traditional non-generic event shape. The newer two-type-parameter .NET delegate with a typed sender is not modeled.
Public operations
| Member | Behavior | Notes |
|---|---|---|
Add(handler) | Subscribes a copied/moved std::function and returns a token | Use the token for precise removal |
operator+=(handler) | Delegates to Add and discards the token | Convenient when the subscription lasts as long as the event owner |
Remove(token) | Removes the matching subscription | Unknown or already-removed tokens are a no-op |
Clear() | Removes all stored handlers | Does not clear the replay hook |
Empty() / Size() | Inspect the current stored list | No synchronization is performed |
Raise(sender, args) | Invokes a snapshot in subscription order | Mutations during dispatch affect the next raise |
Invoke(sender, args) | Alias for Raise | Identical behavior |
SetReplayHook(hook) | Installs subscription-time replay behavior | Special compatibility facility, not normal .NET event semantics |
Subscribe, raise, and remove
#include <iostream>
#include <System/EventHandler.hpp>
struct ProgressArgs {
int completed;
};
System::EventHandler<ProgressArgs> progress;
const auto token = progress.Add(
[](System::Object*, const ProgressArgs& args) {
std::cout << args.completed << '\n';
});
progress.Raise(nullptr, ProgressArgs{3});
progress.Remove(token);
progress.Raise(nullptr, ProgressArgs{4}); // no subscriber remains
Multiple subscriptions are independent. Adding the same callable twice stores two entries and invokes it twice. Each non-empty Add receives a unique numeric Token; removing one token does not remove another subscription that happens to wrap the same callable. There is no operator-= because arbitrary std::function values do not provide a reliable general equality operation.
Empty handlers
An empty HandlerType is treated like adding a null delegate in C#: it is not stored, does not invoke the replay hook, and does not change Size() or Empty(). Raise and Invoke therefore do not encounter a deliberately empty subscriber through the public API.
Add still consumes and returns a unique token for the ignored handler. Calling Remove with that token is safe and has no effect. Consuming the token prevents a later real subscription from reusing it and being accidentally removed through the ignored subscription's token.
Dispatch order, snapshots, and reentrancy
Raise copies the current vector of token/callable pairs, then invokes that snapshot in subscription order. A handler may add, remove, or clear subscriptions on the same event without invalidating the loop. A handler that removes itself still runs once in the current raise; a not-yet-invoked handler removed by an earlier callback also remains in the current snapshot. Those changes are visible on the next raise.
A nested call to Raise takes its own snapshot of the then-current list. This makes reentrancy memory-safe at the collection level, but it does not make application state reentrancy-safe. Handlers must still protect invariants if they trigger nested events, mutate shared objects, or cause the owner to begin shutdown.
Each raise copies the stored std::function objects. Captured shared owners remain shared; captured references and raw pointers remain non-owning. Copying a callable does not repair a dangling capture.
Replay hooks
SetReplayHook installs one std::function<void(const HandlerType&)> that runs synchronously whenever a non-empty handler is added. It receives the new handler before that handler is stored. The intended use is a specific compatibility event whose documented contract replays already-existing state to a new subscriber—for example, an owner can call the handler once for each pending item.
The hook's own calls are not subscriptions and do not change the event size. Replacing it with an empty function clears it. Plain events should leave it unset. It is not a general event-history queue, and Clear() removes handlers without removing this hook.
progress.SetReplayHook(
[](const System::EventHandler<ProgressArgs>::HandlerType& handler) {
handler(nullptr, ProgressArgs{2}); // current state replay
});
const auto replayed = progress.Add(onProgress);
// onProgress ran synchronously before Add returned, then was stored.
A replay hook can call user code during subscription, so Add is not necessarily a passive mutation. Avoid holding locks across subscription if the hook or callback could call back into the owner.
Exception behavior
The event collection does not catch callback exceptions. If a handler invoked by Raise throws, that exception escapes immediately and later handlers in the snapshot are not called. The stored subscription list remains intact unless callbacks changed it before the exception. Code that must isolate subscriber failures should catch at an explicit application boundary and define logging or aggregation policy there.
A replay-hook exception escapes from Add. Because the hook runs before insertion, the new handler is not stored when that call fails. Copying the snapshot can also propagate native allocation or callable-copy failures before any subscriber runs.
Thread safety
EventHandler contains a vector, a token counter, and a replay callable with no mutex or atomic coordination. Concurrent Add, Remove, Clear, SetReplayHook, Size, or Raise on the same instance is not supported without external synchronization. The snapshot protects mutation performed synchronously by a handler on the same thread; it is not a cross-thread safety mechanism.
If an event crosses threads, choose and document the owner of the lock or message queue. Do not hold an event lock while invoking arbitrary handlers unless reentrancy and deadlock behavior have been designed explicitly.
Ownership and lifetime
The event owns its stored std::function values. What those functions own depends on their captures:
- A value capture is copied into the callable.
- A
shared_ptrcapture extends the target's lifetime and can participate in a reference cycle. - A reference, raw pointer, or captured
thisdoes not extend lifetime and can dangle. - A weak capture must be locked and checked on every invocation.
Tokens are plain integers, not RAII subscription objects. The caller must remove a subscription at the appropriate teardown boundary. If the event owner dies first, its handler list dies with it; if it outlives a referenced subscriber, the callback must be removed or use an expiry-aware capture.
The class has ordinary compiler-generated copy and move behavior. Copying an EventHandler duplicates its current handler list, replay hook, counter value, and callable captures into an independent event object. Tokens should be treated as meaningful only for the particular event instance that returned them. If event identity must not be duplicated, make the containing owner non-copyable.
Differences from C# and .NET
| C# / .NET | Sharp Runtime / C++ | Consequence |
|---|---|---|
EventHandler<T> is one delegate type | The template owns a multicast subscription list | Declaring the value also declares event storage |
event restricts external invocation and assignment | A public EventHandler value exposes Raise, Clear, and mutation | Encapsulation must be designed by the containing C++ class |
+= and -= combine/remove delegates | += adds; removal uses an Add token | Keep the token when teardown is required |
| Managed delegate captures keep managed targets reachable | Native capture ownership is explicit | Audit raw this, references, shared cycles, and weak expiry |
| Multicast invocation uses a delegate snapshot | Raise copies the stored handlers before invocation | Mid-raise mutations take effect next time |
| Typed-sender newer overload may be available | Sender remains System::Object* | No EventHandler<TSender,TArgs> shape |
| No general subscription-time replay in EventHandler | Optional owner-installed replay hook | Use only for a specifically documented compatibility event |
Encapsulation pattern
When only the owning type should raise an event, avoid exposing a freely mutable public field. A native owner can keep EventHandler private, expose a subscription method returning a token, expose a matching unsubscribe method, and call Raise internally. This is more verbose than the C# event keyword but preserves its important access boundary.