Pointers & by-ref
In this specification
Section titled “In this specification”This page is the complete pointer overview. The two linked subpages expand the parts where source syntax deliberately hides a CLR distinction:
- Pointer values and allocation — the one
*Tmeaning, its escape-selected representations, and thenew/&allocation boundary. - By-ref calls and method sets — parameter grammar,
outand readonly borrowing, address-of resolution, pointer receivers, and interface conformance.
*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 }Two representations
Section titled “Two representations”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… | Representation | Cost |
|---|---|---|
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 holder | one allocation, shared, GC-kept-alive |
| provably does neither | a managed pointer ref T — aliases the caller’s storage directly | zero 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.
new vs. &
Section titled “new vs. &”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:
newallocates 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 managed-pointer foundation
Section titled “The managed-pointer foundation”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:
| Form | CLR emission | Direction | Deref | Call site |
|---|---|---|---|---|
x: T | T | in (by value) | — | f(x) |
x: *T | ref T or __Ptr_T | in / out | auto | f(&x) / f(*x) |
x: readonly *T | in T ([In]) | in (zero-copy) | auto, read-only | f(&x) |
out x: T | [Out] T& | out | implicit | f(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
Section titled “readonly *T”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).
Address-of and ref locals
Section titled “Address-of and ref locals”&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.
Method sets and interface conformance
Section titled “Method sets and interface conformance”*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.