C# 7.2 added the ability to mark a struct
declaration as readonly
. This has the effect of guaranteeing that no
member of the struct
can mutate its contents as it ensures every field is marked as readonly
. This guarantee is
imporant because it allows the compiler to avoid defensive copies of struct
values in cases where the underlying
location is considered readonly
. For example when invoking members of a struct
which is stored in a
readonly
field.
class Operation {
readonly string Name;
readonly DateTimeOffset Started;
public override string ToString() => Name + Started.ToString();
}
When calling Started.ToString
here the compiler first creates a defensive copy of Started
on the stack. The
ToString
operation is then invoked on that copy. The reason for this is the compiler must assume the worst case
which is ToString
mutates the contents of the struct
and hence violates the readonly
contract on the field.
Starting with netcoreapp2.1 though DateTime
, and many other types,
are now marked as readonly struct
. Invocations like the ToString
above now occur directly on the field, avoiding
the wasteful copy it had before.
These defense copies are small when looked at individually but can quickly add up to a significant performance issue.
Particularly in high performance scenarios which make heavy use of readonly
and tend to use larger sized struct
declarations. Before the readonly struct
feature these code bases often had to sacrifice correctness by avoiding
readonly
to improve perforamnce by avoiding defensive copies. Now though the same code bases can have performance
and without sacrificing correctness.
One question that frequently comes up with readonly struct
though is whether or not this is a breaking change? The
short answer is no. This is a very safe change to make. Adding readonly
is not a source breaking change for
consumers: it is still recognized by older compilers, it doesn’t cause overload resolution changes, it can be used in
other struct
types, etc … The only effect it has is that it allows the compiler to elide defensive copies in a
number of cases.
That being said there is one scenario to be careful of when applying this feature. One of the requirements is that every
field of the type be explicitly marked as readonly
. Adding readonly
to a field as a part of making the containing
type readonly
can cause observable behavior changes. When the field type is a non-readonly struct
defensive copies
will now be made for invocations and this can cause changes to be dropped where previously they were persisted. This
has nothing to do with readonly struct
but instead is a direct result of making the field readonly
.
The CoreFX team ran into exactly this problem when making Nullable<T>
into a readonly struct
. The T value
field
was marked as readonly
as a part of that process. This turned out to be
a breaking change because it meant operations
like value.ToString
now caused a defensive copy to occur which caused all mutations inside value
to be discarded.
Eventually this lead to the change being reverted because of the high
impact of Nullable<T>
.
struct Nullable<T> {
readonly T value;
bool hasValue;
public override string ToString() {
// Oops: value.ToString now creates a defensive copy
return hasValue ? value.ToString() : "";
}
}
Again though, this is about marking fields readonly
, not the containing type. This type of problem is fairly rare
though. Even in code bases where compat is of incredibly high value there have been sweeping changes to
mark large
blocks of struct
declarations as readonly
.
The other case where behavior changes can occur has to do with aliasing. This is extremely rare though, only showing up in hypotheticals vs. actual code bases. It is best demonstrated by example:
struct S {
static S StaticField = new S(0);
public static ref readonly S Get() => ref StaticField;
public readonly int Field;
public S(int field) {
Field = field;
}
public int M(int value) {
StaticField = new S(value);
return Field;
}
static void Main() {
Console.WriteLine(S.Get().M(42));
}
}
This code will print 0
. The invocation of M(42)
here occurs on a ref readonly S
which means the receiver location
is conisdered readonly
. This is the ref
equivalent of invoking M
when the receiver is contained in a
static readonly
field. The location itself is readonly
, the member is not and hence the compiler creates a
defensive copy.
When the declaration is changed to readonly struct S
the code will print 42
. The reason is that there is no longer
a defensive copy during the invocation of M
. Defensive copies are all about ensuring the target method does not
directly mutate the contents of the receiver. But it is still possible for other aliases to the same location to
indirectly mutate the contents by assigning into the location.
This is a fairly contrived example though and not one that is likely to occur in many code bases. It is listed here
not as a warning against using readonly struct
but quite the opposite. It’s meant to demonstrate the level of
complication needed to observe the difference.
The take away here is readonly struct
is a beneficial annotation, both for performance and correctness, that is
very safe to add to your code base.