Advanced C# A 01 11 features

Types and Memory

Where does a value live, and who gets a copy? Answer that for every type you write, and most C# performance and bug questions answer themselves.

This page is for a reader who already writes basic C#. It covers what the runtime really does with your types. Each feature has the same shape. You get what it is and when to use it. Then a tested sample, the traps, and interview questions. The tag next to each title says which C# version added the feature.

Contents

  1. Value vs reference types
  2. Boxing
  3. Struct design
  4. ref, out, in, ref readonly
  5. ref returns and ref locals
  6. ref struct and scoped
  7. Span, ReadOnlySpan, Memory
  8. stackalloc
  9. Inline arrays
  10. Implicit span conversions
  11. Equality
  12. Recap
The one rule. A value type variable holds its data. A reference type variable holds an address of data on the heap. Assignment copies whatever the variable holds. Everything on this page follows from that.

1. Value vs reference types C# 1.0

What it is

Structs, enums, and the built-in numbers are value types. Classes, records, arrays, strings, and delegates are reference types. A value type stores its fields inline, right where the variable is. A reference type stores a pointer to an object on the managed heap.

The phrase “value types live on the stack” is only half true. A value type lives wherever its container lives. A local int sits in the stack frame or a register. An int field of a class sits inside that object on the heap. An array of structs stores every struct inline in one heap block.

Stack frame of Demo() Managed heap PointS a X = 1 Y = 2 PointC c address header MT ptr X = 1 Y = 2 PointS[] arr address hdr+len (1, 2) (3, 4) (5, 6) structs stored inline, one block PointC[] objs address hdr+len ptr ptr ptr each ptr leads to its own object elsewhere on the heap
Figure A1.1 — A struct sits inside its container. A class variable holds only an address.

Reading the figure. Green boxes are struct data stored inline. Blue boxes are addresses or fields of a heap object. Amber boxes are runtime overhead: the object header and the method table pointer. That overhead is 16 bytes on 64-bit. Notice the struct array is one dense block. The class array is a block of pointers, so walking it jumps around memory.

Why and when

Sample

/// <summary>A small value type. Copying it copies both fields.</summary>
public struct PointS
{
    public int X;
    public int Y;
}

/// <summary>The same shape as a class. Copying it copies only the address.</summary>
public sealed class PointC
{
    public int X;
    public int Y;
}

public static class ValueVsReference
{
    /// <summary>Shows that assignment copies a struct but shares a class instance.</summary>
    /// <returns>The X seen through each original after writing through the copy.</returns>
    /// <example>ValueVsReference.Demo() returns "struct=1 class=99".</example>
    public static string Demo()
    {
        // Struct: b is a full copy, so writing b.X leaves a alone.
        var a = new PointS { X = 1 };   // 1 is a start value to compare against later
        var b = a;
        b.X = 99;                       // 99 is a marker that must not leak into a
        // Class: d holds the same address as c, so the write shows through c.
        var c = new PointC { X = 1 };   // same start value as the struct case
        var d = c;
        d.X = 99;                       // same marker, and this time it does leak
        return $"struct={a.X} class={c.X}";
    }

    /// <summary>Writes into a struct inside an array and inside a List.</summary>
    /// <returns>Both X values after the update, as "array=5 list=5".</returns>
    /// <example>ValueVsReference.ArrayVsList() returns "array=5 list=5".</example>
    public static string ArrayVsList()
    {
        // An array element is a storage slot, so arr[0].X writes in place.
        var arr = new PointS[3];        // 3 slots, all zeroed by the runtime
        arr[0].X = 5;                   // 5 is an arbitrary new value, slot 0 is the first
        // A List indexer returns a copy. list[0].X = 5 would not even compile (CS1612).
        var list = new List<PointS> { new() };
        var tmp = list[0];              // copy out of slot 0
        tmp.X = 5;                      // change the copy
        list[0] = tmp;                  // write the copy back
        return $"array={arr[0].X} list={list[0].X}";
    }
}

Pitfalls

Interview questions

Say this out loud: “A value type lives inline in its container. A reference type variable holds an address. Assignment copies what the variable holds.”

2. Boxing C# 1.0

What it is

Boxing turns a value type into an object. The runtime allocates a heap object and copies the value into it. Unboxing copies the value back out. It happens when a value type is converted to object, to an interface it implements, or to System.ValueType.

