目录
1. 不可变对象定义
C#中的不可变对象(内部不可变性)是创建其内部状态后无法更改的对象。这与普通对象(可变对象)不同,后者的内部状态通常在创建后可以更改。C#对象的不可变性在编译时强制执行。不可变性是一种编译时约束,它指示程序员可以通过对象的正常接口执行哪些操作。
有一个小的混淆,因为有时在不可变对象下,假设以下定义:
C#中的不可变对象(观察不可变性)([2])是创建后无法更改其public状态的对象。在这种情况下,我们不关心对象的内部状态是否随时间变化,如果public,可观察状态总是相同的。对于代码的其余部分,它始终显示为同一对象,因为这就是它的显示方式。
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关键字标记所有public属性来创建基于-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没有区别,当所有属性/方法都被标记为readonly除了在struct级别定义上很容易看到struct的意图是什么,即struct创建者从一开始就计划它是不可变的。
ublic 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关键字标记所有public属性来创建基于-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. 不可变对象(基于类)和非破坏性突变
对于基于类的不可变对象,他们没有使用new with关键字扩展C#语言,但仍然可以轻松自定义编程相同的功能。下面是一个示例:
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] Immutability in C# Part One: Kinds of Immutability | Fabulous adventures in coding
- [3] Structure types - C# reference - C# | Microsoft Learn
- [4] with expression - create new objects that are modified copies of existing objects - C# | Microsoft Learn
- [5] Avoid memory allocations and data copies - C# | Microsoft Learn
- [6] c# - Is this a defensive copy of readonly struct passed to a method with in keyword - Stack Overflow
- [7] https://levelup.gitconnected.com/defensive-copy-in-net-c-38ae28b828
- [8] https://blog.paranoidcoding.com/2019/03/27/readonly-struct-breaking-change.html
https://www.codeproject.com/Articles/5353999/Csharp11-Immutable-Object-Pattern