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.
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.
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.
/// <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}";
}
}
in or make it a class.list[i] is a compile error. Use an array, or CollectionsMarshal.AsSpan(list).int field of a class stored? A: On the heap, inside the object. Value types live inline in whatever contains them.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.
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.
ArrayList, and object parameters. Another is calling a method through an interface variable that holds a struct.T is the real struct type at run time, so no box is needed.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;
}
}
IEquatable<T>. Dictionary falls back to Equals(object), which boxes on every lookup. See Equality.(long)(object)1 throws InvalidCastException. Unbox to int first, then widen.(long)(object)42 throw? A: Unboxing checks the exact boxed type. The box holds an Int32, so it must unbox to int.T is the real struct, so calls are direct.readonly struct (C# 7.2) makes every field read-only. The compiler checks it.readonly members (C# 8) mark one method or property as not changing the struct.record struct (C# 10) gives a struct value equality, ==, ToString, deconstruction, and with. Add readonly to make it immutable.readonly unless you have a measured reason not to. It removes defensive copies and the bugs from mutable structs.readonly record struct for small data keys. You get correct, fast equality with one line.readonly fields, in parameters, and ref readonly locals of non-readonly structs./// <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}";
}
}
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.
readonly field changes a copy. The compiler does not warn.in. Passing a non-readonly struct with in can copy it on every member call. That defeats the point of in.default skips your constructor. default(Money) has Currency == null, even though the type looks non-nullable. Arrays of structs start as default too.record struct is mutable. Its positional properties have setters. Write readonly record struct for an immutable one.record struct or record class? A: Struct for small values copied often with no identity. Class for larger data, inheritance, or when you need a null value.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.
ref (C# 1.0): read and write. The caller must assign it first.out (C# 1.0): the method must assign it before returning. Out variable declarations (out var x) came in C# 7.in (C# 7.2): read-only by reference. The caller may skip the keyword and may pass a temporary.ref readonly (C# 12): read-only by reference, but the caller must pass a real variable. The compiler warns on a temporary.ref for swaps and for changing a caller's struct in place.out for the TryXxx pattern: return success as bool and the result through out.in for large readonly struct arguments, to skip a copy.ref readonly when the method keeps or compares the address, so a temporary would be a bug./// <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}";
}
}
ref on a class parameter is rarely needed. Without it you can already change the object. With it you can also replace the caller's reference.in on a small struct is slower. Passing an int by reference adds an indirection. Use in for structs bigger than about 16 bytes.in on a mutable struct causes defensive copies.ref, out, or in parameters.ref and out? A: ref needs an assigned value going in. out does not, but the method must assign it before returning.ref readonly when in exists? A: in silently accepts temporaries. ref readonly warns, which matters when the method keeps or compares the address.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.
CollectionsMarshal.GetValueRefOrAddDefault gives a ref to a dictionary value. A word count then does one hash lookup per word instead of two.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;
}
}
List<T> slot (through CollectionsMarshal.AsSpan) goes stale when the list grows.ref on the call site, int top = MaxRef(arr), gives a plain copy. The compiler accepts it, so the write is lost.GetValueRefOrAddDefault faster for counting? A: counts[w]++ hashes twice, once to read and once to write. The ref version hashes once and writes through the ref.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.
scoped to a span parameter that you only read during the call. Without it, a caller with stack memory and a ref receiver gets error CS8350./// <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;
}
}
allows ref struct.await or yield. C# 13 allows ref struct locals in async methods and iterators, but not alive across an await or yield return.scoped from Append breaks AppendNumber with CS8350. The compiler fears Append could store tmp inside w.Span<T> not be a field of a class? A: A span may point at stack memory. A class lives on the heap and can outlive that stack frame, leaving a dangling pointer.scoped buy you? A: It narrows how far a value may escape. Callers can then pass stack memory to the method safely.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.
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.
Substring allocates, each slice does not.Memory<T> when the view must live in a field or survive an await.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;
}
}
List<T> from CollectionsMarshal.AsSpan breaks when the list grows. The list moves to a new array.Memory<T>.Span is not free. Read it once per chunk, not once per element.stackalloc buffer. The compiler stops you, but the reason is the frame dies.List<Span<int>> is illegal.Span<T> vs Memory<T>? A: Span is a stack-only ref struct and is fastest. Memory is a normal struct that can live on the heap and cross awaits. You get a Span from it when you need the data.text.AsSpan(1, 3) cheaper than text.Substring(1, 3)? A: Substring allocates a new string and copies. The span only stores a ref and a length.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.
ArrayPool for big ones.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);
}
}
}
stackalloc a size taken from user input without a cap.stackalloc in a loop. Stack memory is only freed when the method returns, so each pass grows the frame.SkipLocalsInit the buffer holds garbage. Write before you read.stackalloc everything? A: The stack is about 1 MB per thread and overflow is fatal. Heap and pooled arrays have no such cliff.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[].
unsafe fixed arrays. It works for any element type, including references.params spans use inline arrays under the hood.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;
}
}
int[4]? A: int[] is a heap object reached by a pointer. An inline array is four ints stored inside the containing struct, with no allocation.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:
array.MySpanExtension() now finds an extension on ReadOnlySpan<T>.First(array) now infers T for a First<T>(ReadOnlySpan<T>) method.It also allows covariance. A string[] converts to ReadOnlySpan<object>.
ReadOnlySpan<T>. Arrays, spans, and strings can all call them with dot syntax.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}";
}
}
array.Contains(x) may now bind to MemoryExtensions.Contains instead of Enumerable.Contains. Results match, but inside an expression tree it can fail to compile or run.object[] variable really holds a string[], converting it to Span<object> throws ArrayTypeMismatchException. ReadOnlySpan is safe.LangVersion below 14 keep the old rules. The sample above will not compile there.ReferenceEquals(a, b) always means this.Equals and GetHashCode, and by implementing IEquatable<T>.== on a class is reference equality unless the class defines operator ==. string does, so strings compare by content.== on a record (C# 9) is value equality. The compiler writes Equals, GetHashCode, ==, and != for you.ValueType.Equals, but no ==. That fallback can use reflection and boxing, so it is slow.Dictionary key or HashSet item needs consistent Equals and GetHashCode.record or record struct for data types. The generated code is correct and fast.IEquatable<T> on structs so collections skip boxing./// <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
}
}
Equals without GetHashCode and hash sets break. Equal items land in different buckets.Equals or use an immutable collection with value semantics.== picks the operator from static types. Two strings typed as object compare by reference. The compiler warns (CS0252) only in some cases.record inheritance includes the runtime type in equality, through EqualityContract. A base and a derived record with the same fields are not equal.Equals. Different hashes mean Equals is never even tried.Equals(object), typed Equals(T), GetHashCode, ==, !=, and an EqualityContract property so derived types compare correctly.readonly. It removes defensive copies and lost writes.ref, out, in, and ref readonly pass the variable, not a copy. Ref returns let a caller write into your storage.stackalloc, inline arrays, and C# 14 span conversions give zero-allocation code with plain syntax.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.