object boxed = n; int n 1 object boxed address header MT: Int32 1 24 bytes on the heap for a 4-byte int After n = 2, the local reads 2 but the box still holds its own copy, 1.
Figure A1.2 — Boxing allocates and copies. The box and the local are separate from then on.

Reading the figure. The green cell is the int value. Boxing makes a second green cell inside a new heap object with amber overhead. Notice the box is a copy, not a view. Changing n later does not touch it.

Why and when

Sample

using System.Collections;

/// <summary>A counter interface, used to show boxing through an interface.</summary>
public interface ICounter
{
    int Next();
}

/// <summary>A mutable struct counter. Mutable structs plus interfaces cause surprises.</summary>
public struct StructCounter : ICounter
{
    private int _n;

    /// <summary>Adds one and returns the new count.</summary>
    /// <returns>The count after the step.</returns>
    /// <example>new StructCounter().Next() returns 1.</example>
    public int Next() => ++_n;
}

public static class Boxing
{
    /// <summary>Boxing copies the value into a new heap object.</summary>
    /// <returns>The local and the boxed values after the local changes.</returns>
    /// <example>Boxing.CopyDemo() returns "local=2 boxed=1".</example>
    public static string CopyDemo()
    {
        int n = 1;                      // 1 is the value that gets boxed
        object boxed = n;               // box: allocate an object and copy 1 into it
        n = 2;                          // 2 changes only the local, not the box
        return $"local={n} boxed={(int)boxed}";   // the cast unboxes a copy
    }

    /// <summary>Calls Next on a struct directly and through a boxed interface copy.</summary>
    /// <returns>The two counts, which differ because they are separate copies.</returns>
    /// <example>Boxing.InterfaceDemo() returns "local=2 boxed=3".</example>
    public static string InterfaceDemo()
    {
        var s = new StructCounter();
        ICounter boxed = s;             // box: the heap now holds a copy of s
        boxed.Next();                   // bumps the box to 1
        boxed.Next();                   // bumps the box to 2
        s.Next();                       // bumps the local to 1, the box is not involved
        return $"local={s.Next()} boxed={boxed.Next()}";
    }

    /// <summary>Calls Next through a constrained generic. No box, the original changes.</summary>
    /// <param name="counter">The struct to step, passed by reference.</param>
    /// <returns>The new count.</returns>
    /// <example>var c = new StructCounter(); Boxing.Step(ref c) returns 1.</example>
    public static int Step<T>(ref T counter) where T : ICounter => counter.Next();

    /// <summary>Compares heap bytes for summing through ArrayList versus List of int.</summary>
    /// <param name="n">How many ints to add.</param>
    /// <returns>True when the ArrayList version allocated more bytes.</returns>
    /// <example>Boxing.ArrayListAllocatesMore(1000) returns true.</example>
    public static bool ArrayListAllocatesMore(int n)
    {
        // Warm up both paths once so JIT work is not counted below.
        SumBoxed(n);
        SumGeneric(n);
        long before = GC.GetAllocatedBytesForCurrentThread();
        SumBoxed(n);
        long boxedBytes = GC.GetAllocatedBytesForCurrentThread() - before;
        before = GC.GetAllocatedBytesForCurrentThread();
        SumGeneric(n);
        long genericBytes = GC.GetAllocatedBytesForCurrentThread() - before;
        return boxedBytes > genericBytes;
    }

    // Each Add(i) boxes i, so this allocates one object per element.
    private static long SumBoxed(int n)
    {
        var list = new ArrayList(n);    // capacity n so growth does not muddy the count
        for (int i = 0; i < n; i++) list.Add(i);
        long total = 0;                 // 0 is the empty sum
        foreach (object o in list) total += (int)o;
        return total;
    }

    // List<int> stores ints inline in its array, so there are no boxes.
    private static long SumGeneric(int n)
    {
        var list = new List<int>(n);    // capacity n for the same reason as above
        for (int i = 0; i < n; i++) list.Add(i);
        long total = 0;                 // 0 is the empty sum
        foreach (int v in list) total += v;
        return total;
    }
}

Pitfalls

Interview questions

3. Struct design: readonly struct, record struct, defensive copies C# 7.2 / C# 10

What it is

Why and when

Sample

