System::EventHandler

Understand the native type that deliberately combines a callback signature with event subscription and dispatch storage.

Core.BaseTokens + snapshot dispatchSource-verified

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

MemberBehaviorNotes
Add(handler)Subscribes a copied/moved std::function and returns a tokenUse the token for precise removal
operator+=(handler)Delegates to Add and discards the tokenConvenient when the subscription lasts as long as the event owner
Remove(token)Removes the matching subscriptionUnknown or already-removed tokens are a no-op
Clear()Removes all stored handlersDoes not clear the replay hook
Empty() / Size()Inspect the current stored listNo synchronization is performed
Raise(sender, args)Invokes a snapshot in subscription orderMutations during dispatch affect the next raise
Invoke(sender, args)Alias for RaiseIdentical behavior
SetReplayHook(hook)Installs subscription-time replay behaviorSpecial 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.

Snapshot means copied callables

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_ptr capture extends the target's lifetime and can participate in a reference cycle.
  • A reference, raw pointer, or captured this does 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# / .NETSharp Runtime / C++Consequence
EventHandler<T> is one delegate typeThe template owns a multicast subscription listDeclaring the value also declares event storage
event restricts external invocation and assignmentA public EventHandler value exposes Raise, Clear, and mutationEncapsulation must be designed by the containing C++ class
+= and -= combine/remove delegates+= adds; removal uses an Add tokenKeep the token when teardown is required
Managed delegate captures keep managed targets reachableNative capture ownership is explicitAudit raw this, references, shared cycles, and weak expiry
Multicast invocation uses a delegate snapshotRaise copies the stored handlers before invocationMid-raise mutations take effect next time
Typed-sender newer overload may be availableSender remains System::Object*No EventHandler<TSender,TArgs> shape
No general subscription-time replay in EventHandlerOptional owner-installed replay hookUse 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.