摘要
最近在Udemy上学习了C#进阶知识,以后会定期分享一下。
本篇文章主要讨论在C#中Mutable Structures可能会带来的问题及如何避免。
Struct & Class区别
当面试官问到在C#中,Struct和Class之间有何区别的时候,相信大部分的回答是:
关于引用类型和值类型的区别,相信很多面试官会问。这里用两幅图回忆一下,图片来自《C# 图解教程》:Class对象是引用类型,其数据存储在堆中;
Struct对象是值类型,其数据存储在栈中。
给出一个经常看到的实例:
public struct StructTest
{
public int X;
public StructTest(int x)
{
X = x;
}
}
public class ClassTest
{
public int X;
public ClassTest(int x)
{
X = x;
}
}
上面的代码,先分别给出一个结构体和一个类
随后测试代码:
public static void Main(string[] args)
{
//分别创建结构体对象、类对象
var structObject = new StructTest(1);
var classObject = new ClassTest(1);
//分别将两个对象进行复制,得到两个新的副本对象
var structObject1 = structObject;
var classObject1 = classObject;
//更改副本对象中的成员
structObject1.X++;
classObject1.X++;
//查看原始对象中的成员
Console.WriteLine(structObject.X);
Console.WriteLine(classObject.X);
}
打印结果如下:
1
2
显而易见证实了这一点。
Tips: 在Visual Studio中可以打断点查看,然后在监视窗口中输入: '&'+变量名;可以看到变量的物理地址;
另外我还不太清楚如何在Jetbrain Rider中查看变量的地址,如果有读者知道请在评论区告诉我;
当然,这样回答完全正确,但是更专业的回答是:
Structures implement the semantic of copying by value, whereas classes implement the semantic of copying by reference.
可以理解为:结构体对象的复制行为从语法角度来说是值传递,而类对象的复制行为从语法角度来说是引用传递。
Mutable:可异变的。
下面分享一下Mutable Structures可能会带来的意外问题以及如何避免。
案例&分析:
案例1:
//使用Struct后可能会出现问题的代码
public struct Customer {
public int Age { get; private set; }
public Customer(int age) : this() {
Age = age;
}
public int IncrementAge() {
Age++;
return Age;
}
}
public class CustomerClient {
public Customer PropertyCustomer { get; } = new Customer(1);
public readonly Customer ReadonlyCustomer = new Customer(1);
public Customer FieldCustomer = new Customer(1);
}
在上面的代码中,新建了一个结构体Customer,里面有:
- 一个Int类型成员变量Age;
- 还有一个方法IncrementAge(),它负责使Age自增1,并返回Age的值。
很快,你就会发现这样做出现问题了!
接下来有一个类CustomerClient,里面有三个Customer类型的成员,这三个成员分别如下:
- 第一个是仅可访问的属性PropertyMutable;
- 第二个是添加了readonly关键字的成员;
- 第三个是没有任何关键字修饰的成员;
测试:
测试的过程:分三组;每组先打印结构体成员中的Age,再调用自增方法,最后再打印一次Age,看有没有增加;
public class CustomerClient {
public void Test() {
Console.WriteLine("Property case");
Console.WriteLine((int) PropertyCustomer.Age);
PropertyCustomer.IncrementAge();
Console.WriteLine((int) PropertyCustomer.Age);
Console.WriteLine("Readonly case");
Console.WriteLine((int) ReadonlyCustomer.Age);
ReadonlyCustomer.IncrementAge();
Console.WriteLine((int) ReadonlyCustomer.Age);
Console.WriteLine("Field case");
Console.WriteLine((int) FieldCustomer.Age);
FieldCustomer.IncrementAge();
Console.WriteLine((int) FieldCustomer.Age);
}
}
class Program
{
public static void Main(string[] args)
{
CustomerClient a = new CustomerClient();
a.Test();
}
}
测试结果:
Property case
1
1
Readonly case
1
1
Field case
1
2
Process finished with exit code 0.
分析:为什么会出现这种情况?
-
第一个试验,属性中的get访问器从语法上来讲:它返回一个拷贝。
因此每次get得到的Customer对象,都要先发生复制动作。之前说过:结构体的复制语义上都是值传递。因此,每次get结构体实例的时候,都是重新拷贝一个结构体实例。 -
第二个试验,看起来我们直接获取到了结构体变量。但是有
readonly
这个关键字修饰。经过readonly
关键字修饰的变量在初始化之后就无法更改了;因此等同于,编译器做了如下操作:
Customer tmp1 = a.ReadonlyCustomer;
Console.WriteLine(tmp1.Age);
Customer tmp2 = a.ReadonlyCustomer;
tmp2.IncrementAge();
Customer tmp3 = a.ReadonlyCustomer;
Console.WriteLine(tmp3.Age);
编译器默默对其进行了拷贝,防止对其进行修改
其实在微软官方文档也说明了:https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/readonly
The
readonly
modifier on a struct definition declares that the struct is immutable. Every instance field of the struct must be markedreadonly
注意:以下代码为C# 7.2及以上的语法特性
public readonly struct Point
{
public double X { get; }
public double Y { get; }
public Point(double x, double y) => (X, Y) = (x, y);
public override string ToString() => $"({X}, {Y})";
}
The preceding example uses readonly auto properties to declare its storage. That instructs the compiler to create
readonly
backing fields for those properties.
意思是说这样定义的readonly的结构体是Immutable的;并且,编译器会默认给里面所有的属性成员赋予readonly关键字,等同于下面这段:
public readonly struct Point
{
public readonly double X;
public readonly double Y;
public Point(double x, double y) => (X, Y) = (x, y);
public override string ToString() => $"({X}, {Y})";
}
错误CS8340Adding a field not marked readonly generates compiler error CS8340: "Instance fields of readonly structs must be readonly."
如果struct使用了readonly
修饰,而它的某些实例字段没有使用了readonly
,编译器则会报错
- 第三个试验,并没有复制的动作。每次调用的都是a的引用中的结构体实例,因此每次操作都会对之前的数据产生影响。
改进方法:
那么如何改进代码,避免出现这种Mutable Structures呢?除了前面所说可以用readonly字段加以修饰之外,还可以用以下方法去避免:
struct Point {
public int X { get; }
public int Y { get; }
public Point(int x, int y) : this() {
X = x;
Y = y;
}
public Point Increment(int x, int y) {
return new Point(x, y);
}
}
首先,将结构体中的所有成员变量设定为只读属性,一旦初始化后就保证之后会对其修改;其次,方法体返回一个新对象,保证了返回的对象是一个副本。
对之前的代码进行修改:
struct Customer {
public int Age {get;}
public Customer (int age) : this() {
Age = age;
}
public int IncrementAge()
{
var age= Age;
age++;
var tmp = new Customer(age);
return tmp.Age;
}
}
通过修改,结果也变为1,1了
案例2:通过数组索引访问成员发生异变
结构体还是和上面一样,只不过换了个名字:
struct Customer {
public int Age { get; private set; }
public Customer(int age) : this() {
Age = age;
}
public int IncrementAge() {
Age++;
return Age;
}
}
然后测试代码如下:
public class CustomerClient {
public void Test() {
var list = new List<Customer> {new Customer(age: 5)};
Console.WriteLine("Initial Age: {0}", list[0].Age);
list[0].IncrementAge();
Console.WriteLine("Modified Age: {0}", list[0].Age);
//----------
var array = new[] {new Customer(age: 5)};
Console.WriteLine("Initial Age: {0}", array[0].Age);
array[0].IncrementAge();
Console.WriteLine("Modified Age: {0}", array[0].Age);
}
}
测试代码分为两部分:
- 第一部分为List列表的操作,使用一对花括号初始化List列表,然后取出列表中的第一个元素进行操作,随后检查结果是否增加;
- 第二部分为数组对象,同样初始化数组后取出第一个元素进行操作,再查看结果是否有改变。
测试结果:
Initial Age: 5
Modified Age: 5
Initial Age: 5
Modified Age: 6
分析:
在测试1,实际上是使用了索引器的方式对List数组进行了访问,索引器在很大程度上类似于属性。那么返回的值,也就和案例一种通过属性访问是一样的道理了。因此就算增加的只是一份值拷贝,执行完后,值就销毁了。
在测试2,看起来和上面相同,也是通过索引进行访问。并且,C#不支持托管指针,不能返回对类的字段的引用以获取实例。然而,编译器能够为了让我们在数组中访问元素对其做了优化(IL-instruction(idelema)能够使我们获取到数组元素的引用拷贝而不是值的拷贝)
这里我也不是太清楚,具体还要去看看CLR Via C#一书是怎么说的
案例3:
public struct MyHandle : IDisposable {
public IntPtr Handle { get; private set; }
public MyHandle(IntPtr preexistingHandle) {
Handle = preexistingHandle;
}
public void Dispose() {
if (Handle != IntPtr.Zero)
CloseHandle(Handle);
Handle = IntPtr.Zero;
}
[ReliabilityContract(Consistency.WillNotCorruptState, Cer.Success)]
[DllImport("kernel32.dll", SetLastError = true)]
internal static extern bool CloseHandle(IntPtr handle);
}
实际上,在.Net框架中,DateTime和TimeSpan都是结构体。其中,DateTime有一个方法是AddHours,例如下面这段代码:
var dateTime = new DateTime();
dateTime.AddHours(1);
Console.WriteLine(dateTime);
发现时间并不会改变;可以看到源码中:
返回了一个新的结构体对象
近况
最近项目特别多,特别特别累。
十一去了神农架,好好放松。那里景色特别美:
这两张看起来像水墨画 雾渐渐消散,好像看到了彩虹
这是用手机拍出来照片,效果还不错;
呃,攒钱买单反吧 :)
网友评论