/// <summary>An immutable amount of money, stored in whole cents to avoid rounding.</summary>
public readonly struct Money(long cents, string currency)
{
    public long Cents { get; } = cents;
    public string Currency { get; } = currency;

    /// <summary>Adds two amounts in the same currency.</summary>
    /// <param name="other">The amount to add.</param>
    /// <returns>A new Money. Neither input changes.</returns>
    /// <example>new Money(150, "USD").Add(new Money(275, "USD")) is 4.25 USD.</example>
    public Money Add(Money other) => other.Currency == Currency
        ? new Money(Cents + other.Cents, Currency)
        : throw new ArgumentException("currency mismatch", nameof(other));

    // / 100 gives whole units because 100 cents make one unit.
    // % 100 gives the cents left over. D2 pads to two digits, so 5 prints as 05.
    public override string ToString() => $"{Cents / 100}.{Cents % 100:D2} {Currency}";
}

/// <summary>A record struct: value equality, ToString and with for free.</summary>
public readonly record struct Temperature(double Celsius)
{
    // 9 / 5 is the size ratio of one Fahrenheit degree to one Celsius degree.
    // 32 is the Fahrenheit reading at the freezing point of water.
    public double Fahrenheit => Celsius * 9 / 5 + 32;
}

/// <summary>A mutable struct, used only to show a defensive copy.</summary>
public struct MutableTally
{
    public int Value;

    /// <summary>Adds one to Value.</summary>
    /// <example>t.Increment() moves Value from 0 to 1.</example>
    public void Increment() => Value++;
}

/// <summary>Holds one readonly and one normal field of the same mutable struct.</summary>
public sealed class DefensiveCopyDemo
{
    private readonly MutableTally _readOnly;
    private MutableTally _normal;

    /// <summary>Calls Increment on both fields once.</summary>
    /// <returns>Both values. The readonly one stays 0 because a copy was changed.</returns>
    /// <example>new DefensiveCopyDemo().Run() returns "readonly=0 normal=1".</example>
    public string Run()
    {
        // The compiler cannot let Increment change a readonly field.
        // So it copies _readOnly to a temp and increments the temp.
        _readOnly.Increment();
        // A normal field is changed in place.
        _normal.Increment();
        return $"readonly={_readOnly.Value} normal={_normal.Value}";
    }
}

public static class StructDesign
{
    /// <summary>Shows record struct equality and a with expression.</summary>
    /// <returns>Equality result, the changed copy, and the untouched original.</returns>
    /// <example>StructDesign.RecordDemo() returns "True 30 20".</example>
    public static string RecordDemo()
    {
        var a = new Temperature(20);    // 20 C is a sample room temperature
        var b = new Temperature(20);    // the same value, built separately
        var warmer = a with { Celsius = 30 };   // 30 is a new sample value for the copy
        return $"{a == b} {warmer.Celsius} {a.Celsius}";
    }
}
_readOnly.Increment(); field _readOnly Value 0 copy Value 0 ++ Value 1 discarded hidden temp Afterward the field still reads 0. A readonly struct makes the copy unnecessary, because the compiler knows no member can change it.
Figure A1.3 — A defensive copy. The method runs on a temp, so the change is lost.

Reading the figure. Blue is the real field. Amber is the hidden temp the compiler makes. Red is the changed temp, which nobody reads. Notice no warning appears. The bug is silent, and it also costs a copy on every call.

Pitfalls

Interview questions

4. ref, out, in, ref readonly parameters C# 1.0 / 7.2 / 12

What it is

By default C# passes arguments by value. The method gets a copy of the variable, which for a class is a copy of the address. The modifiers pass the variable itself by reference.

Why and when

Sample

/// <summary>A 24-byte readonly struct. Big enough that passing by reference can help.</summary>
public readonly record struct Vector3D(double X, double Y, double Z);

public static class ParamKinds
{
    /// <summary>Swaps two variables owned by the caller.</summary>
    /// <param name="a">First variable, passed by reference.</param>
    /// <param name="b">Second variable, passed by reference.</param>
    /// <example>int x = 1, y = 2; ParamKinds.Swap(ref x, ref y) leaves x = 2, y = 1.</example>
    public static void Swap<T>(ref T a, ref T b) => (a, b) = (b, a);

