This is a continuation of the article on Immutable Object Pattern. We discuss some issues related to the creation of “defense copy”.
1. Prerequisite
This article is a continuation of another article of mine:
2. 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 in
modifier.
2.1. Example
Let us look at the following example.
We will create the following struct
s 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 struct
s passed to another service method in four 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());
}
}
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();
( address4d, state4d) = car4.GetterMethod();
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();
}
}
- In Case-1, the mutable
struct
is passed with 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 original is not affected. - In Case-3, the immutable
struct
is passed with in
modifier, meaning it is passed by reference to the method TestDefenseCopy
. But when the method making hidden mutations is invoked, the compiler created 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 that a “defensive copy” will be created once at the beginning of the method TestDefenseCopy
, and assigned to car3
local variable. - In Case-4, the immutable
struct
is passed with in
modifier, meaning it is passed by reference to the method TestDefenseCopy
. Invoking readonly
method does not create any kind of “defense copy”, as can be 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 private hidebysig static void
TestDefenseCopy(
valuetype E5_ImmutableDefensiveCopy.CarStruct& car1,
valuetype E5_ImmutableDefensiveCopy.CarStruct car2,
[in] valuetype E5_ImmutableDefensiveCopy.CarStructI1& car3,
[in] valuetype E5_ImmutableDefensiveCopy.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
) cil managed
{
.custom instance void System.Runtime.CompilerServices.NullableContextAttribute/
*02000005*/::.ctor(unsigned int8)
= (01 00 02 00 00 )
.param [3]
.custom instance void [System.Runtime]System.Runtime.
CompilerServices.IsReadOnlyAttribute::.ctor()
= (01 00 00 00 )
.param [4]
.custom instance void [System.Runtime]System.Runtime.
CompilerServices.IsReadOnlyAttribute::.ctor()
= (01 00 00 00 )
.maxstack 2
.locals init (
[0] valuetype [System.Runtime]System.ValueTuple`2<string, string> V_0,
[1] valuetype E5_ImmutableDefensiveCopy.CarStructI1 V_1
)
IL_0000: ldarg.0
IL_0001: ldc.i4.s 115
IL_0003: newobj instance void valuetype [System.Runtime]System.Nullable
`1<char>::.ctor(!0)
IL_0008: call instance void E5_ImmutableDefensiveCopy.CarStruct::set_Brand
(valuetype [System.Runtime]System.Nullable`1<char>)
IL_000d: ldarg.2
IL_000e: ldobj E5_ImmutableDefensiveCopy.CarStructI1
IL_0013: stloc.1
IL_0014: ldloca.s V_1
IL_0016: call instance valuetype [System.Runtime]System.ValueTuple`2
<string, string> E5_ImmutableDefensiveCopy.CarStructI1
::HiddenMutatorMethod() /(*38)
IL_001b: stloc.0
IL_001c: ldarg.s address3d
IL_001e: ldloc.0
IL_001f: ldfld !0 valuetype [System.Runtime]System.ValueTuple
`2<string, string>::Item1
IL_0024: stind.ref
IL_0025: ldarg.s state3d
IL_0027: ldloc.0
IL_0028: ldfld !1 valuetype [System.Runtime]System.ValueTuple
`2<string, string>::Item2
IL_002d: stind.ref
IL_002e: ldarg.3
IL_002f: call instance valuetype [System.Runtime]System.ValueTuple
`2<string, string> E5_ImmutableDefensiveCopy.CarStructI3
::GetterMethod()
IL_0034: stloc.0
IL_0035: ldarg.s address4d
IL_0037: ldloc.0
IL_0038: ldfld !0 valuetype [System.Runtime]System.ValueTuple
`2<string, string>::Item1
IL_003d: stind.ref
IL_003e: ldarg.s state4d
IL_0040: ldloc.0
IL_0041: ldfld !1 valuetype [System.Runtime]System.ValueTuple
`2<string, string>::Item2
IL_0046: stind.ref
IL_0047: ldarg.s address1
IL_0049: ldarg.0
IL_004a: call instance string E5_ImmutableDefensiveCopy.CarStruct
::GetAddress()
IL_004f: stind.ref
IL_0050: ldarg.s address2
IL_0052: ldarga.s car2
IL_0054: call instance string E5_ImmutableDefensiveCopy.CarStruct
::GetAddress()
IL_0059: stind.ref
IL_005a: ldarg.s address3
IL_005c: ldarg.2
IL_005d: call instance string E5_ImmutableDefensiveCopy.CarStructI1
::GetAddress()
IL_0062: stind.ref
IL_0063: ldarg.s address4
IL_0065: ldarg.3
IL_0066: call instance string E5_ImmutableDefensiveCopy.CarStructI3
::GetAddress()
IL_006b: stind.ref
IL_006c: ret
}
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.
3. 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.
4. References
5. History
- 7th February, 2023: Initial version