Delegates & events
E# has two callable tiers, chosen by intent rather than inferred. Function pointers are the systems tier — zero allocation, single target; delegates are the interop tier — heap-allocated, multicast, the shape the BCL and C# speak. This page specifies delegates and events; function pointers are in Functions → function pointers.
In this specification
Section titled “In this specification”- Delegate conversion and nominal delegates specifies target typing,
method groups, lambdas, and
delegate funcidentity. - Events and subscription specifies event declarations,
raise, subscriptions, and CLR interop.
| Tier | Forms | CLR | Cost |
|---|---|---|---|
| function pointer | &f; type &(int, int -> int) | ldftn + calli | zero-alloc, single-target |
| delegate | Func / Action / EventHandler<T>, a delegate func, a lambda | a MulticastDelegate subclass | heap, GC, multicast |
Method-group conversion
Section titled “Method-group conversion”A bare function name where a delegate type is expected converts to that delegate, bound directly to the
real method — no synthesized forwarder is interposed, so reflection and interop see the actual target
(delegate.Method.Name is the original function’s name). This is the CLR-citizenship requirement: an E#
function handed across the boundary as a delegate is indistinguishable from a C# one.
func dbl(x: int) -> int = x * 2let f: Func<int, int> = dbl // ldnull ; ldftn dbl ; newobj Func`2::.ctorConversion fires only when the target delegate type is known — from a typed let, a typed parameter,
a return type, or an event. An un-annotated let f = dbl is a hard error: with no target, the
compiler will not guess between a delegate (heap, multicast) and a function pointer (zero-alloc), nor
silently allocate. Write the delegate type, or &dbl for a pointer.
It works for any delegate type, including bridging to a nominally distinct BCL delegate — a method
group converts to Predicate<int> exactly as it does to Func<int, bool>, even though the two are
unrelated CLR types:
func is_even(x: int) -> bool = x % 2 == 0let p: Predicate<int> = is_even // bridges to the BCL named delegateA lambda lands in a delegate-typed slot the same way, with its parameter types inferred from the
target delegate’s Invoke signature — so let op: BinOp = (a, b) => a + b types a and b from
BinOp, and the materialized value’s runtime type is BinOp, not a default Func.
Nominal delegate types — delegate func
Section titled “Nominal delegate types — delegate func”DelegateFuncDecl = "delegate" "func" TypeName "(" [ ParamList ] ")" [ ReturnType ] .delegate func Name(...) mints a nominal delegate type: a sealed MulticastDelegate subclass whose
identity is its Invoke signature. The emitted type is exactly what C# emits for delegate R Name(...) —
sealed, deriving from MulticastDelegate, with a matching Invoke — so it crosses the assembly boundary
in both directions unchanged.
delegate func BinOp(a: int, b: int) -> int // sealed : MulticastDelegate, Invoke(int,int) -> intdelegate func Tick() // void, zero-parameterNominality is the point. A Func<int, int, int> is not a BinOp even though both wrap
(int, int) -> int — a value materialized as BinOp reports its runtime type as BinOp and is not
assignable to Func<int, int, int>. This is the same nominal philosophy as E#‘s
interfaces: structural coincidence does not imply
identity. A BinOp-typed slot accepts a method group, a lambda (parameters inferred from its Invoke),
or a capturing lambda, and a delegate func value is a first-class parameter, let, and return type:
func add(a: int, b: int) -> int = a + bfunc apply(f: BinOp, a: int, b: int) -> int = f(a, b)func get_op() -> BinOp = add // method group → named delegate in return position
let op: BinOp = add // bound directly to addapply(add, 20, 22) // method group converts at the call, target-typed by the paramdelegate is a contextual keyword — recognized only before func at member scope, an ordinary
identifier elsewhere.
The Delegate position
Section titled “The Delegate position”System.Delegate (and MulticastDelegate) is the abstract base of every delegate, not a delegate
type: it declares no Invoke, so it supplies no signature to target-type a lambda against. Framework APIs
use it as a catch-all parameter (the minimal-API MapGet(string, Delegate) shape).
A lambda there must bring its own shape, which means annotating its parameters: (x: int) => x * 2
is Func<int, int> on its own terms and converts to Delegate as an ordinary upcast. An unannotated
lambda has no shape at all and nothing downstream can invent one — that is
ES2288. Binding a typed local first is the equivalent spelling:
RouteSink.Invoke1((x: int) => x * 2, 21) // annotated — natural type Func<int, int>
let f: Func<int, int> = (x) => x * 2 // or name the shape, then pass itRouteSink.Invoke1(f, 21)Events
Section titled “Events”EventDecl = "event" identifier ":" Type . // Type shall be a delegateAn event is a member: a controlled subscription point over a delegate. Events are declared field-style
and only on class or interface — both carry identity, which an event implies. An event on
a value struct is ES2140; an event whose type is not a delegate is
ES2141. The delegate may be Action, Action<T>, EventHandler<T>, or a
delegate func.
delegate func Notify(value: int)
pub class Server { pub event OnReady: Notify // typed by a delegate func}
pub class Counter { var total: int pub event OnChanged: Action<int> // typed by a BCL delegate
pub func add(n: int) { self.total = self.total + n raise OnChanged(self.total) }}The emitted shape
Section titled “The emitted shape”An event lowers to exactly the CLR shape C# emits for a field-like event, so a C# consumer subscribes with no glue and E# subscribes to C# events identically:
| Declared on | Emits |
|---|---|
class | a private backing field of the delegate type, public add_ / remove_ accessors (the same lock-free Delegate.Combine / Delegate.Remove + Interlocked.CompareExchange the C# compiler emits), and an EventDefinition |
interface | abstract + virtual add_ / remove_ accessors and an EventDefinition, no backing field |
Each event on a type gets its own backing field and accessors; raising one never reaches another event’s subscribers.
raise Name(args) fires the event named Name on the enclosing class. It lowers to a thread-safe
capture-then-invoke and is null-safe: raising with no subscribers is a no-op, never a
NullReferenceException. It multicasts to every subscriber, in subscription order.
raise naming an event not declared on the enclosing type is ES2142. raise
is contextual — the event name between raise and ( distinguishes it from a call — as is event,
recognized only before name : in a type body.
Subscription
Section titled “Subscription”Subscribe and unsubscribe with += / -=, on E#-declared and external (C#) events alike; both resolve
the event’s add_ / remove_ accessors. After a -=, the removed handler stops receiving:
pub func wire(h: Action<int>) { self.OnChanged += h }pub func unwire(h: Action<int>) { self.OnChanged -= h }Because the emitted metadata is an ordinary CLR event, a C# consumer wires counter.OnChanged += … with
no awareness that Counter was authored in E#, and E# wires a BCL event the same way.