With the current compiler, defensive copies do indeed appear to be made for both 'primitive' value types and other non-readonly structs. Specifically, they are generated similarly to how they are for readonly
fields: when accessing a property or method that could potentially mutate the contents. The copies appear at each call site to a potentially mutating member, so if you invoke n such members, you'll end up making n defensive copies. As with readonly
fields, you can avoid multiple copies by manually copying the original to a local.
Take a look at this suite of examples. You can view both the IL and the JIT assembly.
Is it safe to pass primitive types via in arguments and not have defensive copies made?
It depends on whether you access a method or property on the in
parameter. If you do, you may see defensive copies. If not, you probably won't:
// Original:
int In(in int _) {
_.ToString();
_.GetHashCode();
return _ >= 0 ? _ + 42 : _ - 42;
}
// Decompiled:
int In([In] [IsReadOnly] ref int _) {
int num = _;
num.ToString(); // invoke on copy
num = _;
num.GetHashCode(); // invoke on second copy
if (_ < 0)
return _ - 42; // use original in arithmetic
return _ + 42;
}
Are other commonly used framework structs such as DateTime, TimeSpan, Guid, ... considered readonly by [the compiler]?
No, defensive copies will still be made at call sites for potentially mutating members on in
parameters of these types. What's interesting, though, is that not all methods and properties are considered 'potentially mutating'. I noticed that if I called a default method implementation (e.g., ToString
or GetHashCode
), no defensive copies were emitted. However, as soon as I overrode those methods, the compiler created copies:
struct WithDefault {}
struct WithOverride { public override string ToString() => "RO"; }
// Original:
void In(in WithDefault d, in WithOverride o) {
d.ToString();
o.ToString();
}
// Decompiled:
private void In([In] [IsReadOnly] ref WithDefault d,
[In] [IsReadOnly] ref WithOverride o) {
d.ToString(); // invoke on original
WithOverride withOverride = o;
withOverride.ToString(); // invoke on copy
}
If this varies by platform, how can we find out which types are safe in a given situation?
Well, all types are 'safe'--the copies ensure that. I assume you're asking which types will avoid a defensive copy. As we've seen above, it's more complicated than "what's the type of the parameter"? There's no single copy: the copies are emitted at certain references to in
parameters, e.g., where the reference is an invocation target. If no such references are present, no copies need to be made. Moreover, the decision whether to copy can depend on whether you invoke a member that is known to be safe or 'pure' vs. a member which could potentially mutate the a value type's contents.
For now, certain default methods seem to be treated as pure, and the compiler avoids making copies in those cases. If I had to guess, this is a result of preexisting behavior, and the compiler is utilizing some notion of 'read only' references that was originally developed for readonly
fields. As you can see below (or in SharpLab), the behavior is similar. Note how the IL uses ldflda
(load field by address) to push the invocation target onto the stack when calling WithDefault.ToString
, but uses a ldfld
, stloc
, ldloca
sequence to push a copy onto the stack when invoking WithOverride.ToString
:
struct WithDefault {}
struct WithOverride { public override string ToString() => "RO"; }
static readonly WithDefault D;
static readonly WithOverride O;
// Original:
static void Test() {
D.ToString();
O.ToString();
}
// IL Disassembly:
.method private hidebysig static void Test () cil managed {
.maxstack 1
.locals init ([0] valuetype Overrides/WithOverride)
// [WithDefault] Invoke on original by address:
IL_0000: ldsflda valuetype Overrides/WithDefault Overrides::D
IL_0005: constrained. Overrides/WithDefault
IL_000b: callvirt instance string [mscorlib]System.Object::ToString()
IL_0010: pop
// [WithOverride] Copy original to local, invoke on copy by address:
IL_0011: ldsfld valuetype Overrides/WithOverride Overrides::O
IL_0016: stloc.0
IL_0017: ldloca.s 0
IL_0019: constrained. Overrides/WithOverride
IL_001f: callvirt instance string [mscorlib]System.Object::ToString()
IL_0024: pop
IL_0025: ret
}
That said, now that read only references will presumably become more common, the 'white list' of methods that can be invoked without defensive copies may grow in the future. For now, it seems somewhat arbitrary.