Readonly Structs and Defensive Copying in C

In C#, readonly struct declarations enforce immutability on value types to help developers write safer, high-performance code. When standard structs are passed by reference using modifiers like in, the C# compiler often creates hidden "defensive copies" of the struct to ensure its state is not modified unexpectedly. By explicitly marking a struct as readonly, you guarantee to the compiler that the data cannot mutate, completely eliminating the need for defensive copies and significantly reducing memory overhead and CPU cycles.

The Problem: Defensive Copies with Standard Structs

In high-performance C# applications, structs are often passed using the in parameter modifier (or returned via ref readonly) to avoid copying large value types across method boundaries. Passing by reference avoids the overhead of copying the entire struct onto the stack.

However, standard structs are mutable by default. If you invoke a method, property, or field on a struct passed via in, the compiler cannot determine with absolute certainty whether that member will mutate the struct's internal state. To prevent accidental mutation while upholding the read-only contract of in, the compiler automatically creates a temporary copy of the struct in memory and calls the member on that copy instead. This process is known as a defensive copy.

For example, consider a standard mutable struct:

public struct Point
{
    public double X { get; set; }
    public double Y { get; set; }

    public double GetDistance() => Math.Sqrt(X * X + Y * Y);
}

public class GeometryService
{
    public void ProcessPoint(in Point p)
    {
        // The compiler creates a defensive copy of 'p' before calling GetDistance()
        double distance = p.GetDistance();
    }
}

In the example above, calling p.GetDistance() forces the compiler to copy p to a local temporary variable, negating the performance benefits of passing the struct by in reference.

What Are Readonly Structs?

Introduced in C# 7.2, the readonly struct keyword enforces compile-time immutability on a value type. When a struct is declared with the readonly modifier, every field within it must also be marked readonly, and properties can only have getters.

public readonly struct ReadonlyPoint
{
    public double X { get; }
    public double Y { get; }

    public ReadonlyPoint(double x, double y)
    {
        X = x;
        Y = y;
    }

    public double GetDistance() => Math.Sqrt(X * X + Y * Y);
}

If you attempt to modify a field or declare a mutable property within a readonly struct, the compiler produces a compilation error.

How Readonly Structs Eliminate Defensive Copying

When a struct is declared as readonly struct, the compiler knows that none of its members can alter its state. Consequently, when a readonly struct is accessed via an in parameter, a ref readonly local, or a readonly field, the compiler skips creating defensive copies.

Instead of duplicating the struct in memory, calls to methods and properties are executed directly against the original memory address via reference.

public class GeometryService
{
    public void ProcessPoint(in ReadonlyPoint p)
    {
        // No defensive copy is created. 
        // The call executes directly on the referenced instance.
        double distance = p.GetDistance();
    }
}

Readonly Members on Mutable Structs

If a struct cannot be entirely immutable, C# 8.0 introduced the ability to apply the readonly modifier to individual members (such as methods or properties) within a standard struct:

public struct HybridPoint
{
    public double X { get; set; }
    public double Y { get; set; }

    // Marking this method readonly prevents defensive copying when called
    public readonly double GetDistance() => Math.Sqrt(X * X + Y * Y);
}

Marking a specific method as readonly assures the compiler that invoking that specific operation will not mutate state, avoiding defensive copying for that call even if the parent struct remains mutable.

Summary of Benefits

  1. Zero Allocation Overhead: Prevents hidden stack copies when passing large structs by reference.
  2. Compile-Time Safety: Enforces strict immutability rules across the entire type.
  3. Predictable Performance: Ensures critical loops and high-throughput pipelines do not suffer from performance regressions caused by implicit compiler-generated temporary variables.