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

📚 Interview question

What is a ref struct and when would you write one? Explain the rules (stack-only, no boxing, no class fields, no lambda capture), ref fields (C# 11), C# 13 changes (implementing interfaces, the allows ref struct anti-constraint, use in async and iterator methods), the Dispose pattern for ref structs, and walk through a ValueStringBuilder-style type.

Writing ref structs: rules, ref fields, C# 13 interface and generic support, and a stack-first builder example.

Senior Coding Expert allows-ref-struct csharp-13 performance ref-struct span

🎯 Key points

  • A ref struct must live on the stack, so it can contain spans and ref fields
  • Ref structs cannot be boxed, stored in heap objects, captured by escaping lambdas or used as array elements
  • C# 11 added ref fields inside ref structs
  • C# 13 allows ref structs to implement interfaces, but not convert to them
  • C# 13 allows ref struct generic arguments with the allows ref struct anti-constraint
  • C# 13 allows ref struct locals in async and iterator methods between awaits and yields
  • A public Dispose method enables using without IDisposable
  • Stack-first builders start with stackalloc and rent from ArrayPool only when growing

⏱ 30-second 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.

🗣 Full answer (senior)

How I would answer this in an interview

A ref struct is a value type that must live on the stack. That restriction lets it safely hold managed references (ref fields) into the stack, arrays or native memory — which is how Span<T>, ReadOnlySpan<T>, Utf8JsonReader and ValueStringBuilder work. I'd cover the rules, ref fields, C# 13 changes, the Dispose pattern, and walk through a ValueStringBuilder-style type.

1. The rules

A ref struct:

  • Can't be boxed (no conversion to object, ValueType, or — before C# 13 — interfaces).
  • Can't be a field of a class or a normal struct (only of another ref struct).
  • Can't be captured by lambdas or local functions that become delegates.
  • Can't be used across await or yield (C# 13 allows them in async/iterator methods if they don't cross a suspension point).
  • Can't be an array element or (before C# 13) a generic type argument.
  • Is subject to ref safety rules (the compiler prevents returning a ref struct that refers to stack memory of the current method).

These rules guarantee a ref struct never outlives the stack frame of the memory it may reference.

