C#11 - Immutable Object and Defensive Copy
Some issues related to Immutable Object and “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
- Mutablestruct
CarStructI1
- Partly Mutable/Immutablestruct
that has a hidden mutator methodCarStructI3
- Immutablestruct
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 within
modifier, applying hidden mutator on it - Case 4: Immutable
struct
passed within
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 withref
modifier, meaning it is passed by reference and can be mutated inside the methodTestDefenseCopy
- In Case-2, the mutable
struct
is passed without a modifier, meaning it is passed by value and the copy is mutated inside the methodTestDefenseCopy
, but original is not affected. - In Case-3, the immutable
struct
is passed within
modifier, meaning it is passed by reference to the methodTestDefenseCopy
. But when the method making hidden mutations is invoked, the compiler created a “defense copy” and mutated that copy. We can see thataddress-3d
taken from inside that hidden mutator method is different from the original address ofcar3
. The confusing part is that address taken later forcar3
again points to the original copy ofcar3
. I expected that a “defensive copy” will be created once at the beginning of the methodTestDefenseCopy
, and assigned tocar3
local variable. - In Case-4, the immutable
struct
is passed within
modifier, meaning it is passed by reference to the methodTestDefenseCopy
. Invokingreadonly
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 /*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 originalstruct
pointed by the address at (*31) is not affected. So here, we can see that methodHiddenMuttatorMethod
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 objectcar3
. - 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 ofcar4
. 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
- [1] https://en.wikipedia.org/wiki/Immutable_object
- [2] https://ericlippert.com/2007/11/13/immutability-in-c-part-one-kinds-of-immutability/
- [3] https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct
- [4] https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/with-expression
- [5] https://learn.microsoft.com/en-us/dotnet/csharp/write-safe-efficient-code
- [6] https://stackoverflow.com/questions/57126134/is-this-a-defensive-copy-of-readonly-struct-passed-to-a-method-with-in-keyword
- [7] https://levelup.gitconnected.com/defensive-copy-in-net-c-38ae28b828
- [8] https://blog.paranoidcoding.com/2019/03/27/readonly-struct-breaking-change.html
- [9] https://www.codeproject.com/Articles/5353999/Csharp11-Immutable-Object-Pattern
5. History
- 7th February, 2023: Initial version