    /// <summary>Parses "x,y" into a point. The TryXxx pattern.</summary>
    /// <param name="text">Text such as "3,4".</param>
    /// <param name="point">The parsed point, or default when parsing fails.</param>
    /// <returns>True on success.</returns>
    /// <example>ParamKinds.TryParsePoint("3,4", out var p) returns true with p.X = 3.</example>
    public static bool TryParsePoint(string text, out PointS point)
    {
        point = default;                // out must be assigned on every path
        var parts = text.Split(',');
        // 2 because a point has exactly two coordinates.
        if (parts.Length != 2) return false;
        // [0] is the x part and [1] is the y part.
        if (!int.TryParse(parts[0], out int x) || !int.TryParse(parts[1], out int y)) return false;
        point = new PointS { X = x, Y = y };
        return true;
    }

    /// <summary>Length of a vector, read through an in reference. No copy is made.</summary>
    /// <param name="v">The vector. The method cannot change it.</param>
    /// <returns>The Euclidean length.</returns>
    /// <example>ParamKinds.Length(new Vector3D(2, 3, 6)) returns 7.</example>
    public static double Length(in Vector3D v) => Math.Sqrt(v.X * v.X + v.Y * v.Y + v.Z * v.Z);

    /// <summary>Checks whether two references point at the same storage.</summary>
    /// <param name="a">First variable. Must be a real variable, not a temporary.</param>
    /// <param name="b">Second variable.</param>
    /// <returns>True when both refer to the same slot.</returns>
    /// <example>ParamKinds.SameSlot(in arr[0], in arr[0]) returns true.</example>
    public static bool SameSlot(ref readonly int a, ref readonly int b) =>
        System.Runtime.CompilerServices.Unsafe.AreSame(in a, in b);

    /// <summary>Runs each modifier once.</summary>
    /// <returns>A summary string of every result.</returns>
    /// <example>ParamKinds.Demo() returns "2,1 True 3 7 True False".</example>
    public static string Demo()
    {
        int x = 1, y = 2;               // 1 and 2 so the swap is visible
        Swap(ref x, ref y);
        bool ok = TryParsePoint("3,4", out var p);
        // 2, 3, 6 form a Pythagorean quadruple, so the length is exactly 7.
        double len = Length(new Vector3D(2, 3, 6));
        int[] arr = [10, 20];           // two slots with different addresses
        bool same = SameSlot(in arr[0], in arr[0]);   // [0] twice: the same slot
        bool diff = SameSlot(in arr[0], in arr[1]);   // [0] and [1]: different slots
        return $"{x},{y} {ok} {p.X} {len} {same} {diff}";
    }
}

Pitfalls

Interview questions

5. ref returns and ref locals C# 7.0

What it is

A method can return a reference to storage instead of a value: ref int Find(...). A ref local (ref int slot = ref ...) is an alias for that storage. Writing through it writes the original. C# 7.3 lets you re-point a ref local with slot = ref other.

Why and when

Sample

using System.Runtime.InteropServices;

public static class RefReturns
{
    /// <summary>Returns a reference to the largest element, so the caller can change it.</summary>
    /// <param name="items">A non-empty array.</param>
    /// <returns>A ref to the slot holding the maximum. The first one wins ties.</returns>
    /// <example>ref int top = ref RefReturns.MaxRef(arr); top = 0; zeroes the max in arr.</example>
    public static ref int MaxRef(int[] items)
    {
        // 0 elements means there is no slot to return a ref to.
        if (items.Length == 0) throw new ArgumentException("empty array", nameof(items));
        // best starts at index 0, the first candidate.
        int best = 0;
        // i is the next index to test. Invariant: items[best] is the max of items[0..i-1].
        // Start at 1 because index 0 is already the current best.
        for (int i = 1; i < items.Length; i++)
        {
            if (items[i] > items[best]) best = i;
        }
        return ref items[best];
    }

    /// <summary>Zeroes the largest element in place through a ref local.</summary>
    /// <param name="items">A non-empty array. It is changed.</param>
    /// <returns>The same array, for easy checking.</returns>
    /// <example>RefReturns.ZeroTheMax([3, 9, 4]) returns [3, 0, 4].</example>
    public static int[] ZeroTheMax(int[] items)
    {
        ref int top = ref MaxRef(items);
        top = 0;                        // 0 is the new value, written into the array slot
        return items;
    }

    /// <summary>Counts words with one hash lookup each.</summary>
    /// <param name="words">The words to count.</param>
    /// <returns>A map from word to count.</returns>
    /// <example>RefReturns.CountWords(["a", "b", "a"])["a"] returns 2.</example>
    public static Dictionary<string, int> CountWords(string[] words)
    {
        var counts = new Dictionary<string, int>();
        // Each pass adds one to the slot for w, creating it as 0 if missing.
        foreach (var w in words)
        {
            ref int slot = ref CollectionsMarshal.GetValueRefOrAddDefault(counts, w, out _);
            slot++;                     // + 1 for this occurrence of w
        }
        return counts;
    }
}

