ref Locals and ref Returns · How it works
7 min readHow it works
ref locals
A: A local variable declared with ref that refers to another storage location instead of holding its own copy. It must be initialized with a reference (= ref …), and from then on every read and write goes through to that location.
int[] scores = { 10, 20, 30 };
int copy = scores[1]; // ordinary local: a copy of the value
copy = 99; // scores[1] is still 20
ref int slot = ref scores[1]; // ref local: an alias for scores[1]
slot = 99; // scores[1] is now 99A: Any storage with a stable location: array elements, fields of classes, fields of structs reached through a reference, static fields, other locals and parameters, ref/in/out parameters, and the result of a ref-returning method, property or indexer (such as a span's indexer). It cannot refer to a regular property (a property getter returns a value, not storage) or to the result of an ordinary method.
A: Since C# 7.3, yes, with ref reassignment: slot = ref scores[2]; makes slot an alias for a different element. Without ref on the right-hand side, slot = scores[2]; would instead copy the value of scores[2] into scores[1] (the storage slot currently aliases).
A: (C# 7.2) A ternary that selects between two references rather than two values: ref int target = ref (useFirst ? ref a : ref b); target++; increments whichever variable was chosen.
ref returns
A: A method, property or indexer declared to return ref T returns a reference to storage that outlives the call, instead of a copy. The caller can then read or write that storage directly.
public class Grid
{
private readonly Cell[] _cells = new Cell[100 * 100];
public ref Cell At(int x, int y) => ref _cells[y * 100 + x];
}
ref Cell c = ref grid.At(3, 4); // alias to the element inside the array
c.Visited = true; // modifies the array element in place
grid.At(5, 5).Cost = 7; // also modifies in place, no local neededref when calling a ref-returning method?A: The reference is dereferenced and the value copied. Cell c = grid.At(3, 4); c.Visited = true; changes a local copy and leaves the array untouched. This is the most common ref-return bug; the compiler cannot warn, because copying is legal.
A: The reference must point to storage that is still alive after the method returns. Allowed: elements of arrays, fields of classes, static fields, and anything that came in by reference (ref or in parameters, or a field of a ref-passed struct). Not allowed: locals and by-value parameters of the method itself, because its stack frame is gone after it returns.
ref int Bad()
{
int local = 5;
return ref local; // error CS8168: cannot return local by reference
}
ref int First(int[] data) => ref data[0]; // fine: the array lives on the heap
ref int Larger(ref int a, ref int b) => ref (a >= b ? ref a : ref b); // fine: came in by refref readonly returns and locals?A: (C# 7.2) A reference the caller may read through but not write through. It avoids copying a large struct out of a container while still protecting it: public ref readonly Matrix4 Transform(int i) => ref _transforms[i]; with ref readonly var t = ref scene.Transform(0);. As with in parameters, calling members of a non-readonly struct through a ref readonly reference causes defensive copies, so pair it with readonly struct.
Memory level
A: A managed pointer (a "byref"): an address that can point into the middle of an object (an array element, a field), onto the stack, or into native memory. It is the same size as an ordinary pointer (8 bytes on 64-bit). Unlike an object reference, it is not a GC handle to a whole object; the GC still understands it and keeps the containing object alive and updates the pointer if the object moves during compaction. Because tracking such interior pointers is only cheap for values on the stack, a byref can live only in locals, parameters and return values, and (since C# 11) in fields of ref structs: never in a class field or on the heap.
stack managed heap
┌──────────────────────┐ ┌──────────────────────────────────────────────┐
│ ref Cell c ──────────┼──┐ │ Cell[] header | MT | length │
└──────────────────────┘ │ │ [0] Cell [1] Cell ... [403] Cell ... │
└────►│ ▲ │
└──────────────────────────┼───────────────────┘
interior pointer to element 403A: array[i] on an array of structs already refers to the element in place, so cells[i].Visited = true works. But anything that goes through a method or property that returns the struct by value gives you a copy: list[i].Visited = true on a List<Cell> does not even compile (CS1612), and var c = GetCell(i); c.Visited = true; changes a copy. Ref returns give methods and custom containers the same in-place access an array has, without copying a potentially large struct in or out.
List<T> and Dictionary<TKey, TValue>?A: The standard collections return values by copy, so updating a struct inside them normally means read, modify, write back (two lookups for a dictionary). System.Runtime.InteropServices.CollectionsMarshal exposes references:
// Update a struct value in a dictionary with one lookup, no copy
ref Stats s = ref CollectionsMarshal.GetValueRefOrAddDefault(stats, key, out bool existed);
s.Count++;
s.Total += amount;
// Mutate List<T> elements in place through its backing array
Span<Cell> cells = CollectionsMarshal.AsSpan(list);
foreach (ref Cell c in cells) c.Visited = false;
Do not add or remove entries while holding such a reference: the collection may reallocate its storage, leaving your reference pointing at the old, abandoned array or bucket.
ref in foreach and ref fields
foreach (ref var x in span)?A: (C# 7.3) Iterating with a ref iteration variable, supported when the enumerator's Current returns by reference, as Span<T>'s does. Each x is an alias to the element, so you can modify elements in place and avoid copying large structs: foreach (ref var p in particles.AsSpan()) p.Position += p.Velocity * dt;. foreach (ref readonly var x in …) gives read-only aliases.
A: (C# 11) A ref struct can declare a ref T field, storing a managed pointer inside the struct. That is how Span<T> is written in C# today: readonly ref struct Span<T> { internal readonly ref T _reference; private readonly int _length; }. Because the containing type is a ref struct, it stays on the stack, which keeps the byref off the heap.
Rules and restrictions
A: In async methods and iterators across an await or yield (locals there are hoisted to the heap), inside lambdas or local functions that capture them, and as fields of classes or ordinary structs. You also cannot take a ref to a property unless the property itself returns ref.
A: With ref-safe-to-escape analysis: every reference has a scope beyond which it may not escape (the method for locals, the caller for things that came in by reference, "anywhere" for heap storage). Returning, storing or assigning a reference to a place with a longer lifetime than its scope is a compile error. The scoped keyword (C# 11) narrows a parameter's scope further so more callers can pass stack-based references.
When to use them
A: In performance-critical code working with large structs or arrays of structs: game engines and entity-component systems, simulations and numerics, parsers and serializers, custom high-performance collections and pools, and lookup tables that are updated in place. In ordinary business code they add complexity for no measurable gain; classes and immutable records are clearer there.
Common interview gotchas
int[] a = {1, 2}; ref int r = ref a[0]; r = a[1]; r++; Console.WriteLine($"{a[0]} {a[1]}");A: 3 2. r = a[1] (without ref) copies the value 2 into a[0], which r still aliases; r++ makes a[0] 3.
ref array[0] but not ref localVariable?A: The array lives on the heap and outlives the call; the local lives in the method's stack frame, which is destroyed when the method returns, so a reference to it would dangle.
A: Yes. The GC treats the interior pointer as keeping the whole containing object alive, and updates the pointer if the array is moved during compaction.
ref readonly returns and returning the value?A: Returning the value copies it (cheap for small types, expensive for big structs). ref readonly returns a pointer and forbids writes, so large readonly structs can be read without copying, and without letting callers change them.