Structs & Ref Structs
Design efficient value types including stack-only ref struct types
Structs & Ref Structs in C#
Master the performance advantages of value types in C# with free flashcards and spaced repetition practice. This lesson covers structs, ref structs, stack allocation, and memory optimizationβessential concepts for writing high-performance C# applications that minimize garbage collection pressure and maximize throughput.
Welcome π
When building performance-critical applications in C#, understanding the difference between reference types (classes) and value types (structs) can mean the difference between milliseconds and microseconds of execution time. Structs live on the stack by default, avoiding heap allocations and garbage collection overhead. Ref structs take this even further, guaranteeing stack-only allocation for scenarios like high-performance parsing, span manipulation, and zero-allocation pipelines.
π‘ Did you know? The Span<T> and ReadOnlySpan<T> types that power modern high-performance C# are implemented as ref structs, enabling safe memory access without any heap allocations!
Core Concepts π
What Are Structs? π»
A struct (structure) is a value type in C# that typically lives on the stack rather than the heap. Unlike classes, structs are copied by value, not by reference.
Key characteristics:
- Value semantics: Assignment copies the entire struct
- Stack allocation: Default allocation is on the stack (unless boxed or part of a reference type)
- No inheritance: Structs cannot inherit from other structs or classes (but can implement interfaces)
- Implicit default constructor: Always has a parameterless constructor that zeros all fields
- Performance: Faster allocation/deallocation, but copying can be expensive for large structs
public struct Point
{
public int X;
public int Y;
public Point(int x, int y)
{
X = x;
Y = y;
}
}
// Value semantics in action
Point p1 = new Point(10, 20);
Point p2 = p1; // Copies entire struct
p2.X = 50;
Console.WriteLine(p1.X); // Still 10 - p1 is unchanged
When to Use Structs vs Classes π€
| Use Struct When... | Use Class When... |
|---|---|
| Type is small (β€16 bytes recommended) | Type is large or variable size |
| Logically represents a single value | Represents complex entity with identity |
| Immutable by design | Mutable state is needed |
| Short-lived (method scope) | Long-lived objects |
| Needs value semantics | Needs reference semantics |
| High allocation volume | Shared across many references |
β οΈ Common Mistake: Making large structs (>16 bytes) can hurt performance because every assignment or method call copies all the data!
Memory Layout: Stack vs Heap π
STACK (Fast, Limited) HEAP (Slower, Large) ββββββββββββββββββββββ ββββββββββββββββββββββ β Local variables β β Class instances β β Method params β β Boxed structs β β Struct values β β Arrays β β β β String objects β β β Grows down β β β β β β GC manages this β ββββββββββββββββββββββ ββββββββββββββββββββββ Automatic cleanup Must be collected No GC overhead GC pauses possible
Ref Structs: Stack-Only Types π
A ref struct is a special kind of struct that can only exist on the stack. It cannot be boxed, cannot be a field of a regular class or struct, and cannot be used in async methods or lambda expressions.
public ref struct SpanParser
{
private ReadOnlySpan<char> _data;
private int _position;
public SpanParser(ReadOnlySpan<char> data)
{
_data = data;
_position = 0;
}
public bool TryReadInt(out int value)
{
// High-performance parsing without allocations
value = 0;
// ... parsing logic
return true;
}
}
Ref struct restrictions:
- β Cannot be boxed to
objector interface - β Cannot be a field of a class or regular struct
- β Cannot implement interfaces
- β Cannot be used in async methods
- β Cannot be captured by lambdas or local functions
- β Cannot be used as a generic type argument
- β Can only live on the stack
- β Perfect for zero-allocation scenarios
π‘ Why the restrictions? These ensure ref structs never escape to the heap, maintaining their performance guarantees.
Readonly Structs: Immutability Guarantee π‘οΈ
The readonly struct modifier guarantees the struct is immutable, preventing accidental mutations and enabling compiler optimizations.
public readonly struct Vector3D
{
public readonly double X;
public readonly double Y;
public readonly double Z;
public Vector3D(double x, double y, double z)
{
X = x;
Y = y;
Z = z;
}
public double Magnitude => Math.Sqrt(X * X + Y * Y + Z * Z);
// Readonly structs can have methods that return new instances
public Vector3D Normalize()
{
double mag = Magnitude;
return new Vector3D(X / mag, Y / mag, Z / mag);
}
}
Benefits:
- Compiler enforces immutability at compile time
- No defensive copying when passing as
inparameters - Thread-safe by design
- Clearer intent in your code
The in Parameter Modifier: Pass by Readonly Reference π
Normally, passing a struct to a method copies the entire struct. The in modifier passes a readonly reference instead, avoiding the copy.
public readonly struct Matrix4x4
{
// 16 doubles = 128 bytes!
private readonly double m11, m12, m13, m14;
private readonly double m21, m22, m23, m24;
private readonly double m31, m32, m33, m34;
private readonly double m41, m42, m43, m44;
// Without 'in': copies 128 bytes on every call!
// With 'in': passes 8-byte reference
public static Matrix4x4 Multiply(in Matrix4x4 left, in Matrix4x4 right)
{
// Access left and right without copying
// ... multiplication logic
return new Matrix4x4(/* ... */);
}
}
β οΈ Performance tip: Use in for structs larger than 16 bytes, and always combine with readonly struct to avoid defensive copies!
Practical Examples π§
Example 1: Simple Value Type Struct
// Representing a color as RGB values
public readonly struct Color
{
public byte R { get; }
public byte G { get; }
public byte B { get; }
public Color(byte r, byte g, byte b)
{
R = r;
G = g;
B = b;
}
public Color Lighten(double factor)
{
return new Color(
(byte)Math.Min(255, R + R * factor),
(byte)Math.Min(255, G + G * factor),
(byte)Math.Min(255, B + B * factor)
);
}
public override string ToString() => $"RGB({R}, {G}, {B})";
}
// Usage
Color red = new Color(255, 0, 0);
Color pink = red.Lighten(0.5);
Console.WriteLine(pink); // RGB(255, 127, 127)
Why use a struct here?
- Small size (3 bytes + padding)
- Represents a single logical value
- Immutable design
- Often created in tight loops (rendering)
Example 2: Ref Struct for High-Performance Parsing
public ref struct CsvRowParser
{
private ReadOnlySpan<char> _row;
private int _position;
public CsvRowParser(ReadOnlySpan<char> row)
{
_row = row;
_position = 0;
}
public bool TryGetNextField(out ReadOnlySpan<char> field)
{
if (_position >= _row.Length)
{
field = default;
return false;
}
int commaIndex = _row.Slice(_position).IndexOf(',');
if (commaIndex == -1)
{
// Last field
field = _row.Slice(_position);
_position = _row.Length;
}
else
{
field = _row.Slice(_position, commaIndex);
_position += commaIndex + 1;
}
return true;
}
}
// Usage: Zero allocations!
string csvLine = "John,Doe,30,Engineer";
var parser = new CsvRowParser(csvLine.AsSpan());
while (parser.TryGetNextField(out var field))
{
Console.WriteLine(field.ToString());
}
Why ref struct?
- Works with
Span<T>which is also a ref struct - Zero heap allocations
- Perfect for parsing tight loops
- Data doesn't need to outlive the method scope
Example 3: Using in Parameters for Performance
public readonly struct BoundingBox
{
public readonly Vector3D Min;
public readonly Vector3D Max;
public BoundingBox(Vector3D min, Vector3D max)
{
Min = min;
Max = max;
}
// Without 'in': would copy both BoundingBox structs (48 bytes each!)
public static bool Intersects(in BoundingBox a, in BoundingBox b)
{
return (a.Min.X <= b.Max.X && a.Max.X >= b.Min.X) &&
(a.Min.Y <= b.Max.Y && a.Max.Y >= b.Min.Y) &&
(a.Min.Z <= b.Max.Z && a.Max.Z >= b.Min.Z);
}
public bool Contains(in Vector3D point)
{
return point.X >= Min.X && point.X <= Max.X &&
point.Y >= Min.Y && point.Y <= Max.Y &&
point.Z >= Min.Z && point.Z <= Max.Z;
}
}
// Usage
var box1 = new BoundingBox(
new Vector3D(0, 0, 0),
new Vector3D(10, 10, 10)
);
var box2 = new BoundingBox(
new Vector3D(5, 5, 5),
new Vector3D(15, 15, 15)
);
// Passes by reference - no copying!
if (BoundingBox.Intersects(in box1, in box2))
{
Console.WriteLine("Boxes intersect!");
}
Performance comparison:
- Without
in: Copies 96 bytes per call (both structs) - With
in: Passes two 8-byte references = 16 bytes - 6x less data movement!
Example 4: Avoiding Boxing with Generics
// BAD: This boxes the struct!
public void ProcessValue(object value)
{
// Using Point as object causes boxing
if (value is Point p)
{
Console.WriteLine($"Point: {p.X}, {p.Y}");
}
}
// GOOD: Generic version avoids boxing
public void ProcessValue<T>(T value) where T : struct
{
// No boxing - T is known at compile time
Console.WriteLine($"Value: {value}");
}
// BETTER: Use specific type when possible
public void ProcessPoint(Point point)
{
Console.WriteLine($"Point: {point.X}, {point.Y}");
}
// Demonstration
Point p = new Point(10, 20);
// Boxing occurs here - allocates heap memory!
ProcessValue((object)p);
// No boxing - stays on stack
ProcessValue(p);
ProcessPoint(p);
Boxing overhead:
- Heap allocation
- Garbage collection pressure
- Cache misses
- Performance degradation in tight loops
Common Mistakes β οΈ
Mistake 1: Making Structs Too Large
// BAD: 256 bytes - too large!
public struct HugeStruct
{
public long Field1, Field2, Field3, Field4;
public long Field5, Field6, Field7, Field8;
// ... 32 longs total
}
// Every assignment copies 256 bytes!
HugeStruct a = new HugeStruct();
HugeStruct b = a; // Expensive copy!
// GOOD: Use a class instead
public class LargeData
{
public long Field1, Field2, Field3, Field4;
// ... many fields
}
// Only 8-byte reference is copied
LargeData a = new LargeData();
LargeData b = a; // Just copies reference
Mistake 2: Mutating Struct Fields (Without readonly)
// BAD: Mutable struct leads to confusion
public struct MutablePoint
{
public int X;
public int Y;
public void MoveRight(int distance)
{
X += distance; // Mutates the copy!
}
}
var points = new MutablePoint[10];
points[0].X = 5;
points[0].Y = 10;
// This mutates a COPY, not the array element!
points[0].MoveRight(5);
// X is still 5, not 10!
Console.WriteLine(points[0].X); // 5
// GOOD: Use readonly struct with new instance
public readonly struct ImmutablePoint
{
public int X { get; }
public int Y { get; }
public ImmutablePoint(int x, int y)
{
X = x;
Y = y;
}
public ImmutablePoint MoveRight(int distance)
{
return new ImmutablePoint(X + distance, Y);
}
}
var points2 = new ImmutablePoint[10];
points2[0] = new ImmutablePoint(5, 10);
// Returns new instance - must reassign
points2[0] = points2[0].MoveRight(5);
Console.WriteLine(points2[0].X); // 10 β
Mistake 3: Trying to Use Ref Structs in Async
// ERROR: Won't compile!
public async Task ProcessDataAsync()
{
// Ref structs cannot cross await boundaries
Span<byte> buffer = stackalloc byte[256];
// CS4013: Instance of ref struct cannot be used in async methods
await Task.Delay(100);
buffer[0] = 42;
}
// GOOD: Use ref struct only in synchronous scope
public async Task ProcessDataAsync()
{
byte[] buffer = new byte[256];
await Task.Delay(100);
// After await, use synchronous method with ref struct
ProcessBuffer(buffer);
}
private void ProcessBuffer(byte[] data)
{
// OK - ref struct used in synchronous method
Span<byte> span = data;
span[0] = 42;
}
Mistake 4: Not Using in with Large Readonly Structs
public readonly struct Transform
{
public readonly Matrix4x4 Matrix; // 128 bytes
public readonly Vector3D Position; // 24 bytes
public readonly Vector3D Scale; // 24 bytes
// Total: 176 bytes!
}
// BAD: Copies 352 bytes every call!
public Transform Combine(Transform a, Transform b)
{
// ... combination logic
return new Transform();
}
// GOOD: Passes by reference
public Transform Combine(in Transform a, in Transform b)
{
// Only 16 bytes passed (two references)
return new Transform();
}
// Usage
Transform t1 = GetTransform1();
Transform t2 = GetTransform2();
Transform combined = Combine(in t1, in t2);
Performance Comparison π
| Scenario | Class (Heap) | Struct (Stack) | Ref Struct |
|---|---|---|---|
| Allocation time | ~50-100ns | ~1ns | ~1ns |
| GC pressure | High | None | None |
| Copy cost (16 bytes) | 8 bytes (ref) | 16 bytes | 16 bytes |
| Copy cost (128 bytes) | 8 bytes (ref) | 128 bytes | 128 bytes |
| Heap fragmentation | Possible | Never | Never |
| Can be boxed | N/A | Yes | No |
| Async-safe | Yes | Yes | No |
Key Takeaways π―
Structs are value types that live on the stack by default, avoiding GC pressure and heap allocations
Use structs for small, immutable values (β€16 bytes) that represent single logical values like Point, Color, or Money
Ref structs are stack-only types that cannot escape to the heap, perfect for
Span<T>and zero-allocation scenariosReadonly structs are immutable and prevent defensive copying when passed as
inparametersThe
inmodifier passes structs by readonly reference, avoiding expensive copies for large structsAvoid large mutable structs - they cause performance problems and confusing semantics
Boxing converts structs to heap objects - avoid by using generics or specific types
Ref structs cannot be used in async methods or as class fields due to their stack-only constraint
π Quick Reference Card
| Struct | Value type, stack allocation, copyable |
| Ref Struct | Stack-only, cannot box, no async |
| Readonly Struct | Immutable, no defensive copies |
| In Parameter | Pass by readonly reference |
| Ideal Size | β€16 bytes for copy efficiency |
| Use For | Coordinates, colors, ranges, keys |
| Avoid For | Large mutable data, long-lived objects |
π§ Memory Aid: STRUCT
Small size (β€16 bytes)
Type is a single value
Readonly when possible
Use 'in' for large structs
Copies on assignment
Think performance
π Further Study
- Microsoft Docs - Structure types: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct
- Ref structs in C#: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct
- Performance considerations for structs: https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/choosing-between-class-and-struct
π‘ Pro tip: Use BenchmarkDotNet to measure the actual performance impact of your struct design decisions. Profile first, optimize second!