Skip to content

Pointers & by-ref

This page is the complete pointer overview. The two linked subpages expand the parts where source syntax deliberately hides a CLR distinction:

*T is E#‘s pointer: a nullable, aliasing, first-class reference to a T location — storable, returnable, and shareable. It is how a value-semantic struct reaches the shared, recursive, and nullable flows that a plain value cannot (Type system → pointers). It applies to value struct of any size and to primitives (*int). *Class is ill-formed (ES2003) — a class is already a reference. Member access through a *T auto-dereferences (p.value, never (*p).value).

struct Node { value: int, next: *Node } // *Node breaks the recursive cycle
func prepend(head: *Node, v: int) -> *Node = new Node { value: v, next: head }

A *T has one source meaning but two CLR forms; a whole-module escape analysis chooses per binding, and the choice is not observable from source — every program behaves identically either way:

The pointer…RepresentationCost
escapes the frame (returned, stored in a field/collection, captured) or is nullable (compared to nil, initialized nil)__Ptr_T — a heap reference cell shared by every holderone allocation, shared, GC-kept-alive
provably does neithera managed pointer ref T — aliases the caller’s storage directlyzero allocation

Conversions between the two forms are inserted automatically wherever they meet at a call, so a non-escaping bump(&c) aliases the caller’s local through a ref T while an escaping prepend(head, v) keeps the __Ptr_T cell — both spelled *Node in source.

The two forms differ only in where the value lives. Taking the address of a local and passing it to a non-escaping parameter aliases the caller’s stack slot with no allocation:

struct Data { var value: int }
func otherFunc(d: *Data) { d.value = 99 } // mutates through the pointer
func f() {
var local = Data { value: 10 }
otherFunc(&local) // &local → ref Data, aliases the stack slot
// local.value == 99
}

f emits Data as a stack local and the call as ldloca; call otherFunc(Data&) — no newobj, no GC. Allocating the same value with new instead routes every access through a heap cell:

func g() {
var local = new Data { value: 10 } // *Data — a __Ptr_Data heap cell
otherFunc(local) // already a pointer
// local.value == 99
}

Here g allocates the wrapper (newobj __Ptr_Data::.ctor(Data)) and each field touch dereferences it (ldflda Value). The callee body is identical in both cases — it receives Data& either way; the cost difference is entirely at the definition site, and is exactly the escape-analysis choice made explicit by the spelling: &local pays nothing unless the address escapes the frame, new pays the allocation unconditionally. The escape analysis is precisely what makes the ref T form safe to choose: a managed pointer is GC-tracked, cannot dangle, and cannot escape its frame, so it is only selected when escape is provably impossible; otherwise the __Ptr_T cell carries the value and the GC keeps it alive as long as a holder exists. There is no unmanaged-pointer arithmetic and no way to form a dangling reference. The rationale is detailed in Memory model → the *T foundation.

NewExpr = "new" TypeName ( "{" [ FieldInitList ] "}" | "(" [ ArgList ] ")" ) .

new T { … } heap-allocates a value struct and yields a fresh *T — it is the only allocation expression and the only way to mint a fresh pointer. new on a non-struct type is ES2144. Otherwise & only takes the address of storage that already exists:

new allocates something that does not yet exist; & takes the address of something that does.

Neither is the stack-buffer primitive: stackalloc T[](n) yields a frame-local Span<T>, not a *T. It is the span-world counterpart to new’s heap pointer — a localloc-backed buffer governed by the by-ref-like safety rules, never an aliasing pointer. See Low-level & unmanaged → stack-allocated spans.

The CLR has one underlying by-ref primitive, the managed pointer T& (ByReferenceType). ref, out, and in are the same IL type, distinguished only by parameter metadata; E#‘s by-ref family maps onto it:

FormCLR emissionDirectionDerefCall site
x: TTin (by value)f(x)
x: *Tref T or __Ptr_Tin / outautof(&x) / f(*x)
x: readonly *Tin T ([In])in (zero-copy)auto, read-onlyf(&x)
out x: T[Out] T&outimplicitf(out x) / f(out var x)

A *T parameter emits the escape-chosen ref T or __Ptr_T; both &expr and *expr at the call site pass by reference.

out x: T is the C# out shape: it emits [Out] T& (IsByRef + IsOut, never [In]). Assignment writes through the slot, a read loads through it, and it interoperates one-to-one with C# out — an E# out parameter binds from C# with out var, and E# calls a C# out method the same way. The call site may inline-declare the receiving local with out var x, which scopes x to the statement and what follows.

readonly *T is a zero-copy read-only borrow — it emits in T ([In]), aliasing the caller’s storage without copying while forbidding writes through it: assigning through a readonly *T is a binder error. A readonly *T is an ordinary free-function parameter, not a receiver kind: the receivers are value (c: T), pointer (c: *T), and readonly-value readonly func (c: T) (Functions → methods).

&name yields a managed pointer to a local, parameter, or field (ldloca / ldarga / ldflda), and &arr[i] to an array element (ldelema). If name resolves to a function instead, &name is a function pointer — the disambiguation is by what the name denotes, a storage location versus a function. A local initialized from &expr becomes a ref local (a ByReferenceType variable); reads and writes through it transparently dereference. Managed pointers are GC-tracked, cannot dangle, and cannot escape the frame.

*T has its own method set. A value receiver (func (c: T)) is in both T’s and *T’s sets; a pointer receiver (func (c: *T)) is in *T’s set only (Functions → methods). When a declared interface is satisfied only by the pointer method set, the compiler generates a __Ptr_T wrapper that implements the interface and forwards to the underlying static hosts — so a *T flows through an interface-typed parameter without boxing the value on every call. The wrapper is generated only when a *T is actually used as that interface.