📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Explain ref locals, ref returns and ref fields in C#. How do ref safety rules and the scoped keyword work, what does CollectionsMarshal.GetValueRefOrAddDefault give you, when do ref returns improve performance, and what bugs can references to storage cause?

ref locals, ref returns, ref fields (C# 11), scoped, ref safety and practical uses.

Senior Theory Expert performance ref ref-fields ref-returns scoped

🎯 Key points

  • ref locals and ref returns alias storage instead of copying it
  • Methods may return refs to array or span elements, class fields and statics, never to locals
  • ref readonly returns allow aliasing without writes
  • Ref fields (C# 11) are allowed only in ref structs and are how Span<T> is built
  • The compiler tracks safe-to-escape scopes; scoped promises a parameter does not escape
  • CollectionsMarshal.GetValueRefOrAddDefault updates dictionary values with one lookup
  • Refs into lists or dictionaries become stale after resizing
  • Use ref features for large structs and hot paths, not ordinary class-based code

⏱ 30-second answer

ref creates an alias to a storage location instead of a copy. A ref local (ref int slot = ref array[i];) and a ref return (ref T Find(...)) let you read and write an element in place, which avoids copying large structs and double lookups. Ref fields (C# 11) let a ref struct hold a reference, which is how Span<T> is implemented. The compiler enforces ref safety: you cannot return a reference to a local or to a stack-only value that will die, and scoped marks a ref or span parameter as not escaping the method, which makes more call patterns legal. CollectionsMarshal.GetValueRefOrAddDefault returns a ref to a dictionary value so "lookup and update" costs one hash. The risk is dangling or stale references: a ref into a list or dictionary is invalid after the collection resizes.

🗣 Full answer (senior)

How I would answer this in an interview

Ref locals and ref returns let you hold and return references to storage (array elements, fields, dictionary values) instead of copies; ref fields (C# 11) let a ref struct store such a reference, which is how Span<T> is built. The compiler's ref safety rules guarantee references never outlive what they point to, with scoped to narrow lifetimes. I'd cover each, CollectionsMarshal.GetValueRefOrAddDefault, when ref returns help performance, and the bugs references to storage can cause.

1. Ref locals and ref returns

public struct Pixel { public byte R, G, B, A; }

public sealed class Image(int width, int height)
{
    private readonly Pixel[] _pixels = new Pixel[width * height];
    public ref Pixel At(int x, int y) => ref _pixels[y * width + x];   // ref return
}

ref Pixel p = ref image.At(10, 20);   // ref local: alias for the array element
p.R = 255;                            // modifies the element in place - no copy
image.At(11, 20).G = 128;             // direct in-place update

Without ref, At would return a copy of the struct, and mutations would be lost. ref readonly returns expose without copying and without allowing writes:

public ref readonly Matrix4x4 Transform => ref _transform;   // no 64-byte copy per access

2. Ref fields (C# 11)

public readonly ref struct Window<T>
{
    private readonly ref T _start;
    private readonly int _length;
    public Window(ref T start, int length) { _start = ref start; _length = length; }
    public ref T this[int i] => ref Unsafe.Add(ref _start, (uint)i < (uint)_length ? i : throw new IndexOutOfRangeException());
}

Only ref structs can have ref fields (they must stay on the stack). This made Span<T> expressible in C# rather than relying on runtime magic.

3. Ref safety rules and scoped

The compiler tracks, for each reference and ref struct value, its safe context: whether it may refer to the current method's stack (function-member scope) or can escape to the caller (caller-context).

  • You can't return a ref to a local variable (return ref local; → CS8168) or to a span over stackalloc.
  • You can return refs to fields of heap objects, array elements, static fields, and ref parameters (they come from the caller).
  • scoped (C# 11) marks a parameter or local as not escaping the method:
public static int Sum(scoped ReadOnlySpan<int> values) { ... }   // promises not to store/return 'values'

Callers can then pass spans over stack memory, and the method can't leak them. It also relaxes rules for ref struct methods (e.g. Span<T> parameters of ref struct methods default to scoped in some positions).

  • [UnscopedRef] (C# 11) does the opposite for specific cases (e.g. returning a ref to a field of this in a struct).

4. CollectionsMarshal.GetValueRefOrAddDefault

var counts = new Dictionary<string, int>(StringComparer.Ordinal);
foreach (var word in words)
{
    ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out _);
    count++;                                         // ONE hash lookup, in-place update
}
  • Returns a ref to the value slot, adding a default entry if missing.
  • Avoids the double lookup of TryGetValue + indexer set, and avoids copying large struct values.
  • GetValueRefOrNullRef returns a null ref for missing keys (check with Unsafe.IsNullRef).
  • The ref is valid only until the dictionary is modified (adds/removes can resize and move entries).

5. When ref returns improve performance

  • Large structs stored in arrays/collections (matrices, game entities, pixels) — avoid copying on each access and allow in-place mutation.
  • Hot paths in data structures: custom collections returning refs (ref T this[int i]), ECS-style component storage.
  • Dictionary counters/accumulators with GetValueRefOrAddDefault.
  • Spans and parsers: MemoryMarshal.GetReference(span) + Unsafe.Add in low-level loops (careful!).

For small types (≤ 16 bytes) or reference types, copying is cheap and refs add complexity for little gain. Measure with BenchmarkDotNet.

6. Bugs references to storage can cause

  • Stale refs after resize: a ref into a List<T> backing array (via CollectionsMarshal.AsSpan) or a dictionary entry becomes stale when the collection grows; writes go to the old array and are lost.
  • Aliasing: two refs to the same storage; modifying through one changes what the other sees — surprising in algorithms that assume independence.
  • Exposing internal state: a public ref return lets callers mutate private storage, bypassing invariants (use ref readonly).
  • Ref to readonly fields of structs may be defensive copies when not marked ref readonly.
  • Thread safety: refs to shared storage mutated concurrently cause races (no locks are implied).
  • Unsafe helpers (Unsafe.Add, Unsafe.As) bypass bounds and type checks — memory corruption if misused.

7. A production example

A simulation service updated millions of Particle structs (48 bytes each) per frame via particles[i] = particles[i] with { ... } — copying each struct twice. Changing the loop to ref var p = ref particles[i]; p.X += p.Vx * dt; (and later iterating a Span<Particle>) removed the copies and roughly doubled frame throughput. Separately, a word-count job switched to GetValueRefOrAddDefault, halving hash lookups. One regression: a helper cached a ref to a List<T> element across an Add that resized the list — a test with a small initial capacity caught it.

🔍 What the interviewer is checking

The interviewer checks deep knowledge of C# reference semantics and the safety rules behind spans.

Green flags

  • Explains ref locals/returns as aliases and when they avoid copies.
  • Knows ref fields are only in ref structs and power Span.
  • Explains ref safety and what scoped fixes.
  • Mentions GetValueRefOrAddDefault and the stale reference trap.

Red flags

  • Confuses ref with passing a class reference.
  • Thinks you can return a ref to a local.
  • Uses unsafe pointers to bypass ref safety errors.

📚 Learn in depth

1. Managed pointers

A ref is a managed pointer (T& in IL). It can point into the stack, into an object's field, into an array element, or into unmanaged memory. The GC tracks managed pointers in registers/stack slots ("interior pointers") and updates them when objects move. Managed pointers can't be stored in heap fields (except as ref fields of ref structs, which themselves can't be on the heap).

2. Safe-context rules (simplified)

  • Locals declared in the method: safe-context = current method.
  • ref parameters: safe-context = caller (can be returned).
  • scoped ref parameters: safe-context = current method.
  • Fields of a class (via a reference): caller-context (heap lives long enough).
  • For ref struct values, separate rules govern the values they reference ("ref-safe-to-escape").

The compiler ensures any returned ref/ref struct value has caller-context. C# 11 overhauled these rules (with scoped and ref fields), and some older code gets new errors — [UnscopedRef] addresses specific patterns.

3. Ref returns from properties and indexers

public ref T this[int index] => ref _items[index];

Indexers returning ref can't have setters (assignment goes through the ref). ref readonly indexers expose large elements cheaply without mutation. Span<T>'s indexer is a ref-returning indexer.

4. Unsafe and MemoryMarshal

  • Unsafe.Add(ref T, int) — pointer arithmetic on refs (no bounds check).
  • Unsafe.As<TFrom, TTo>(ref TFrom) — reinterpret.
  • Unsafe.IsNullRef / Unsafe.NullRef<T>() — null refs.
  • MemoryMarshal.GetReference(span) / GetArrayDataReference(array) — refs to first elements.

These are the tools behind high-performance BCL code; misuse leads to memory corruption.

5. Defensive copies and ref readonly

Calling non-readonly members on structs via ref readonly/in/readonly fields creates hidden copies. Marking structs and members readonly avoids them. C# 12 added ref readonly parameters (stricter than in, requiring an lvalue).

Try it yourself

  1. Return a ref to an array element and mutate it via a ref local.
  2. Try returning a ref to a local and read CS8168.
  3. Count words with GetValueRefOrAddDefault and compare with TryGetValue + set.
  4. Hold a ref from CollectionsMarshal.AsSpan across a list resize and observe stale writes.

💻 Code

using System.Runtime.InteropServices;

public struct Particle
{
    public double X, Y, Vx, Vy;      // 32 bytes: copies add up in hot loops
    public int Hits;
}

public ref struct HitCounter
{
    private ref int _total;          // C# 11 ref field: only allowed in a ref struct
    public HitCounter(ref int total) => _total = ref total;
    public void Add(int hits) => _total += hits;
}

public static class Program
{
    // Ref return into the caller's span: no copy, caller can write through it
    static ref Particle Fastest(Span<Particle> particles)
    {
        ref Particle best = ref particles[0];
        for (int i = 1; i < particles.Length; i++)
        {
            ref Particle p = ref particles[i];
            if (p.Vx * p.Vx + p.Vy * p.Vy > best.Vx * best.Vx + best.Vy * best.Vy)
                best = ref p;          // re-point the ref local
        }
        return ref best;
    }

    // scoped: the span does not escape, so callers can pass stackalloc memory freely
    static int Total(scoped ReadOnlySpan<int> values)
    {
        int sum = 0;
        foreach (int v in values) sum += v;
        return sum;
    }

    public static void Main()
    {
        Particle[] particles = new Particle[3];
        particles[1].Vx = 5;
        particles[2].Vy = 2;

        ref Particle fastest = ref Fastest(particles);
        fastest.Hits++;                                   // updates particles[1] in place
        Console.WriteLine(particles[1].Hits);             // 1

        int total = 0;
        var counter = new HitCounter(ref total);
        foreach (ref Particle p in particles.AsSpan())    // ref iteration variable
            counter.Add(p.Hits);
        Console.WriteLine(total);                         // 1

        var counts = new Dictionary<string, int>();
        foreach (string word in "the cat and the hat".Split(' '))
        {
            ref int count = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, word, out _);
            count++;                                      // one hash lookup per word
        }
        Console.WriteLine(counts["the"]);                 // 2

        Span<int> scratch = stackalloc int[] { 1, 2, 3 };
        Console.WriteLine(Total(scratch));                // 6
    }
}

⚠ Common mistakes

1. Holding refs across collection modifications

What happens: refs from CollectionsMarshal used after adding/removing items. Why it is wrong: stale references after resize; lost writes. Do instead: use refs immediately; re-acquire after modifications.

2. Exposing mutable refs publicly

What happens: public ref T Get(int i) on domain types. Why it is wrong: callers bypass invariants. Do instead: ref readonly or methods that enforce rules.

3. Using refs for small or reference types

What happens: ref returns for int or class references. Why it is wrong: complexity without benefit. Do instead: reserve refs for large structs and hot paths.

4. Ignoring aliasing

What happens: algorithms take two refs that may point to the same element. Why it is wrong: unexpected results. Do instead: check for aliasing or copy.

5. Misusing Unsafe.Add

What happens: pointer arithmetic beyond bounds. Why it is wrong: memory corruption without exceptions. Do instead: use bounds-checked spans unless proven necessary.

6. Forgetting scoped in APIs taking spans

What happens: callers can't pass stack spans. Why it is wrong: forces heap allocations. Do instead: mark non-escaping span parameters scoped.

7. Concurrent writes through refs

What happens: multiple threads mutate shared storage via refs. Why it is wrong: data races. Do instead: synchronise or partition data.

8. Assuming ref returns avoid copies everywhere

What happens: assigning a ref return to a normal local (var p = image.At(1,2);). Why it is wrong: that copies the value. Do instead: use ref var p = ref ....

🏛 Senior insight: trade-offs

Ref features are precision tools. They let C# express what C++ developers do with references and pointers while keeping memory safety, and they are why Span<T> can exist in safe code.

Feature Where it earns its place
ref locals/returns arrays of large structs, ECS-style data, matrices
ref readonly exposing internal struct data without copies or writes
ref fields custom span-like or writer ref structs in libraries
scoped APIs taking several spans, especially with stackalloc callers
GetValueRefOrAddDefault counting and aggregation hot paths

In application code I keep them rare and commented, because aliasing makes code harder to reason about. In libraries and hot loops they can remove significant copying and hashing cost, and benchmarks should justify each use.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate explains ref parameters. Why: Checks ref parameter basics.
    How to answer

    C# passes everything by value by default. For a value type that copies the data; for a reference type it copies the reference, so the method can change the object but reassigning the parameter does not affect the caller. ref passes the caller's variable itself; out is a ref the callee must assign, used by TryParse; in and ref readonly pass a read-only reference, useful for large structs. params takes a variable number of arguments, and since C# 13 it can be a ReadOnlySpan<T> to avoid allocating. Optional parameters' defaults are compiled into the caller, which matters when versioning public APIs.

  2. When: Candidate mentions ref fields. Why: Ref fields live in ref structs.
    How to answer

    A ref struct is a struct that must live on the stack, so it can safely contain spans and ref fields. Rules: it cannot be boxed, stored in a class or normal struct field, captured by a lambda, used as an array element, or (before C# 13) used as a generic argument or implement interfaces. C# 11 added ref fields. C# 13 lets ref structs implement interfaces (but never convert to them), adds the allows ref struct anti-constraint so generic code can accept them without boxing, and allows ref struct locals in async and iterator methods as long as they are not used across await or yield. A ref struct with a public Dispose() works with using even without an interface. You write one when you need a stack-first helper over spans, such as a string builder that starts with a stackalloc buffer and rents from ArrayPool only if it grows.

  3. When: Candidate uses GetValueRefOrAddDefault. Why: Dictionary internals explain why refs into it go stale.
    How to answer

    Use TryGetValue for reads: one hash lookup, no exception. ContainsKey then dict[key] hashes twice. Add throws ArgumentException on a duplicate key; the indexer setter silently overwrites; TryAdd returns false instead of throwing. The indexer getter throws KeyNotFoundException; GetValueOrDefault returns a default. Pass a comparer such as StringComparer.OrdinalIgnoreCase for case-insensitive keys. For counters and aggregations, CollectionsMarshal.GetValueRefOrAddDefault gives a ref to the value slot so you update it with one lookup. Enumeration order is not guaranteed; use OrderedDictionary<TKey,TValue> (.NET 9+) or SortedDictionary when order matters.

🛠 Real-world problems: how you handle them

Metrics aggregator double hashing millions of keys
Situation
A metrics aggregator sidecar summed [8 million] counter updates per minute into Dictionary<string, long> keyed by metric name and tags.
Signals
The sidecar used [1.5] cores per node, more than the service it monitored.
Investigation
A CPU profile showed most time in Dictionary FindValue and string hashing, called twice per update by a TryGetValue plus indexer set.
Resolution
Replaced the pattern with CollectionsMarshal.GetValueRefOrAddDefault and incremented through the ref; also precomputed keys for common metrics.
Prevention
Benchmarks for the aggregation path and a code review note on double-lookup patterns.

How to tell it (STAR)

**Situation:** our [metrics sidecar] used more CPU than the services it monitored. **Task:** I was asked to cut its cost across [300] nodes. **Action:** profiling showed dictionary lookups dominated, with every update hashing the key twice. I switched to GetValueRefOrAddDefault to update values through a ref in one lookup and cached common keys. **Result:** CPU dropped from [1.5] to [0.6] cores per node, which saved [about 270] cores across the fleet.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me