Abstract: This is a continuation of the article on Immutable Object Pattern. We discuss some issues related to the creation of "defense copy".
Prerequisite
This article is a continuation of another piece of mine:
Immutable object and Defensive copies
When passing a struct object to a method with an "in" parameter modifier, some optimizations are possible if the struct is marked with "readonly". Because if a mutation is possibly going to happen, the compiler will create the "defense copy" of the struct to prevent possible mutation of the parameter marked with the "in" modifier.
2.1 Example
Let us look at the following example.
We will create the following structs for our example:
- CarStruct - Mutable struct
- CarStructI1 - Partly Mutable/Immutable struct that has a hidden mutator method
- CarStructI3 - Immutable struct marked "readonly"
We are going to monitor the addresses of structs passed to another service method in 4 different cases:
- Case 1: Mutable struct passed by ref ("ref" modifier)
- Case 2: Mutable struct passed by value
- Case 3: Immutable struct passed with "in" modifier, applying hidden mutator on it
- Case 4: Immutable struct passed with "in" modifier, applying getter method
By monitoring object addresses, outside and inside service method ("TestDefenseCopy"), we will see if and when "defense-copy" has been created.
//=============================================
public struct CarStruct {
public CarStruct(Char ? brand, Char ? model, int ? year) {
Brand = brand;
Model = model;
Year = year;
}
public Char ? Brand {
get;
set;
}
public Char ? Model {
get;
set;
}
public int ? Year {
get;
set;
}
public override string ToString() {
return $ "Brand:{Brand}, Model:{Model}, Year:{Year}";
}
public readonly unsafe string ? GetAddress() {
string ? result = null;
fixed(void * pointer1 = ( & this)) {
result = $ "0x{(long)pointer1:X}";
}
return result;
}
}
//=============================================
public struct CarStructI1 {
public CarStructI1(Char ? brand, Char ? model, int ? year) {
Brand = brand;
Model = model;
Year = year;
}
public Char ? Brand {
get;
private set;
}
public Char ? Model {
get;
}
public int ? Year {
get;
}
public readonly override string ToString() {
return $ "Brand:{Brand}, Model:{Model}, Year:{Year}";
}
public(string ? , string ? ) HiddenMutatorMethod() {
Brand = 'Z';
return (this.GetAddress(), this.ToString());
}
public readonly unsafe string ? GetAddress() {
string ? result = null;
fixed(void * pointer1 = ( & this)) {
result = $ "0x{(long)pointer1:X}";
}
return result;
}
}
//=============================================
public readonly struct CarStructI3 {
public CarStructI3(Char brand, Char model, int year) {
this.Brand = brand;
this.Model = model;
this.Year = year;
}
public Char Brand {
get;
}
public Char Model {
get;
}
public int Year {
get;
}
public override string ToString() {
return $ "Brand:{Brand}, Model:{Model}, Year:{Year}";
}
public unsafe string ? GetAddress() {
string ? result = null;
fixed(void * pointer1 = ( & this)) {
result = $ "0x{(long)pointer1:X}";
}
return result;
}
public(string ? , string ? ) GetterMethod() {
return (this.GetAddress(), this.ToString());
}
}
//=============================================
//===Sample code===============================
internal class Program {
private static void TestDefenseCopy(ref CarStruct car1, CarStruct car2, in CarStructI1 car3, in CarStructI3 car4, out string ? address1, out string ? address2, out string ? address3, out string ? address4, out string ? address3d, out string ? state3d, out string ? address4d, out string ? state4d) {
car1.Brand = 's';
(address3d, state3d) = car3.HiddenMutatorMethod(); //(*1)
(address4d, state4d) = car4.GetterMethod(); //(*2)
address1 = car1.GetAddress();
address2 = car2.GetAddress();
address3 = car3.GetAddress();
address4 = car4.GetAddress();
}
static void Main(string[] args) {
CarStruct car1 = new CarStruct('T', 'C', 2022);
CarStruct car2 = new CarStruct('T', 'C', 2022);
CarStructI1 car3 = new CarStructI1('T', 'C', 2022);
CarStructI3 car4 = new CarStructI3('T', 'C', 2022);
string ? address_in_main_1 = car1.GetAddress();
string ? address_in_main_2 = car2.GetAddress();
string ? address_in_main_3 = car3.GetAddress();
string ? address_in_main_4 = car4.GetAddress();
Console.WriteLine($ "State of structs before method call:");
Console.WriteLine($ "car1 : before ={car1}");
Console.WriteLine($ "car2 : before ={car2}");
Console.WriteLine($ "car3 : before ={car3}");
Console.WriteLine($ "car4 : before ={car4}");
Console.WriteLine();
TestDefenseCopy(ref car1, car2, in car3, in car4, out string ? address_in_method_1, out string ? address_in_method_2, out string ? address_in_method_3, out string ? address_in_method_4, out string ? address_in_method_3d, out string ? state3d, out string ? address_in_method_4d, out string ? state4d);
Console.WriteLine($ "State of struct - defense copy:");
Console.WriteLine($ "car3d: d-copy ={state3d}");
Console.WriteLine();
Console.WriteLine($ "State of structs after method call:");
Console.WriteLine($ "car1 : after ={car1}");
Console.WriteLine($ "car2 : after ={car2}");
Console.WriteLine($ "car3 : after ={car3}");
Console.WriteLine($ "car4 : after ={car4}");
Console.WriteLine();
Console.WriteLine($ "Case 1 : Mutable struct passed by ref:");
Console.WriteLine($ "car1 : address_in_main_1 ={address_in_main_1}, address_in_method_1 ={address_in_method_1}");
Console.WriteLine($ "Case 2 :Mutable struct passed by value:");
Console.WriteLine($ "car2 : address_in_main_2 ={address_in_main_2}, address_in_method_2 ={address_in_method_2}");
Console.WriteLine($ "Case 3 :Immutable struct passed with in modifier:");
Console.WriteLine($ "car3 : address_in_main_3 ={address_in_main_3}, address_in_method_3 ={address_in_method_3}");
Console.WriteLine($ "Case 3d:Immutable struct passed with in modifier, applying hidden mutator");
Console.WriteLine($ "car3d: address_in_main_3 ={address_in_main_3}, address_in_method_3d={address_in_method_3d}");
Console.WriteLine($ "Case 4 :Immutable struct passed with in modifier:");
Console.WriteLine($ "car4 : address_in_main_4 ={address_in_main_4}, address_in_method_4 ={address_in_method_4}");
Console.WriteLine($ "Case 4d:Immutable struct passed with in modifier, , applying getter method");
Console.WriteLine($ "car4 : address_in_main_4 ={address_in_main_4}, address_in_method_4d={address_in_method_4d}");
Console.WriteLine();
Console.ReadLine();
}
}
//=============================================
//===Result of execution=======================
/*
State of structs before method call:
car1 : before =Brand:T, Model:C, Year:2022
car2 : before =Brand:T, Model:C, Year:2022
car3 : before =Brand:T, Model:C, Year:2022
car4 : before =Brand:T, Model:C, Year:2022
State of struct - defense copy:
car3d: d-copy =Brand:Z, Model:C, Year:2022
State of structs after method call:
car1 : after =Brand:s, Model:C, Year:2022
car2 : after =Brand:T, Model:C, Year:2022
car3 : after =Brand:T, Model:C, Year:2022
car4 : after =Brand:T, Model:C, Year:2022
Case 1 : Mutable struct passed by ref:
car1 : address_in_main_1 =0x44C0D7E7D0, address_in_method_1 =0x44C0D7E7D0
Case 2 :Mutable struct passed by value:
car2 : address_in_main_2 =0x44C0D7E7C0, address_in_method_2 =0x44C0D7E698
Case 3 :Immutable struct passed with in modifier:
car3 : address_in_main_3 =0x44C0D7E7B0, address_in_method_3 =0x44C0D7E7B0
Case 3d:Immutable struct passed with in modifier, applying hidden mutator
car3d: address_in_main_3 =0x44C0D7E7B0, address_in_method_3d=0x44C0D7E5D0
Case 4 :Immutable struct passed with in modifier:
car4 : address_in_main_4 =0x44C0D7E7A8, address_in_method_4 =0x44C0D7E7A8
Case 4d:Immutable struct passed with in modifier, , applying getter method
car4 : address_in_main_4 =0x44C0D7E7A8, address_in_method_4d=0x44C0D7E7A8
*/
- In Case-1, the mutable struct is passed with the "ref' modifier, meaning it is passed by reference and can be mutated inside the method TestDefenseCopy.
- In Case-2, the mutable struct is passed without a modifier, meaning it is passed by value, and the copy is mutated inside the method TestDefenseCopy, but the original is not affected.
- In Case-3, the immutable struct is passed with an "in" modifier, meaning it is passed by reference to the method TestDefenseCopy. But when the method making hidden mutations is invoked, the compiler creates a "defense copy" and mutated that copy. We can see that address-3d taken from inside that hidden mutator method is different from the original address of car3. The confusing part is that address taken later for car3 again points to the original copy of car3. I expected a "defensive copy" to be created once at the beginning of the method TestDefenseCopy, and assigned to the car3 local variable.
- In Case-4, the immutable struct is passed with an "in" modifier, meaning it is passed by reference to the method TestDefenseCopy. Invoking the "readonly" method does not create any kind of "defense copy, " as seen from address-4d.
2.2 Decompiling example into IL
Since behavior in line of code (*1) looks weird a bit and would be definitely hard to find if overlooked. I expected that "defense copy" will exist through the whole method TestDefenseCopy, but later address taken says it is just created on the spot and abandoned. I decided to decompile the assembly and look into IL what is happening here. I used dotPeek to decompile the assembly and here is the TestDefenseCopy method in IL:
.method /*0600001F*/ private hidebysig static void
TestDefenseCopy(
/*08000010*/ valuetype E5_ImmutableDefensiveCopy.CarStruct/*02000007*/& car1,
/*08000011*/ valuetype E5_ImmutableDefensiveCopy.CarStruct/*02000007*/ car2,
/*08000012*/ [in] valuetype E5_ImmutableDefensiveCopy.CarStructI1/*02000008*/& car3, //(*31)
/*08000013*/ [in] valuetype E5_ImmutableDefensiveCopy.CarStructI3/*02000009*/& car4, //(*41)
/*08000014*/ [out] string& address1,
/*08000015*/ [out] string& address2,
/*08000016*/ [out] string& address3,
/*08000017*/ [out] string& address4,
/*08000018*/ [out] string& address3d,
/*08000019*/ [out] string& state3d,
/*0800001A*/ [out] string& address4d,
/*0800001B*/ [out] string& state4d
) cil managed
{
.custom /*0C000048*/ instance void System.Runtime.CompilerServices.NullableContextAttribute/
*02000005*/::.ctor(unsigned int8)/*06000005*/
= (01 00 02 00 00 ) // .....
// unsigned int8(2) // 0x02
.param [3] /*08000012*/
.custom /*0C000038*/ instance void [System.Runtime/*23000001*/]System.Runtime.
CompilerServices.IsReadOnlyAttribute/*01000017*/::.ctor()
= (01 00 00 00 ) //(*32)
.param [4] /*08000013*/
.custom /*0C00003B*/ instance void [System.Runtime/*23000001*/]System.Runtime.
CompilerServices.IsReadOnlyAttribute/*01000017*/::.ctor()
= (01 00 00 00 )
.maxstack 2
.locals /*11000005*/ init (
[0] valuetype [System.Runtime/*23000001*/]System.ValueTuple`2/*01000019*/<string, string> V_0,
[1] valuetype E5_ImmutableDefensiveCopy.CarStructI1/*02000008*/ V_1 //(*33)
)
// [14 13 - 14 30]
IL_0000: ldarg.0 // car1
IL_0001: ldc.i4.s 115 // 0x73
IL_0003: newobj instance void valuetype [System.Runtime/*23000001*/]System.Nullable
`1/*01000016*/<char>/*1B000002*/::.ctor(!0/*char*/)/*0A000018*/
IL_0008: call instance void E5_ImmutableDefensiveCopy.CarStruct/*02000007*/::set_Brand
(valuetype [System.Runtime/*23000001*/]System.Nullable`1/*01000016*/<char>)/*06000009*/
//(*1)--------------------------------------------------------
// [16 13 - 16 64]
IL_000d: ldarg.2 // car3 //(*34)
IL_000e: ldobj E5_ImmutableDefensiveCopy.CarStructI1/*02000008*/ //(*35)
IL_0013: stloc.1 // V_1 //(*36)
IL_0014: ldloca.s V_1 //(*37)
IL_0016: call instance valuetype [System.Runtime/*23000001*/]System.ValueTuple`2
/*01000019*/<string, string> E5_ImmutableDefensiveCopy.CarStructI1/*02000008*/
::HiddenMutatorMethod()/*06000016*/ /(*38)
IL_001b: stloc.0 // V_0
IL_001c: ldarg.s address3d
IL_001e: ldloc.0 // V_0
IL_001f: ldfld !0/*string*/ valuetype [System.Runtime/*23000001*/]System.ValueTuple
`2/*01000019*/<string, string>/*1B000003*/::Item1/*0A00001B*/
IL_0024: stind.ref
IL_0025: ldarg.s state3d
IL_0027: ldloc.0 // V_0
IL_0028: ldfld !1/*string*/ valuetype [System.Runtime/*23000001*/]System.ValueTuple
`2/*01000019*/<string, string>/*1B000003*/::Item2/*0A00001C*/
IL_002d: stind.ref
//(*2)------------------------------------------------------
// [17 13 - 17 57]
IL_002e: ldarg.3 // car4 //(*42)
IL_002f: call instance valuetype [System.Runtime/*23000001*/]System.ValueTuple
`2/*01000019*/<string, string> E5_ImmutableDefensiveCopy.CarStructI3/*02000009*/
::GetterMethod()/*0600001E*/
IL_0034: stloc.0 // V_0
IL_0035: ldarg.s address4d
IL_0037: ldloc.0 // V_0
IL_0038: ldfld !0/*string*/ valuetype [System.Runtime/*23000001*/]System.ValueTuple
`2/*01000019*/<string, string>/*1B000003*/::Item1/*0A00001B*/
IL_003d: stind.ref
IL_003e: ldarg.s state4d
IL_0040: ldloc.0 // V_0
IL_0041: ldfld !1/*string*/ valuetype [System.Runtime/*23000001*/]System.ValueTuple
`2/*01000019*/<string, string>/*1B000003*/::Item2/*0A00001C*/
IL_0046: stind.ref
// [19 13 - 19 42]
IL_0047: ldarg.s address1
IL_0049: ldarg.0 // car1
IL_004a: call instance string E5_ImmutableDefensiveCopy.CarStruct/*02000007*/
::GetAddress()/*0600000F*/
IL_004f: stind.ref
// [20 13 - 20 42]
IL_0050: ldarg.s address2
IL_0052: ldarga.s car2
IL_0054: call instance string E5_ImmutableDefensiveCopy.CarStruct/*02000007*/
::GetAddress()/*0600000F*/
IL_0059: stind.ref
// [21 13 - 21 42]
IL_005a: ldarg.s address3
IL_005c: ldarg.2 // car3 //(*39)
IL_005d: call instance string E5_ImmutableDefensiveCopy.CarStructI1/*02000008*/
::GetAddress()/*06000017*/
IL_0062: stind.ref
// [22 13 - 22 42]
IL_0063: ldarg.s address4
IL_0065: ldarg.3 // car4
IL_0066: call instance string E5_ImmutableDefensiveCopy.CarStructI3/*02000009*/
::GetAddress()/*0600001D*/
IL_006b: stind.ref
// [23 9 - 23 10]
IL_006c: ret
} // end of method Program::TestDefenseCopy
I marked with (*1) and (*2) lines of code in IL corresponding to the same lines in C#. I marked the difference in IL between handling (*1) and (*2) with (*??). This is what I can read from IL:
- At (*31) we see parameters, it looks like car3 is passed "by ref" and that is fine
- At (*32) looks like it is marked as "readonly", so that is fine,
- At (*33) looks like a local variable of type CarStructI1 is created as a local variable [1]. That is really a placeholder for that "defense copy" to be.
- At (*34) argument at index 2 (that is the address of car3) is loaded into the Evaluation stack
- At (*35) object of type E5_ImmutableDefensiveCopy.CarStructI1 whose address is on the stack is loaded into the Evaluation stack
- At (*36) object from the stack is copied into the local variable defined at (*33). So here is "defense-copy" created in local variable.
- At (*37) address of that local variable from (*33) is pushed to the stack
- At (*38) we have the invocation of the method HiddenMuttatorMethod over the local variable in (*33). So, the original struct pointed by the address at (*31) is not affected. So here we can see that method HiddenMuttatorMethod is executed on "defense-copy"
- At (*39) again original address from (*31) is loaded for the call when we take the address of the car3 object. That explains why we do not see the change of address in this instance. Honestly, I expected that here we would get the address of the local variable defined at (*33) here. But what I consider normal is not how it actually works. So, here we do not take the address of "defense-copy" but of original object car3.
- At (*42) we see the difference between (*1) car 3 and (*2) car4 , that is the address from (*41) is directly loaded to the stack, and the method GetterMethod directly operates on that original instance of car4. In this case, no "defense-copy" is used.
It is not completely obvious to me in all details why "defense copy" works like that, but that is IL, so that is the real world. I expected that "defense-copy", once created, would be used all the time inside the method for which is created. But what I just saw is that sometimes the compiler uses "defense-copy" and sometimes the original reference to the read-only object. IL does not lie. This example was made with .NET 7/C#11.
Conclusion
We explained the concept of "defense copy" and gave an example of it. Regarding "defense copy" behavior, I did not personally see the "exact" behavior described in [5], but I did see "similar" behavior to the one described. It is even possible that details regarding implementation change between different versions of .NET and C# compiler.
References
- https://en.wikipedia.org/wiki/Immutable_object
- https://ericlippert.com/2007/11/13/immutability-in-c-part-one-kinds-of-immutability/
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct
- https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/with-expression
- https://learn.microsoft.com/en-us/dotnet/csharp/write-safe-efficient-code
- https://stackoverflow.com/questions/57126134/is-this-a-defensive-copy-of-readonly-struct-passed-to-a-method-with-in-keyword
- https://levelup.gitconnected.com/defensive-copy-in-net-c-38ae28b828
- https://blog.paranoidcoding.com/2019/03/27/readonly-struct-breaking-change.html
- https://www.codeproject.com/Articles/5353999/Csharp11-Immutable-Object-Pattern