You are viewing a preview of this lesson. Sign in to start learning
Back to C# Programming

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 valueRepresents complex entity with identity
Immutable by designMutable state is needed
Short-lived (method scope)Long-lived objects
Needs value semanticsNeeds reference semantics
High allocation volumeShared 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 object or 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 in parameters
  • 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 πŸ“Š

ScenarioClass (Heap)Struct (Stack)Ref Struct
Allocation time~50-100ns~1ns~1ns
GC pressureHighNoneNone
Copy cost (16 bytes)8 bytes (ref)16 bytes16 bytes
Copy cost (128 bytes)8 bytes (ref)128 bytes128 bytes
Heap fragmentationPossibleNeverNever
Can be boxedN/AYesNo
Async-safeYesYesNo

Key Takeaways 🎯

  1. Structs are value types that live on the stack by default, avoiding GC pressure and heap allocations

  2. Use structs for small, immutable values (≀16 bytes) that represent single logical values like Point, Color, or Money

  3. Ref structs are stack-only types that cannot escape to the heap, perfect for Span<T> and zero-allocation scenarios

  4. Readonly structs are immutable and prevent defensive copying when passed as in parameters

  5. The in modifier passes structs by readonly reference, avoiding expensive copies for large structs

  6. Avoid large mutable structs - they cause performance problems and confusing semantics

  7. Boxing converts structs to heap objects - avoid by using generics or specific types

  8. Ref structs cannot be used in async methods or as class fields due to their stack-only constraint

πŸ“‹ Quick Reference Card

StructValue type, stack allocation, copyable
Ref StructStack-only, cannot box, no async
Readonly StructImmutable, no defensive copies
In ParameterPass by readonly reference
Ideal Size≀16 bytes for copy efficiency
Use ForCoordinates, colors, ranges, keys
Avoid ForLarge 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

  1. Microsoft Docs - Structure types: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct
  2. Ref structs in C#: https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/ref-struct
  3. 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!