Pitfalls

Interview questions

6. ref struct and scoped C# 7.2 / C# 11

What it is

A ref struct (C# 7.2) is a struct that may only live on the stack. Span<T> is the famous one. It never reaches the heap, so it may hold other stack-only things. That includes a span or, since C# 11, a ref field.

scoped (C# 11) on a parameter or local is a promise. It says the value will not escape the current method. The compiler then lets callers pass stack memory that would otherwise be rejected.

Why and when

Sample

/// <summary>Walks words in a span. A ref struct, so it may hold a span field.</summary>
public ref struct WordCursor
{
    private ReadOnlySpan<char> _rest;

    public WordCursor(ReadOnlySpan<char> text) => _rest = text;

    /// <summary>Gets the next space-separated word.</summary>
    /// <param name="word">The word, as a slice of the original text.</param>
    /// <returns>False when no words remain.</returns>
    /// <example>new WordCursor("hi there").TryNext(out var w) returns true, w is "hi".</example>
    public bool TryNext(out ReadOnlySpan<char> word)
    {
        _rest = _rest.TrimStart(' ');
        if (_rest.IsEmpty) { word = default; return false; }
        int end = _rest.IndexOf(' ');
        if (end < 0) end = _rest.Length;   // < 0 means -1, no space: the word runs to the end
        word = _rest[..end];
        _rest = _rest[end..];
        return true;
    }
}

/// <summary>Appends text into a caller-supplied buffer.</summary>
public ref struct SpanWriter
{
    private readonly Span<char> _dest;
    private int _pos;

    public SpanWriter(Span<char> dest)
    {
        _dest = dest;
        _pos = 0;                       // 0: nothing written yet
    }

    /// <summary>Copies text to the end of the buffer.</summary>
    /// <param name="text">Read only during this call. scoped promises it is not stored.</param>
    /// <example>w.Append("ab") then w.ToString() returns "ab".</example>
    public void Append(scoped ReadOnlySpan<char> text)
    {
        text.CopyTo(_dest[_pos..]);
        _pos += text.Length;
    }

    public override string ToString() => new string(_dest[.._pos]);
}

/// <summary>A ref struct with a ref field that points at a caller's int (C# 11).</summary>
public ref struct IntAlias
{
    private ref int _target;

    public IntAlias(ref int target) => _target = ref target;

    /// <summary>Adds one to the caller's variable.</summary>
    /// <example>int n = 0; new IntAlias(ref n).Bump(); leaves n = 1.</example>
    public void Bump() => _target++;
}

public static class RefStructs
{
    /// <summary>Counts words without allocating any substrings.</summary>
    /// <param name="text">The text to scan.</param>
    /// <returns>The number of words.</returns>
    /// <example>RefStructs.CountWords("  to be or  not ") returns 4.</example>
    public static int CountWords(string text)
    {
        var cursor = new WordCursor(text);
        int count = 0;                  // 0 words seen so far
        // Each pass consumes one word. Invariant: count = words consumed so far.
        while (cursor.TryNext(out _)) count++;
        return count;
    }

    /// <summary>Formats two numbers into one stack buffer.</summary>
    /// <returns>The text built in the buffer.</returns>
    /// <example>RefStructs.WriteNumbers() returns "42-7".</example>
    public static string WriteNumbers()
    {
        Span<char> buf = stackalloc char[32];   // 32 chars is plenty for two ints and a dash
        var w = new SpanWriter(buf);
        AppendNumber(ref w, 42);        // 42 and 7 are sample numbers
        w.Append("-");
        AppendNumber(ref w, 7);
        return w.ToString();
    }

    // tmp lives in this frame but w belongs to the caller.
    // Append's scoped parameter is what makes this call legal.
    private static void AppendNumber(ref SpanWriter w, int n)
    {
        Span<char> tmp = stackalloc char[11];   // 11 fits "-2147483648", the longest int
        n.TryFormat(tmp, out int len);
        w.Append(tmp[..len]);
    }

    /// <summary>Changes a local through a ref field.</summary>
    /// <returns>The local after two bumps.</returns>
    /// <example>RefStructs.AliasDemo() returns 2.</example>
    public static int AliasDemo()
    {
        int n = 0;                      // 0 is the start count
        var alias = new IntAlias(ref n);
        alias.Bump();
        alias.Bump();
        return n;
    }
}

Pitfalls

Interview questions

7. Span<T>, ReadOnlySpan<T>, Memory<T> C# 7.2 / .NET Core 2.1

What it is

A Span<T> is a view over contiguous memory: a reference plus a length. The memory can be an array, a stack buffer, a string, or native memory. Slicing a span makes a new view without copying. ReadOnlySpan<T> is the read-only version. string converts to ReadOnlySpan<char>.

Memory<T> is the heap-friendly cousin. It is a normal struct, so it can be a class field or cross an await. Call .Span when you need to touch the data.

A span is (ref, length) What it points at ref len 3 7 1 4 8 2 int[] on heap ref len 4 4 chars, stackalloc stack frame ref len 2 h e l l o string chars
Figure A1.4 — One type, three kinds of memory. Slicing moves the ref and shrinks the length.

Reading the figure. Violet boxes are the span itself, two fields on the stack. Green cells are the elements the span covers. Grey cells belong to the same block but sit outside the slice. The top span is arr.AsSpan(2, 3). The bottom is "hello".AsSpan(1, 2). Notice nothing was copied.

Why and when

Sample

public static class SpanParsing
{
    /// <summary>Sums comma-separated ints without making any substrings.</summary>
    /// <param name="text">Text such as "1, 2,3". Spaces around numbers are fine.</param>
    /// <returns>The total as a long, so many big ints do not overflow.</returns>
    /// <example>SpanParsing.SumCsv("1, 2,3") returns 6.</example>
    public static long SumCsv(ReadOnlySpan<char> text)
    {
        long total = 0;                 // 0 is the empty sum
        // Split yields a Range per field (.NET 9). text[r] is a slice, not a copy.
        foreach (Range r in text.Split(','))
        {
            total += int.Parse(text[r]);
        }
        return total;
    }

    /// <summary>Reverses part of an array in place through a slice.</summary>
    /// <param name="items">The array. It is changed.</param>
    /// <param name="start">First index of the part.</param>
    /// <param name="length">How many items the part has.</param>
    /// <returns>The same array.</returns>
    /// <example>SpanParsing.ReverseRange([1, 2, 3, 4, 5], 1, 3) returns [1, 4, 3, 2, 5].</example>
    public static int[] ReverseRange(int[] items, int start, int length)
    {
        items.AsSpan(start, length).Reverse();
        return items;
    }
}

/// <summary>Holds a Memory slice in a field and reads it after an await.</summary>
public sealed class ChunkSummer(Memory<int> data)
{
    /// <summary>Sums the data in chunks, yielding between chunks.</summary>
    /// <param name="chunk">Items per chunk. Must be positive.</param>
    /// <returns>The total.</returns>
    /// <example>await new ChunkSummer(new[] { 1, 2, 3 }).SumAsync(2) returns 6.</example>
    public async Task<long> SumAsync(int chunk)
    {
        long total = 0;                 // 0 is the empty sum
        // start is the first index of the next chunk. Invariant: total covers [0, start).
        for (int start = 0; start < data.Length; start += chunk)
        {
            await Task.Yield();         // a Span local could not live across this line
            int len = Math.Min(chunk, data.Length - start);   // the last chunk may be short
            foreach (int v in data.Span.Slice(start, len)) total += v;
        }
        return total;
    }
}

Pitfalls

Interview questions

8. stackalloc C# 7.2 for Span

What it is

stackalloc T[n] reserves n elements in the current stack frame. Since C# 7.2 it can be assigned to a Span<T> with no unsafe code. Since C# 8 it can appear inside other expressions, such as a ternary. The memory is freed when the method returns, with no GC work at all.

Why and when

Sample

using System.Buffers;

public static class StackBuffers
{
    // 256 chars is 512 bytes, tiny next to the 1 MB default stack.
    private const int StackLimit = 256;

    /// <summary>Reverses a string. Stack buffer when short, pooled array when long.</summary>
    /// <param name="s">The text to reverse.</param>
    /// <returns>The reversed text.</returns>
    /// <example>StackBuffers.Reverse("abc") returns "cba".</example>
    public static string Reverse(string s)
    {
        char[]? rented = null;
        // Short input: stack memory. Long input: rent from the shared pool.
        Span<char> buf = s.Length <= StackLimit
            ? stackalloc char[StackLimit]
            : (rented = ArrayPool<char>.Shared.Rent(s.Length));
        try
        {
            // Rent may return a bigger array, so slice to the exact length.
            var work = buf[..s.Length];
            s.AsSpan().CopyTo(work);
            work.Reverse();
            return new string(work);
        }
        finally
        {
            // Return the array so the pool can reuse it. Stack memory needs nothing.
            if (rented is not null) ArrayPool<char>.Shared.Return(rented);
        }
    }
}

Pitfalls

Interview questions

9. Inline arrays C# 12

What it is

An inline array is a struct marked [InlineArray(N)] with exactly one field. The runtime repeats that field N times inside the struct. You index it like an array, loop over it with foreach, and convert it to a span. No heap allocation happens, unlike a normal T[].

Why and when

Sample

using System.Runtime.CompilerServices;

/// <summary>Four T values stored inline. The runtime repeats _first 4 times.</summary>
[InlineArray(4)]                        // 4 is the fixed element count
public struct Four<T>
{
    private T _first;
}

/// <summary>Keeps the last four readings in a ring, with no heap array.</summary>
public struct LastFour
{
    private const int Size = 4;         // must match the InlineArray length above
    private Four<int> _items;
    private int _count;
    private int _next;

    /// <summary>Adds a reading, overwriting the oldest once full.</summary>
    /// <param name="value">The new reading.</param>
    /// <example>Adding 1..6 keeps 3, 4, 5, 6.</example>
    public void Add(int value)
    {
        _items[_next] = value;
        _next = (_next + 1) % Size;     // + 1 moves on, % Size wraps from 3 back to 0
        if (_count < Size) _count++;    // stop counting once all slots are full
    }

    /// <summary>Sums the readings currently held.</summary>
    /// <returns>The sum of up to four readings.</returns>
    /// <example>After adding 1..6, Sum() returns 18.</example>
    public readonly int Sum()
    {
        int total = 0;                  // 0 is the empty sum
        // i walks the filled slots. Invariant: total is the sum of slots [0, i).
        for (int i = 0; i < _count; i++) total += _items[i];
        return total;
    }

    /// <summary>Runs the ring with six readings.</summary>
    /// <returns>The sum of the last four.</returns>
    /// <example>LastFour.Demo() returns 18.</example>
    public static int Demo()
    {
        var ring = new LastFour();
        // 1..6: six readings, two more than fit, so 1 and 2 get overwritten.
        for (int v = 1; v <= 6; v++) ring.Add(v);
        return ring.Sum();
    }

    /// <summary>Shows that an inline array converts to a Span.</summary>
    /// <returns>The span length.</returns>
    /// <example>LastFour.AsSpanLength() returns 4.</example>
    public static int AsSpanLength()
    {
        var buf = new Four<string>();
        Span<string> span = buf;        // a span over the 4 inline slots
        span.Fill("x");                 // "x" is any placeholder text
        return span.Length;
    }
}

Pitfalls

Interview questions

10. Implicit span conversions C# 14

What it is

C# 14 makes spans first-class in the language. Arrays convert to Span<T> and ReadOnlySpan<T>. Span<T> converts to ReadOnlySpan<T>. Strings convert to ReadOnlySpan<char>. These conversions existed before as user-defined operators. Now the compiler knows them, so they also work in two new places:

It also allows covariance. A string[] converts to ReadOnlySpan<object>.

Why and when

Sample

public static class SpanHelpers
{
    /// <summary>Sums a read-only span of ints.</summary>
    /// <param name="values">Any ints: array, span, or slice.</param>
    /// <returns>The total.</returns>
    /// <example>new[] { 1, 2, 3 }.Total() returns 6 in C# 14.</example>
    public static int Total(this ReadOnlySpan<int> values)
    {
        int total = 0;                  // 0 is the empty sum
        foreach (int v in values) total += v;
        return total;
    }

    /// <summary>First item of a span, with T inferred from the argument.</summary>
    /// <param name="values">A non-empty span.</param>
    /// <returns>values[0].</returns>
    /// <example>SpanHelpers.First(new[] { 'a', 'b' }) returns 'a' in C# 14.</example>
    public static T First<T>(ReadOnlySpan<T> values) => values[0];   // [0] is the first slot
}

public static class FirstClassSpans
{
    /// <summary>Uses all three new conversion spots.</summary>
    /// <returns>Results from extension, inference, and covariance.</returns>
    /// <example>FirstClassSpans.Demo() returns "6 9 2".</example>
    public static string Demo()
    {
        int[] data = [1, 2, 3];
        // Array to ReadOnlySpan as an extension receiver. Before C# 14: error CS1929.
        int total = data.Total();
        // T inferred as int through the array-to-span conversion.
        int first = SpanHelpers.First(new[] { 9, 8 });   // 9 and 8 are sample values
        // Covariant: string[] to ReadOnlySpan<object>.
        ReadOnlySpan<object> objects = new[] { "a", "b" };
        return $"{total} {first} {objects.Length}";
    }
}

Pitfalls

Interview questions

11. Equality C# 1.0 / records C# 9

What it is

Why and when

Sample

/// <summary>No equality override: == and Equals compare references.</summary>
public sealed class PlainUser(string name)
{
    public string Name { get; } = name;
}

/// <summary>Hand-written value equality, the full correct set.</summary>
public sealed class EqUser(string name) : IEquatable<EqUser>
{
    public string Name { get; } = name;

    /// <summary>Typed Equals. No cast, no boxing.</summary>
    /// <param name="other">The user to compare with. May be null.</param>
    /// <returns>True when names match.</returns>
    /// <example>new EqUser("a").Equals(new EqUser("a")) returns true.</example>
    public bool Equals(EqUser? other) => other is not null && Name == other.Name;

    public override bool Equals(object? obj) => Equals(obj as EqUser);

    // Equal objects must give equal hashes, so hash exactly the fields Equals uses.
    public override int GetHashCode() => Name.GetHashCode();

    public static bool operator ==(EqUser? a, EqUser? b) => a is null ? b is null : a.Equals(b);
    public static bool operator !=(EqUser? a, EqUser? b) => !(a == b);
}

/// <summary>A record: the compiler writes all of the above.</summary>
public sealed record RecUser(string Name);

/// <summary>A record with an array member, to show a classic trap.</summary>
public sealed record Tagged(string Name, int[] Tags);

public static class EqualityDemo
{
    /// <summary>Compares two separately built users of each kind with ==.</summary>
    /// <returns>[plain, hand-written, record] equality results.</returns>
    /// <example>EqualityDemo.Compare() returns [false, true, true].</example>
    public static List<bool> Compare() =>
    [
        new PlainUser("ann") == new PlainUser("ann"),
        new EqUser("ann") == new EqUser("ann"),
        new RecUser("ann") == new RecUser("ann"),
    ];

    /// <summary>Adds two equal-looking users of each kind to a HashSet.</summary>
    /// <returns>[plain set size, record set size].</returns>
    /// <example>EqualityDemo.SetSizes() returns [2, 1].</example>
    public static List<int> SetSizes()
    {
        var plain = new HashSet<PlainUser> { new("ann"), new("ann") };
        var records = new HashSet<RecUser> { new("ann"), new("ann") };
        return [plain.Count, records.Count];
    }

    /// <summary>Records compare array members by reference, not by contents.</summary>
    /// <returns>False, even though both arrays hold the same numbers.</returns>
    /// <example>EqualityDemo.ArrayMemberTrap() returns false.</example>
    public static bool ArrayMemberTrap() =>
        new Tagged("x", [1, 2]) == new Tagged("x", [1, 2]);   // [1, 2] built twice

    /// <summary>Casting to object switches == to reference equality.</summary>
    /// <returns>False, because two different string objects are compared.</returns>
    /// <example>EqualityDemo.ObjectCastTrap() returns false.</example>
    public static bool ObjectCastTrap()
    {
        // new string(...) forces two separate objects with the same text.
        object a = new string('z', 3);  // 3 copies of 'z' gives "zzz"
        object b = new string('z', 3);
        return a == b;                  // object == is reference equality
    }
}

Pitfalls

Interview questions

Say this out loud: “For a dictionary key I want a readonly record struct. It is immutable, has value equality, and implements IEquatable, so lookups never box.”

Recap

Things to carry forward

Where this goes next

The next page, Generics, builds on all of this. Constraints decide whether T can be a struct, a ref struct, or a number. Reified generics are why List<int> never boxes.


← 23 — Design: Hash Map plus Linked List A 02 — Generics →