C#11 - 不可变对象和防御性拷贝
与不可变对象和“防御性拷贝”相关的一些问题
1. 先决条件
本文是我另一篇文章的延续
2. 不可变对象与防御性副本
当将 struct
对象传递给带有 in
参数修饰符的方法时,如果 struct
被标记为 readonly
,则可以进行一些优化。因为,如果可能发生变异,编译器将创建 struct
的“防御性副本”,以防止对带有 in
修饰符的参数进行可能的变异。
2.1. 示例
让我们看下面的例子。
我们将为我们的示例创建以下 struct
CarStruct
- 可变struct
CarStructI1
- 部分可变/不可变struct
,带有一个隐藏的变异方法CarStructI3
- 标记为“readonly
”的不可变struct
我们将监视在四种不同情况下传递给另一个服务方法的 struct
的地址
- 情况 1:通过 ref(
ref
修饰符)传递可变struct
- 情况 2:按值传递可变
struct
- 情况 3:通过
in
修饰符传递不可变struct
,并对其应用隐藏的变异器 - 情况 4:通过
in
修饰符传递不可变struct
,并应用 getter 方法
通过监视服务方法(TestDefenseCopy
)内部和外部的对象地址,我们将看到是否以及何时创建了“防御性副本”。
//=============================================
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
*/
- 在情况 1 中,可变
struct
是通过ref
修饰符传递的,这意味着它是通过引用传递的,并且可以在TestDefenseCopy
方法内部进行变异。 - 在情况 2 中,可变
struct
在没有修饰符的情况下传递,这意味着它是按值传递的,并且副本在TestDefenseCopy
方法内部被变异,但原始对象不受影响。 - 在情况 3 中,不可变
struct
是通过in
修饰符传递的,这意味着它是通过引用传递给TestDefenseCopy
方法的。但是,当调用进行隐藏变异的方法时,编译器创建了一个“防御性副本”并变异了该副本。我们可以看到从该隐藏变异方法内部获取的address-3d
不同于car3
的原始地址。令人困惑的是,稍后为car3
获取的地址再次指向car3
的原始副本。我期望在TestDefenseCopy
方法开始时创建一个“防御性副本”,并将其分配给car3
局部变量。 - 在情况 4 中,不可变
struct
是通过in
修饰符传递的,这意味着它是通过引用传递给TestDefenseCopy
方法的。调用readonly
方法不会创建任何“防御性副本”,如address-4d
所示。
2.2. 反编译示例为 IL
由于代码行(*1)中的行为有点奇怪,如果忽略的话肯定很难找到。我期望“防御性副本”会一直存在于 TestDefenseCopy
方法的整个过程中,但随后获取的地址表明它只是当场创建并丢弃的。我决定反编译程序集,查看 IL 中发生了什么。我使用 dotPeek 反编译了程序集,这是 IL 中的 TestDefenseCopy
方法。
.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
我用(*1)和(*2)标记了 IL 中与 C# 中相同行的代码行。我用(*??)标记了处理(*1)和(*2)之间的 IL 差异。这是我从 IL 中读取到的内容:
- 在(*31)处我们看到参数,看起来
car3
是“按引用”传递的,这没问题。 - 在(*32)处它被标记为
readonly
,所以没问题。 - 在(*33)处,看起来创建了一个类型为
CarStructI1
的局部变量 [1]。这确实是“防御性副本”的占位符。 - 在(*34)处,索引为 2 的参数(即 car3 的地址)被加载到评估堆栈中。
- 在(*35)处,堆栈上的
E5_ImmutableDefensiveCopy.CarStructI1
类型的对象被加载到评估堆栈中。 - 在(*36)处,堆栈上的对象被复制到(*33)中定义的局部变量中。因此,“防御性副本”在此局部变量中创建。
- 在(*37)处,该局部变量(*33)的地址被推送到堆栈。
- 在(*38)处,我们在(*33)中的局部变量上调用了
HiddenMuttatorMethod
方法。因此,原始struct
(由(*31)处的地址指向)不受影响。因此,我们可以在这里看到HiddenMuttatorMethod
方法是在“防御性副本”上执行的。 - 在(*39)处,当我们要获取
car3
对象的地址时,再次从(*31)处加载原始地址。这解释了为什么在这个实例中我们没有看到地址的变化。老实说,**我期望在这里得到(*33)中定义的局部变量的地址**。但我觉得正常的情况并不是实际的工作方式。因此,在这里我们获取的是原始对象car3
的地址,而不是“防御性副本”的地址。 - 在(*42)处,我们看到了(*1)car 3 和(*2)car4 之间的区别,即地址(*41)直接加载到堆栈,并且
GetterMethod
方法直接在car4
的原始实例上操作。在这种情况下,没有使用“防御性副本”。
我并不完全清楚“防御性副本”为何如此工作,但这就是 IL,这就是现实世界。我期望“防御性副本”一旦创建,就会一直用于创建它的方法内部。但我刚才看到的是,有时编译器会使用“防御性副本”,有时会使用原始的只读对象引用。IL 不会撒谎。这个例子是用 .NET 7/C#11 创建的。
3. 结论
我们解释了“防御性副本”的概念,并给出了一个例子。关于“防御性副本”的行为,我个人没有看到 [5] 中描述的“确切”行为,但我确实看到了与所描述的“类似”行为。甚至有可能不同版本的 .NET 和 C# 编译器在实现细节上有所不同。
4. 参考资料
- [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://codeproject.org.cn/Articles/5353999/Csharp11-Immutable-Object-Pattern
5. 历史记录
- 2023 年 2 月 7 日:初始版本