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