C#11 - 不可变对象模式
不可变对象模式的入门教程,附带 C# 示例
1. 不可变对象定义
C# 中的不可变对象 (内部不可变性) 是指在创建后其内部状态不能更改的对象。这与普通对象 (可变对象) 不同,普通对象的内部状态通常在创建后可以更改。C# 对象的不可变性在编译时强制执行。不可变性是一种编译时约束,它表明程序员可以通过对象的正常接口进行哪些操作。
这里有一个小小的混淆,因为有时在不可变对象下,会假设以下定义
C# 中的不可变对象 (可观察不可变性) ([2]) 是指在创建后其公共状态不能更改的对象。在这种情况下,我们不关心对象的内部状态是否随时间变化,只要公共的、可观察的状态始终相同。对于代码的其余部分,它总是显示为同一个对象,因为它是这样被看到的。
2. 查找对象地址的实用工具
由于我们将在示例中展示堆栈和堆上的对象,为了更好地展示行为差异,我们开发了一个小工具,它将给出相关对象的地址,因此通过比较地址,可以很容易地看出我们谈论的是相同还是不同的对象。唯一的问题是,我们的地址查找工具有一个限制,即它只适用于堆上不包含其他堆上对象(引用)的对象。因此,我们被迫在对象中只使用原始值,这就是我需要避免使用 C# string
而只使用 char
类型的原因。
这是那个地址查找工具。我们创建了两个,一个用于基于 class
的对象,另一个用于基于 struct
的对象。问题是我们想避免基于 struct
的对象的装箱,因为那会给我们装箱对象在堆上的地址,而不是原始对象在堆栈上的地址。我们使用泛型来阻止不正确地使用这些工具。
public static Tuple<string?, string?>
GetMemoryAddressOfClass<T1, T2>(T1 o1, T2 o2)
where T1 : class
where T2 : class
{
//using generics to block structs, that would be boxed
//so we would get address of a boxed object, not struct
//works only for objects that do not contain references
// to other objects
string? address1 = null;
string? address2 = null;
GCHandle? handleO1 = null;
GCHandle? handleO2 = null;
if (o1 != null)
{
handleO1 = GCHandle.Alloc(o1, GCHandleType.Pinned);
}
if (o2 != null)
{
handleO2 = GCHandle.Alloc(o2, GCHandleType.Pinned);
}
if (handleO1 != null)
{
IntPtr pointer1 = handleO1.Value.AddrOfPinnedObject();
address1 = "0x" + pointer1.ToString("X");
}
if (handleO2 != null)
{
IntPtr pointer2 = handleO2.Value.AddrOfPinnedObject();
address2 = "0x" + pointer2.ToString("X");
}
if (handleO1 != null)
{
handleO1.Value.Free();
}
if (handleO2 != null)
{
handleO2.Value.Free();
}
Tuple<string?, string?> result =
new Tuple<string?, string?>(address1, address2);
return result;
}
public static unsafe string?
GetMemoryAddressOfStruct<T1>(ref T1 o1)
where T1 : unmanaged
{
//In order to satisfy this constraint "unmanaged" a type must be a struct
//and all the fields of the type must be unmanaged
//using ref, so I would not get a value copy
string? result = null;
fixed (void* pointer1 = (&o1))
{
result = $"0x{(long)pointer1:X}";
}
return result;
}
3. 可变对象(基于类的)示例
这是一个可变对象(基于类的)的示例,这意味着它在托管堆上。这是一个示例执行和变异。然后是执行结果
public class CarClass
{
public CarClass(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}";
}
}
//============================================
//===Sample code==============================
//class based objects
Console.WriteLine("-----");
Console.WriteLine("Mutation of mutable class object");
CarClass car1 = new CarClass('T', 'C', 2022);
Console.WriteLine($"Before mutation: car1={car1}");
car1.Model = 'A';
Console.WriteLine($"After mutation: car1={car1}");
Console.WriteLine();
//--assigning class based objects
Console.WriteLine("-----");
Console.WriteLine("Assignment of mutable class object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two references pointing to the same object on heap ");
CarClass car3 = new CarClass('T', 'C', 1991);
CarClass car4 = car3;
Tuple<string?, string?> addresses1 = Util.GetMemoryAddressOfClass(car3, car4);
Console.WriteLine($"Address car3={addresses1.Item1}, Address car4={addresses1.Item2}");
Console.WriteLine($"Before mutation: car3={car3}");
Console.WriteLine($"Before mutation: car4={car4}");
car4.Model = 'Y';
Console.WriteLine($"After mutation: car3={car3}");
Console.WriteLine($"After mutation: car4={car4}");
Console.WriteLine();
//============================================
//===Result of execution======================
/*
-----
Mutation of mutable class object
Before mutation: car1=Brand:T, Model:C, Year:2022
After mutation: car1=Brand:T, Model:A, Year:2022
-----
Assignment of mutable class object
From addresses you can see that assignment created
two references pointing to the same object on heap
Address car3=0x21E4F160280, Address car4=0x21E4F160280
Before mutation: car3=Brand:T, Model:C, Year:1991
Before mutation: car4=Brand:T, Model:C, Year:1991
After mutation: car3=Brand:T, Model:Y, Year:1991
After mutation: car4=Brand:T, Model:Y, Year:1991
*/
众所周知,类类型具有引用语义 ([3]),赋值只是引用的赋值,指向同一个对象。因此,赋值只是复制了一个引用,我们有这样一种情况:两个引用指向堆上的一个对象,无论我们使用哪个引用,该对象都被修改了。
4. 可变对象(基于结构的)示例
这是一个可变对象(基于 struct
的)的示例,这意味着它在堆栈上。这是一个示例执行和变异。然后是执行结果
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}";
}
}
//=============================================
//===Sample code===============================
//struct based objects
Console.WriteLine("-----");
Console.WriteLine("Mutation of mutable struct object");
CarStruct car5 = new CarStruct('T', 'C', 2022);
Console.WriteLine($"Before mutation: car5={car5}");
car5.Model = 'Y';
Console.WriteLine($"After mutation: car5={car5}");
Console.WriteLine();
//--assigning struct based objects
Console.WriteLine("-----");
Console.WriteLine("Assignment of mutable struct object");
Console.WriteLine("From addresses you can see that assignment created ");
Console.WriteLine("two different object on the stack ");
CarStruct car7 = new CarStruct('T', 'C', 1991);
CarStruct car8 = car7;
string? address7 = Util.GetMemoryAddressOfStruct(ref car7);
string? address8 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address7}, Address car8={address8}");
Console.WriteLine($"Before mutation: car7={car7}");
Console.WriteLine($"Before mutation: car8={car8}");
car8.Model = 'M';
Console.WriteLine($"After mutation: car7={car7}");
Console.WriteLine($"After mutation: car8={car8}");
Console.WriteLine();
//=============================================
//===Result of execution=======================
/*
Mutation of mutable struct object
Before mutation: car5=Brand:T, Model:C, Year:2022
After mutation: car5=Brand:T, Model:Y, Year:2022
-----
Assignment of mutable struct object
From addresses you can see that assignment created
two different object on the stack
Address car7=0x2A7F79E570, Address car8=0x2A7F79E560
Before mutation: car7=Brand:T, Model:C, Year:1991
Before mutation: car8=Brand:T, Model:C, Year:1991
After mutation: car7=Brand:T, Model:C, Year:1991
After mutation: car8=Brand:T, Model:M, Year:1991
*/
众所周知,struct
具有值语义 ([3]),在赋值时,会复制该类型的一个实例。这与上面所示的基于类的对象(引用类型)的行为不同。正如我们所看到的,赋值创建了一个新的对象实例,因此变异只影响了新的实例。
5. 不可变对象(基于结构的)示例
5.1. 方法 1 – 只读属性
您可以通过使用 readonly
关键字标记所有 public
属性来创建一个基于 struct
类型的不可变对象。此类属性只能在对象的构造阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是不可能的。
public struct CarStructI1
{
public CarStructI1(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public readonly Char? Brand { get; }
public readonly Char? Model { get; }
public readonly int? Year { get; }
public override readonly string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
//------------------------------------
CarStructI1 car10 = new CarStructI1('T', 'C', 2022);
//next line will not compile, since is readonly property
//car10.Model = 'Y';
//next line will not compile, can not initialize readonly property
//CarStructI1 car11 = new CarStructI1() { Brand = 'A', Model = 'A', Year = 2000 };
5.2. 方法 2 – Init-Setter 属性
您可以通过为 setter 将所有 public
属性标记为 init
关键字来创建一个基于 struct
类型的不可变对象。此类属性只能在对象的构造阶段和对象的初始化阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是可能的。
public struct CarStructI2
{
public CarStructI2(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; init; }
public Char? Model { get; init; }
public int? Year { get; init; }
public override readonly string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
//---------------------------------------
CarStructI2 car20 = new CarStructI2('T', 'C', 2022);
//next line will not compile, since is readonly property
//car20.Model = 'Y';
//this works now
CarStructI2 car21 = new CarStructI2() { Brand = 'A', Model = 'A', Year = 2000 };
5.3. 方法 3 – 只读结构体
您可以通过使用 readonly
关键字标记 struct
来创建一个基于 struct
类型的不可变对象。在这种 struct
中,所有属性都必须标记为 readonly
,并且只能在对象的构造阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是不可能的。在这种情况下,我看不出它与上述方法 1 有什么区别,除了在 struct
级别定义上很容易看出该 struct
的意图,即 struct
创建者从一开始就计划使其不可变。
public readonly struct CarStructI3
{
public CarStructI3(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
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}";
}
}
//--------------------------------------
CarStructI3 car30= new CarStructI3('T', 'C', 2022);
//next line will not compile, since is readonly property
//car30.Model = 'Y';
//next line will not compile, can not initialize readonly property
//CarStructI3 car31 = new CarStructI1() { Brand = 'A', Model = 'A', Year = 2000 };
6. 不可变对象(基于类的)示例
6.1. 方法 1 – 只读属性
您可以通过移除 setter 使所有 public
属性为只读,从而创建一个基于类的 Immutable
对象。此类属性只能由类的 private
成员进行修改。在这种情况下,在对象的初始化阶段设置属性是不可能的。
public class CarClassI1
{
public CarClassI1(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public CarClassI1()
{ }
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
//----------------------------------
CarClassI1 car50 = new CarClassI1('T', 'C', 2022);
//next line will not compile, since is readonly property
//car50.Model = 'Y';
//next line will not compile, can not initialize readonly property
//CarClassI1 car51 = new CarClassI1() { Brand = 'A', Model = 'A', Year = 2000 };
6.2. 方法 2 – Init-Setter 属性
您可以通过为 setter 将所有 public
属性标记为 init
关键字来创建一个基于 class
类型的不可变对象。此类属性只能在对象的构造阶段和对象的初始化阶段进行更改,之后是不可变的。在这种情况下,在对象的初始化阶段设置属性是可能的。
public class CarClassI2
{
public CarClassI2(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public CarClassI2()
{ }
public Char? Brand { get; init; }
public Char? Model { get; init; }
public int? Year { get; init; }
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
//------------------------------------------
CarClassI2 car60 = new CarClassI2('T', 'C', 2022);
//next line will not compile, since is readonly property
//car60.Model = 'Y';
//this works now
CarClassI2 car61 = new CarClassI2() { Brand = 'A', Model = 'A', Year = 2000 };
7. 内部不可变性与可观察不可变性
以上所有情况都是内部不可变性的不可变对象。让我们举一个可观察不可变性的不可变对象的例子。下面就是一个这样的例子。我们基本上缓存了长期价格计算的结果。对象总是报告相同的状态,因此它满足可观察不可变性,但其内部状态会发生变化,因此它不满足内部不可变性。
public class CarClassI1
{
public CarClassI1(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public int? Price
{
get
{
// not thread safe
if (_price== null)
{
LongPriceCalcualtion();
}
return _price;
}
}
private int? _price = null;
private void LongPriceCalcualtion()
{
_price = 0;
Thread.Sleep(1000); //long features calculation
_price += 10_000;
Thread.Sleep(1000); //long engine price calculation
_price += 10_000;
Thread.Sleep(1000); //long tax calculation
_price += 10_000;
}
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}, Price:{Price}";
}
}
//=============================================
//===Sample code===============================
CarClassI1 car50 = new CarClassI1('T', 'C', 2022);
Console.WriteLine($"The 1st object state: car50={car50}");
Console.WriteLine($"The 2nd object state: car50={car50}");
//=============================================
//===Result of execution=======================
/*
The 1st object state: car50=Brand:T, Model:C, Year:2022, Price:30000
The 2nd object state: car50=Brand:T, Model:C, Year:2022, Price:30000
*/
8. 线程安全与不可变性
内部不可变性的不可变对象是天生线程安全的。这源于简单的逻辑,即所有共享资源都是只读的,因此线程之间不可能相互干扰。
可观察不可变性的不可变对象不一定线程安全,上述示例就说明了这一点。获取状态会调用一些 private
的非线程安全方法,最终结果不是线程安全的。如果从两个不同的线程访问,上述对象可能会报告不同的状态。
9. 不可变对象(基于结构的)和非破坏性变异
如果您想重用一个不可变对象,您可以随意多次引用它,因为保证它不会改变。但是,如果您想重用不可变对象的某些数据,但稍微修改一下呢?这就是发明非破坏性变异的原因。在 C# 语言中,现在您可以使用 with
关键字来实现它。通常,您会希望保留不可变对象的大部分状态,但只更改某些属性。以下是 C#10 及以后版本中如何实现的方法。
public struct CarStructI2
{
public CarStructI2(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; init; }
public Char? Model { get; init; }
public int? Year { get; init; }
public override readonly string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
//=============================================
//===Sample code===============================
//struct based objects
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable struct object");
CarStructI2 car7 = new CarStructI2('T', 'C', 1991);
CarStructI2 car8 = car7 with { Brand = 'A' };
string? address1 = Util.GetMemoryAddressOfStruct(ref car7);
string? address2 = Util.GetMemoryAddressOfStruct(ref car8);
Console.WriteLine($"Address car7={address1}, Address car8={address2}");
Console.WriteLine($"State: car7={car7}");
Console.WriteLine($"State: car8={car8}");
Console.WriteLine();
//=============================================
//===Result of execution=======================
/*
-----
Nondestructive Mutation of immutable struct object
Address car7=0xC4A4FCE420, Address car8=0xC4A4FCE410
State: car7=Brand:T, Model:C, Year:1991
State: car8=Brand:A, Model:C, Year:1991
*/
10. 不可变对象(基于类的)和非破坏性变异
对于基于类的不可变对象,C# 语言并没有扩展新的 with
关键字,但仍然可以轻松地自定义编程实现相同的功能。这是一个例子
public class CarClassI4
{
public CarClassI4(Char? brand, Char? model, int? year)
{
Brand = brand;
Model = model;
Year = year;
}
public Char? Brand { get; }
public Char? Model { get; }
public int? Year { get; }
public CarClassI4 NondestructiveMutation
( Char? Brand=null, Char? Model = null, int? Year=null)
{
return new CarClassI4(
Brand ?? this.Brand, Model ?? this.Model, Year ?? this.Year);
}
public override string ToString()
{
return $"Brand:{Brand}, Model:{Model}, Year:{Year}";
}
}
//=============================================
//===Sample code===============================
//class based objects
Console.WriteLine("-----");
Console.WriteLine("Nondestructive Mutation of immutable class object");
CarClassI4 car1 = new CarClassI4('T', 'C', 1991);
CarClassI4 car2 = car1.NondestructiveMutation(Model:'M');
Tuple<string?, string?> addresses2 = Util.GetMemoryAddressOfClass(car1, car2);
Console.WriteLine($"Address car1={addresses2.Item1}, Address car2={addresses2.Item2}");
Console.WriteLine($"State: car1={car1}");
Console.WriteLine($"State: car2={car2}");
Console.WriteLine();
//=============================================
//===Result of execution=======================
/*
-----
Nondestructive Mutation of immutable class object
Address car1=0x238EED63FA8, Address car2=0x238EED63FC8
State: car1=Brand:T, Model:C, Year:1991
State: car2=Brand:T, Model:M, Year:1991
*/
11. 结论
不可变对象模式非常流行,并且经常使用。在这里,我们介绍了在 C# 中创建不可变 struct
和 class
的方法,并给出了一些有趣的示例。
我们讨论了内部不可变性与可观察不可变性,并讨论了线程安全问题。
推荐读者感兴趣的相关概念是值对象和C# 中的记录。
12. 参考文献
- [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
13. 历史
- 2023 年 2 月 7 日:初始版本