2. ref fields (C# 11)

public readonly ref struct Slice<T>
{
    private readonly ref T _first;
    private readonly int _length;
    public Slice(ref T first, int length) { _first = ref first; _length = length; }
    public int Length => _length;
}

Only ref structs can declare ref fields. Combined with scoped and the updated safety rules, they let you build span-like abstractions in C# (the BCL's Span<T> now uses a real ref T field).

3. C# 13 changes

  • Interfaces: ref structs can implement interfaces (e.g. IDisposable, IEnumerator<T>), but still can't be converted to the interface (no boxing). The interfaces are usable via generics.
  • allows ref struct anti-constraint: generic methods/types can opt in to accepting ref structs: void Process<T>(T value) where T : allows ref struct. The compiler then enforces ref-struct rules for T inside. .NET 9 annotated many BCL generics (Func<>, Action<>, some collections/comparers) so spans can flow through them.
  • Async and iterator methods can declare ref struct locals (e.g. Span<T>) as long as they're not used across await/yield.

4. Dispose pattern for ref structs

ref structs can't implement IDisposable usefully in older versions, so using is pattern-based: any ref struct with an accessible void Dispose() method works in a using statement/declaration. Typical use: returning a rented ArrayPool buffer.

5. A ValueStringBuilder-style type

public ref struct ValueStringBuilder
{
    private char[]? _rented;
    private Span<char> _buffer;
    private int _length;

    public ValueStringBuilder(Span<char> initialBuffer)
    {
        _rented = null;
        _buffer = initialBuffer;
        _length = 0;
    }

    public void Append(char c)
    {
        if (_length == _buffer.Length) Grow(1);
        _buffer[_length++] = c;
    }

    public void Append(scoped ReadOnlySpan<char> s)
    {
        if (_length + s.Length > _buffer.Length) Grow(s.Length);
        s.CopyTo(_buffer[_length..]);
        _length += s.Length;
    }

    public void Append<T>(T value, ReadOnlySpan<char> format = default) where T : ISpanFormattable
    {
        int written;
        while (!value.TryFormat(_buffer[_length..], out written, format, CultureInfo.InvariantCulture))
            Grow(16);
        _length += written;
    }

    private void Grow(int additional)
    {
        var newArray = ArrayPool<char>.Shared.Rent(Math.Max(_buffer.Length * 2, _length + additional));
        _buffer[.._length].CopyTo(newArray);
        var toReturn = _rented;
        _buffer = _rented = newArray;
        if (toReturn is not null) ArrayPool<char>.Shared.Return(toReturn);
    }

    public override string ToString()
    {
        var result = new string(_buffer[.._length]);
        Dispose();
        return result;
    }

    public void Dispose()
    {
        var toReturn = _rented;
        this = default;                                   // prevent double return / use after dispose
        if (toReturn is not null) ArrayPool<char>.Shared.Return(toReturn);
    }
}

// Usage: starts on the stack, grows into pooled arrays only if needed
var sb = new ValueStringBuilder(stackalloc char[256]);
sb.Append("Order "); sb.Append(orderId); sb.Append(" total "); sb.Append(total, "C2");
string text = sb.ToString();

Why it's a ref struct: it holds a Span<char> that may point to stack memory (stackalloc), so it must not escape. The BCL uses an internal ValueStringBuilder like this for zero-allocation formatting; DefaultInterpolatedStringHandler is a public ref struct built on the same idea.

6. When I'd write one

  • Low-level buffer builders/readers (parsers, formatters, protocol encoders).
  • Wrappers over spans that add domain-specific reading/writing (SequenceReader-style).
  • Interpolated string handlers (must be ref structs).
  • Scoped helpers that must not escape (e.g. a struct holding a lock or pooled resource, disposed in the same frame).

Not for business logic, async workflows, or anything that needs to be stored or passed through interfaces.

7. A production example

A high-volume CSV export built each line with StringBuilder and ToString() per row. We introduced a ValueStringBuilder starting from stackalloc char[512], formatting numbers with TryFormat and writing the resulting span directly to a StreamWriter (no intermediate string). Allocations per row dropped to zero for typical rows, and the rare long rows fell back to pooled arrays. We added tests for double-dispose and growth paths because bugs in such types corrupt data silently.

🔍 What the interviewer is checking

The interviewer checks deep knowledge of stack-only types and recent language changes.

Green flags

  • States ref struct rules and why they exist.
  • Knows ref fields (C# 11) and C# 13 interfaces, allows ref struct and async/iterator relaxations.
  • Writes a stack-first builder with pooled growth and disposal.
  • Mentions the copy and ownership pitfall.

Red flags

  • Thinks ref struct means "struct passed by ref".
  • Tries to box or store ref structs.
  • Writes ref structs without any measured need.

📚 Learn in depth

1. Why stack-only

A ref struct may contain managed pointers to stack memory. The GC tracks managed pointers only in stack frames/registers (and within other ref structs on the stack), not in heap objects. Placing such a struct on the heap (boxing, class fields, closures, async state machines) could create pointers that outlive their targets — the compiler forbids every such path.

2. Ref safety for ref structs

The compiler tracks a safe-context for each ref struct value: whether it may reference current-method stack memory. Rules prevent:

  • returning a span over stackalloc memory,
  • storing a stack-referring span into a ref struct parameter that outlives the method,
  • assigning across scopes incorrectly.

scoped parameters (C# 11) and [UnscopedRef] fine-tune these rules. In ValueStringBuilder.Append(scoped ReadOnlySpan<char> s), scoped tells the compiler s won't be stored in the builder.

3. Interfaces and allows ref struct

public static void Use<T>(T value) where T : IFormattable, allows ref struct => value.ToString(null, null);

Inside, T can't be boxed, stored in fields, captured, etc. The constraint enables generic code like Func<ReadOnlySpan<char>, int> in .NET 9.

4. Interpolated string handlers

Custom handlers ([InterpolatedStringHandler] public ref struct MyHandler) receive literal/formatted parts and write into a buffer without allocating — they must be ref structs so they can hold spans over stack buffers.

5. Testing ref structs

  • Test growth boundaries (exactly full buffer, one more char).
  • Test Dispose idempotency and use-after-dispose behaviour.
  • Run with pooled buffer validation (custom pool that poisons returned arrays) to catch use-after-return.

Try it yourself

  1. Try storing a Span<T> in a class field and capturing a ref struct in a lambda; read the errors.
  2. Implement the ValueStringBuilder and benchmark against StringBuilder with [MemoryDiagnoser].
  3. Write a generic method with allows ref struct and call it with ReadOnlySpan<char>.
  4. Use a ref struct with a Dispose method in a using declaration.

💻 Code

using System.Buffers;

public interface ITextSink
{
    void Append(scoped ReadOnlySpan<char> text);   // scoped: callers may pass stack memory
}

// Stack-first string builder: C# 13 lets a ref struct implement an interface
public ref struct ValueStringBuilder : ITextSink
{
    private Span<char> _buffer;
    private char[]? _rented;
    private int _length;

    public ValueStringBuilder(Span<char> initialBuffer)
    {
        _buffer = initialBuffer;
        _rented = null;
        _length = 0;
    }

    public readonly int Length => _length;

    // scoped: the text is copied, never stored, so stackalloc arguments are allowed
    public void Append(scoped ReadOnlySpan<char> text)
    {
        if (_length + text.Length > _buffer.Length) Grow(text.Length);
        text.CopyTo(_buffer[_length..]);
        _length += text.Length;
    }

    public void Append(int value)
    {
        Span<char> digits = stackalloc char[11];
        value.TryFormat(digits, out int written);
        Append(digits[..written]);
    }

    private void Grow(int needed)
    {
        char[] next = ArrayPool<char>.Shared.Rent(Math.Max(_buffer.Length * 2, _length + needed));
        _buffer[.._length].CopyTo(next);
        if (_rented is not null) ArrayPool<char>.Shared.Return(_rented);
        _rented = next;
        _buffer = next;
    }

    public override string ToString()
    {
        string result = _buffer[.._length].ToString();
        Dispose();
        return result;
    }

    public void Dispose()                        // pattern-based: usable with 'using' without IDisposable
    {
        if (_rented is not null)
        {
            ArrayPool<char>.Shared.Return(_rented);
            _rented = null;
        }
        _buffer = default;
        _length = 0;
    }
}

public static class Csv
{
    // C# 13: generic code that accepts ref structs without boxing
    public static void WriteRow<TSink>(ref TSink sink, scoped ReadOnlySpan<string> fields)
        where TSink : ITextSink, allows ref struct
    {
        for (int i = 0; i < fields.Length; i++)
        {
            if (i > 0) sink.Append(",");
            sink.Append(fields[i]);
        }
    }
}

public static class Program
{
    public static void Main()
    {
        var builder = new ValueStringBuilder(stackalloc char[16]);  // starts on the stack
        Csv.WriteRow(ref builder, ["Mia", "Sydney", "NSW"]);
        builder.Append(",");
        builder.Append(2000);
        builder.Append(", a much longer trailing field that forces a rented buffer");
        Console.WriteLine(builder.Length);
        Console.WriteLine(builder.ToString());                     // returns any rented array
    }
}

⚠ Common mistakes

1. Writing ref structs for business logic

What happens: domain types as ref structs "for performance". Why it is wrong: severe restrictions (no async, no storage) for little benefit. Do instead: reserve ref structs for low-level buffer types.

2. Double-returning pooled buffers

What happens: Dispose() called twice or ToString() + Dispose() both return the buffer. Why it is wrong: the array enters the pool twice — corruption. Do instead: reset state (this = default) after returning.

3. Using after dispose

What happens: appending after the buffer was returned. Why it is wrong: writes into memory owned by someone else. Do instead: clear spans on dispose and guard usage.

4. Large stackalloc initial buffers

What happens: stackalloc char[64_000]. Why it is wrong: stack overflow risk. Do instead: small initial buffers, pooled growth.

5. Forgetting scoped on span parameters

What happens: compiler errors or forcing callers to heap allocate. Why it is wrong: ref safety rules can't prove non-escape. Do instead: mark non-escaping parameters scoped.

6. Passing ref structs by value repeatedly

What happens: copying a builder struct into helper methods. Why it is wrong: changes to the copy are lost (length not updated). Do instead: pass by ref.

7. Expecting interface conversions

What happens: casting a ref struct to IDisposable. Why it is wrong: boxing is forbidden. Do instead: use pattern-based using or generics with allows ref struct.

8. Culture-dependent formatting in builders

What happens: TryFormat with current culture for machine output. Why it is wrong: inconsistent output across servers. Do instead: pass CultureInfo.InvariantCulture for data formats.

🏛 Senior insight: trade-offs

Ref structs are the foundation of allocation-free .NET: Span<T>, Utf8JsonReader, SequenceReader<T> and interpolated string handlers are all ref structs. Writing your own is a library-level skill.

Situation Choice
Hot builder or parser over spans custom ref struct
General string building StringBuilder or string.Create
Generic algorithms that should accept spans allows ref struct constraints (C# 13)
State needed across await class or Memory<T>-based type

The senior judgement is about ownership and copying: a ref struct holding a rented array has value semantics but owns a resource, which is an unusual combination. Document it, pass it by ref, and test disposal paths.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate defines a ref struct. Why: Checks ref struct and struct fundamentals.
    How to answer

    A struct is a value type: a variable holds the data itself, assignment and parameter passing copy it, and it is stored inline (in a local, a field or an array element) rather than as a separate heap object. Structs cannot inherit (they are sealed) but can implement interfaces. default(T) always gives all-zero fields, even when you write a parameterless constructor (allowed since C# 10). readonly struct guarantees immutability and avoids defensive copies. ref struct (like Span<T>) can live only on the stack; since C# 13 it can implement interfaces and be used with allows ref struct generics. Converting a struct to object or an interface boxes it. Mutable structs cause lost updates because you often change a copy.

  2. When: Candidate mentions spans inside the type. Why: Span<T> is the canonical ref struct.
    How to answer

    Span<T> is a ref struct holding a reference and a length: a typed, bounds-checked view over contiguous memory, whether that memory is an array, a stackalloc buffer, native memory or part of a string (through ReadOnlySpan<char>). Slicing (span[2..6], Slice) creates a new view without copying, so parsers and formatters can work on pieces of a buffer with no allocations. Because it can point at the stack, it is stack-only: it cannot be a field of a class, be boxed, be captured by a lambda, or live across await/yield (C# 13 allows span locals in async and iterator methods as long as they do not cross those points). Helpers in MemoryExtensions (IndexOf, SequenceEqual, Sort, Trim) are vectorised. CollectionsMarshal.AsSpan(list) exposes a list's backing array, but adding to the list invalidates it.

  3. When: Candidate mentions the allows ref struct anti-constraint. Why: Probes generic constraints including allows ref struct.
    How to answer

    Constraints tell the compiler what T can do so you can use it safely. C# has class and class? for reference types, struct for non-nullable value types, unmanaged for blittable structs, notnull, new() for a public parameterless constructor, base-class and interface constraints, where T : U, and Enum and Delegate constraints. C# 13 added the allows ref struct anti-constraint so T can be a Span<T>-like type. Interface constraints on struct T also avoid boxing because the JIT emits constrained calls.

🛠 Real-world problems: how you handle them

Per-row StringBuilder allocations in a CSV export
Situation
A reporting service exported [3 million] rows nightly and created a new StringBuilder per row to build CSV lines.
Signals
Gen 0 collections ran [hundreds] of times per second during exports and the job took [25] minutes.
Investigation
An allocation profile showed StringBuilder and its chunk arrays as the top allocations, ahead of the row strings themselves.
Resolution
Introduced a ValueStringBuilder-style ref struct with a 512-char stackalloc buffer and pooled growth, writing each row directly to a StreamWriter.
Prevention
Benchmarks in CI and a code comment documenting that the builder must be passed by ref and disposed.

How to tell it (STAR)

**Situation:** our [nightly CSV export] took [25] minutes and hammered the GC. **Task:** I wanted to speed it up without changing the output format. **Action:** profiling showed a StringBuilder per row, so I wrote a small ref struct builder that starts with a stack buffer and only rents from ArrayPool for long rows, and added benchmarks and tests comparing output byte for byte. **Result:** allocations per row fell by [about 80%], the job dropped to [11] minutes, and the GC graph went almost flat.

↩ Usually asked after

More in Memory-Oriented C#

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me