Events: observation, identity and source lifetime
An import can report progress without knowing whether its listener is a console, a form or an application log. DirectSharp events use ordinary .NET multicast delegates. Declare the notification signature, raise it from its declaring type, and choose how long each listener remains attached.
The event specification defines declaration, imperative subscription and compiler-managed source tracking. No EventArgs convention, event bus or feature adapter is required for an ordinary application notification.
Observe whichever import is currently selected
This complete example uses a stored source and a declarative handler. Replacing the source moves the subscription; assigning nothing detaches it while leaving the log available for reuse:
import Global.System.Collections.Generic
public class ImportProgress
public event ProgressChanged(completed as Integer, total as Integer)
public action Report(completed as Integer, total as Integer)
require completed >= 0
require total >= completed
raise_event ProgressChanged(completed, total)
end action
end class
public class ProgressLog
private progress as ImportProgress = nothing
private entries as new List <of String>
public action Attach(source as ImportProgress)
progress = source
end action
private action Record(completed as Integer, total as Integer)
handles_event progress.ProgressChanged
entries.Add("{{completed}} of {{total}}")
end action
public action Snapshot()
returns IReadOnlyList <of String>
return new List <of String>(entries)
end action
end class
public class ProgressSession
public static action Capture(
first as ImportProgress,
second as ImportProgress
)
returns IReadOnlyList <of String>
let log = new ProgressLog()
try
log.Attach(first)
first.Report(1, 2)
log.Attach(second)
first.Report(2, 2)
second.Report(1, 1)
return log.Snapshot()
finally
log.Attach(nothing)
end try
end action
end class
With two distinct, present sources, Capture returns 1 of 2 and 1 of 1. The final report from the old source does not reach the log. Snapshot copies the entries into a separate List; later notifications cannot add lines to that returned copy. The read-only interface does not promise deep immutability of arbitrary payloads.
Attach performs an ordinary assignment to a field the compiler can track. Its generated management removes the handler from the old present source, updates the reference, then subscribes to the new present source. There is no active subscription while the source is nothing. A replaceable need or trackable property can use the same relationship; neither replacement nor observation transfers ownership of that source.
The finally block ends this observation session before Capture returns or propagates a failure. Declarative handlers also have the containing object's compiler-managed lifetime contract. Ending a session explicitly is useful when the listener can outlive the import. Subscription tracking does not select a weak-reference policy or promise that the CLR garbage collector can collect an otherwise strongly referenced listener. Do not rely on garbage collection as a session boundary.
Reference C#: stored source and one retained handler
The following types and Capture method are nested in the private native reference host; their visibility reflects that fixture. An emitted application must retain its source-declared accessibility. The handler field is generated storage, not an additional source-level property:
using System;
using System.Collections.Generic;
internal static class EventSubscriptionChecks
{
private sealed class ImportProgress
{
internal event Action<int, int>? ProgressChanged;
internal void Report(int completed, int total)
{
if (completed < 0)
throw new ArgumentException("Completed count cannot be negative: completed >= 0", nameof(completed));
if (total < completed)
throw new ArgumentException("Total cannot be less than completed: total >= completed", nameof(total));
ProgressChanged?.Invoke(completed, total);
}
}
private sealed class ProgressLog
{
private ImportProgress? progress;
private readonly List<string> entries = new();
private readonly Action<int, int> handler;
internal ProgressLog() { handler = Record; }
internal void Attach(ImportProgress? source)
{
if (progress is not null) progress.ProgressChanged -= handler;
progress = source;
if (progress is not null) progress.ProgressChanged += handler;
}
private void Record(int completed, int total) => entries.Add($"{completed} of {total}");
internal IReadOnlyList<string> Snapshot() => new List<string>(entries);
}
private static IReadOnlyList<string> Capture(ImportProgress first, ImportProgress second)
{
var log = new ProgressLog();
try
{
log.Attach(first);
first.Report(1, 2);
log.Attach(second);
first.Report(2, 2);
second.Report(1, 1);
return log.Snapshot();
}
finally { log.Attach(null); }
}
}
The stored delegate is created once for this listener. The assignment path visibly preserves remove → store → add order. The same Attach(null) fragment can supply event unwiring at an applicable compiler-managed lifetime boundary; it is not a complete implementation of class-authored Dispose or its shared success/failure lifecycle.
This example serializes reporting, attachment and snapshots on one application thread. The event contract adds no automatic coordination around a List or concurrent source replacement. A desktop application can use its UI thread; other applications must choose suitable coordination explicitly. Removing a handler does not cancel a handler invocation that has already started or erase a delegate list already captured for a raise.
Bound an imperative subscription with try/finally
Use an imperative subscription when the operation itself defines the observation period. This ordinary helper accepts a compatible handler and synchronous work:
public class EventObservers
public static action ObserveWhile(
progress as ImportProgress,
handler as action(Integer, Integer),
work as action()
)
require progress is not nothing
require handler is not nothing
require work is not nothing
event progress.ProgressChanged add_handler handler
try
work()
finally
event progress.ProgressChanged remove_handler handler
end try
end action
end class
The preceding ImportProgress class supplies the event. The helper guards all inputs before subscribing. If work or a reached handler throws, finally removes this subscription. Existing subscriptions belong to their existing owners. An imperative subscription outside such a boundary stays programmer-managed; declaring an event does not create an automatic subscription scope.
Reference C# method, inside the same containing class:
private static void ObserveWhile(ImportProgress progress, Action<int, int> handler, Action work)
{
if (progress is null)
throw new ArgumentNullException(nameof(progress), "Progress is required: progress is not nothing");
if (handler is null)
throw new ArgumentNullException(nameof(handler), "Handler is required: handler is not nothing");
if (work is null)
throw new ArgumentNullException(nameof(work), "Work is required: work is not nothing");
progress.ProgressChanged += handler;
try { work(); }
finally { progress.ProgressChanged -= handler; }
}
The method does not catch and relabel a handler failure. It also does not turn a fresh awaitable call into synchronous work: the callback type is resultless and synchronous. Use an explicit Task-returning framework callback or an owned asynchronous workflow when completion/failure must be awaited. No silent async void, background start or asynchronous event delivery follows from this event syntax.
Handler identity follows the delegate value
Removal needs the compatible delegate value that was subscribed. Retaining a callable value makes this intention clear. Two conversions of the same named instance method can compare equal because their target and method match, even if they are different delegate objects. Conversely, two closures can capture distinct targets even when their bodies have identical text. Creating another closure is not a general removal strategy.
The ordinary multicast behavior matters too:
| Situation | Native delegate behavior used by this reference |
|---|---|
| No subscribed handlers | The raise completes without a handler call |
| A, B and A subscribed in that order | One raise calls A, then B, then A |
| Remove A once from that list | The last matching A occurrence is removed; A then B remain |
| Remove a value absent from a field-like event | No matching subscription is changed |
| A reached handler throws | Original failure propagates; earlier effects remain and later handlers are not automatically invoked |
| A handler changes subscriptions during a raise | That native raise keeps its captured invocation list; subsequent raises use the changed list |
For example, if the first handler removes the second while a captured invocation list is running, the second can still run in that raise. Detachment governs the subscription relationship; it does not undo in-flight delivery. Frameworks that supply dispatcher queues, weak events or isolated delivery can have additional explicit contracts.
Imported .NET events use their existing signatures
BindingList is a normal .NET type; this action temporarily reports its change notification while adding one customer name:
import Global.System.ComponentModel
public action AddCustomerWithNotice(names as BindingList <of String>, name as String)
require names is not nothing
action ShowChange(sender as Object, change as ListChangedEventArgs)
Global.System.Console.WriteLine(change.ListChangedType)
end action
event names.ListChanged add_handler ShowChange
try
names.Add(name)
finally
event names.ListChanged remove_handler ShowChange
end try
end action
For an ordinary BindingList with change notifications enabled, the add emits ItemAdded. Later changes occur after this action's listener is removed. The library's own signature supplies sender and ListChangedEventArgs; DirectSharp neither requires that pattern for its own events nor replaces it on imported events.
Imported add/remove accessors retain their native behavior and possible side effects. The private rewiring pattern above exercises ordinary field-like events. It does not select transactional repair, rollback or a new accessor policy for arbitrary imported event implementations. Source-defined custom event accessors are outside the core v1 event contract.
Required diagnostics and checked boundaries
| Source case | Required treatment |
|---|---|
| Raise another type's event | Reject; only the declaring type can raise it |
| Subscribe an incompatible callable | Reject under the actual delegate signature |
| Reference an inaccessible event | Reject under ordinary CLR accessibility |
| Use a transient call in handles_event | Reject; the compiler needs a stored, deterministically trackable source |
| Suspend inside a synchronous void handler target | Reject; no implicit async void or new event-delivery protocol |
A normal action can first obtain a transient source and subscribe imperatively with an explicit lifetime. The diagnostic repair shows that choice without inventing a source-tracking mechanism.
The complete C# patterns illustrate stored-source rewiring and scoped subscription lifetimes.