ref Locals and ref Returns · How it works

7 min read
Senior9 min read
Rapid overview

How it works

ref locals

Q: What is a ref local?

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 99
Q: What can a ref local refer to?

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

Q: Can a ref local be pointed somewhere else later?

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

Q: What is a conditional ref expression?

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

Q: What is a ref return?

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 needed
Q: What happens if the caller forgets ref 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.

Q: What does a method need to be allowed to return a reference?

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 ref
Q: What are ref 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

Q: What is a ref local at the machine 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 403
Q: Why does this matter for arrays of structs?

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

Q: How do ref returns help with 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

Q: What is 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.

Q: What are ref fields?

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

Q: Where can't you use ref locals?

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.

Q: How does the compiler stop dangling references?

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

Q: When are ref locals and ref returns worth using?

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

Q: What does this print? 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.

Q: Why can a method return 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.

Q: Does holding a ref to an array element keep the array alive?

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.

Q: What is the difference between 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.