Skip to content

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.

TierFormsCLRCost
function pointer&f; type &(int, int -> int)ldftn + callizero-alloc, single-target
delegateFunc / Action / EventHandler<T>, a delegate func, a lambdaa MulticastDelegate subclassheap, GC, multicast

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 * 2
let f: Func<int, int> = dbl // ldnull ; ldftn dbl ; newobj Func`2::.ctor

Conversion 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 == 0
let p: Predicate<int> = is_even // bridges to the BCL named delegate

A 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.

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) -> int
delegate func Tick() // void, zero-parameter

Nominality 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 + b
func 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 add
apply(add, 20, 22) // method group converts at the call, target-typed by the param

delegate is a contextual keyword — recognized only before func at member scope, an ordinary identifier elsewhere.

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 it
RouteSink.Invoke1(f, 21)
EventDecl = "event" identifier ":" Type . // Type shall be a delegate

An 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)
}
}

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 onEmits
classa 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
interfaceabstract + 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.

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.