研一的时候学习C#,发现和自己学的Java不一样,为啥,C#里还有结构体这个东西,这个东西不多余么?今天学习了下结构体,发现,这东西确实挺多余的,没啥用,但是通过对结构体的学习,可以让我更好的理解值类型,C# 中的简单类型,如int、double、bool等都是结构类型。如果需要的话,甚至可以使用结构类型结合运算符运算重载,再为 C# 语言创建出一种新的值类型来。
在进入正题前,先来简单复习下值类型和引用类型:
- 值类型要求直接创建内存拷贝,变量直接包含数据,两个变量不可能引用同一内存位置(ref和out只不过是给变量起了一个别名,实际上传递的还是该变量的地址。一个指向栈里的地址)
- 引用类型在栈里传递的是引用个,是指向堆里的地址。
栈复制的好处是垃圾回收的清理代价低,但是却需要频繁的拷贝,对性能造成影响,如果拷贝值类型的代价比拷贝引用高出四倍,就应该把它考虑设计成引用类型了。
结构
除了string和object是引用类型,其它所有C#内建类型都是值类型。开发者甚至可以通过struct自己定义值类型。
struct Angle { public Angle(int degrees, int minutes, int seconds) { Degrees = degrees; Minutes = minutes; Seconds = seconds; } // Using C# 6.0 read-only, automatically implemented properties public int Degrees { get; } public int Minutes { get; } public int Seconds { get; } public Angle Move(int degrees, int minutes, int seconds) { return new Angle( Degrees + degrees, Minutes + minutes, Seconds + seconds); } }
要创建不可变的值类型,值类型一旦被new出来,就不能修改该实例,要修改应该创建一个新的实例。有几点需要注意:
- 1,结构包含属性、字段,还可以包含方法和构造函数(可以包含静态成员),但不能定义无参的构造函数,只能用系统默认的无参构造函数。
- 2,结构中不能显式的在字段声明中初始化字段,只能通过构造函数来赋值,或者不赋值隐式的使用字段默认值。当然也可以通过只读自动属性来赋默认值
- 3,值类型必须显式的初始化来避免编译时错误,结合第2条,值类型最好通过只读自动属性来初始化字段
- 4,所有值类型都是隐式密封的,所以值类型不能被继承,值类型只能继承自System.ValueType,当然值类型也能实现接口,例如一些比较的接口。
new一个值类型的实例时会发生什么呢?其实也就是new一个结构出来。比较下引用类型和值类型两种不同的new的方式:
- 为引用类型使用new操作符,“运行时”会在托管堆上创建对象的新实例,将所有字段初始化为默认值,再调用构造函数,将对实例的引用以this的形式传递。new操作符最后返回对实例的引用,该引用被拷贝到和变量关联的内存位置。
- 相反,为值类型使用new操作符,“运行时”会在临时存储池中创建对象的新实例,将所有字段初始化为默认值(字段不能声明时初始化),调用构造函数,将临时存储位置作为ref变量以this的形式传递。结果是值被存储到临时存储位置,然后可将该值拷贝到和变量关联的内存位置,所以值类型的变量直接包含它的数据。
和类不同,结构不支持终结器。结构以值的形式拷贝,不像引用类型那样具有“引用同一性”,所以难以知道什么时候能安全执行终结器并释放结构占用的非托管资源。垃圾回收器知道在什么时候没有了对引用类型实例的“活动”引用,可在此之后的任何时间运行终结器。但“运行时”没有任何机制能跟踪值类型在特定时刻有多少个拷贝。
装箱与拆箱
上文讨论过值类型和引用类型的区别,那么如果把值类型转换为它实现的某个接口或者object会怎么样,就像把int类型转为object类型会发生什么呢?看一下实现和CIL代码:
装箱的结果必然是对一个存储位置的引用。该位置表面上包含引用类型的实例,但实际包含值类型的值。这种转换称为装箱(boxing),它具有一些特殊行为。从值类型的变量(直接引用其数据)转换为引用类型(引用堆上的一个位置)会涉及以下几个步骤。
- 首先在堆上分配内存。它将用于存放值类型的数据以及少许额外开销(SyncBlockIndex和方法表指针)。需要这些开销,对象才能看起来像引用类型的托管对象实例。
- 接着发生一次内存拷贝动作,当前存储位置的值类型数据拷贝到堆上分配好的位置。
- 最后,转换结果是对堆上的新存储位置的引用。
相反的过程称为拆箱(unboxing):
- 首先核实已装箱值的类型兼容于要拆箱成的值的类型,也就是说装箱后虽然是object,但它本质是int,所以也是兼容于int类型的。
- 然后拷贝堆中存储的值
- 返回结果是堆上存储的值的拷贝。
关于类型检查需要注意,就是要关心的是值类型实例的真正类型!例如number真正的类型是int,拆箱时就不能直接转为double,必须先转为对应的基础类型再做其他转换。
public class DisplayFibonacci { static void Main() { // ... int number; object thing; double bigNumber; number = 42; thing = number; // ERROR: InvalidCastException // bigNumber = (double)thing; bigNumber = (double)(int)thing; // ... } }
装箱和拆箱如果频繁很可能会引发性能问题。找个小例子分析一下装箱和拆箱:
private static void Main() { List<double> list = new List<double>(); //1,装箱,将值类型装箱为object类型 list.Add((double)0); list.Add((double)1); for (int count = 2; count < list.Count; count++) { //2,先拆箱后装箱,object拆箱为double计算后再装箱到list里 list.Add(((double)list[count - 1] + (double)list[count - 2])); } foreach (double count in list) { //3,先拆箱后装箱,foreach遍历把list里的每一个object都拆箱为count赋值, //然后再通过 Console.Write方法将count装箱为object Console.Write("{0}, ", count); } }
每次装箱都涉及内存分配和拷贝;每次拆箱都涉及类型检查和拷贝。如所有操作都用已拆箱的类型完成,就可避免内存分配和类型检查。显然,可通过避免许多装箱操作来提升代码性能。例如上例的foreach循环可将double换成object来改进。另一个改进是将ArrayList数据类型更改为泛型集合。再来举一个复杂的例子:
public static void Main() { // ... Angle angle = new Angle(25, 58, 23); // Example 1: Simple box operation object objectAngle = angle; // Box 1--- Console.Write(((Angle)objectAngle).Degrees); //方法参数要求值类型 // Example 2: Unbox, modify unboxed value, and discard value ((Angle)objectAngle).MoveTo(26, 58, 23); 2--- Console.Write(", " + ((Angle)objectAngle).Degrees); // Example 3: Box, modify boxed value, and discard reference to box ((IAngle)angle).MoveTo(26, 58, 23); 3--- Console.Write(", " + ((Angle)angle).Degrees); // Example 4: Modify boxed value directly ((IAngle)objectAngle).MoveTo(26, 58, 23); 4--- Console.WriteLine(", " + ((Angle)objectAngle).Degrees); // ... }
这里的Angle 是一个结构体,也就是一个值类型。讨论以上四种情况:
- 第一种情况,输出值为25,装箱返回一个引用类型的地址,然后输出时又进行拆箱返回堆里值的拷贝,所以还是25没有问题。
- 第二种情况,输出值为25,先对引用类型拆箱返回一个值类型的拷贝,然后调用方法修改该拷贝的值为26,但是打印的时候还是打印原objectAngle引用的堆上的值,也就是25.
- 第三种情况,输出值为25,先对值类型angle装箱为接口,在堆上创建值的拷贝,然后修改了拷贝值为26,但是打印的时候其实使用的还是该值类型再次装箱后的值,也就是25
- 第四种情况,输出值为26,将引用类型objectAngle转为接口,不算装箱,修改为26,然后拆箱返回修改值26
其实,最好还是避免在方法调用时候进行装箱,在已装箱的值类型上调用接口方法,可以避免内存开销(每次装箱调用方法都会拷贝一次),以及保证一致性。也就是说尽量用第四种情况,少用第三种情况。
枚举
枚举是由开发者声明的可读性更强的值类型。举一个枚举的小例子,枚举默认第一个枚举值为0:
enum ConnectionState : short { Disconnected, //0 Connecting = 10, //10 Connected, //11 Joined = Connected, //11 Disconnecting //12 }
枚举有以下几点需要注意:
- 枚举总是具有一个基础类型,可以是除了char之外的任何整型。
- 能转换成基础类型,就能转换成枚举类型。该设计的优点在于可在未来的API版本中为枚举添加新值,同时不会破坏早期版本。例如以后如果需要一个新的类型作为枚举,那么只要能转基础类型,就能转。
- 枚举值为已知值提供了名称,同时允许在运行时分配未知的值。该设计的缺点在于编码时须谨慎,要主动考虑到未命名值的可能性。例如,将case ConnectionState.Disconnecting替换成default,并认定default case唯一可能的值就是ConnectionState.Disconnecting,那么就是不明智的。相反,应显式处理Disconnecting这一case,而让default case报告一个错误,或者执行其他无害的行为。
- 从枚举转换为基础类型以及从基础类型转换为枚举类型都涉及显式转型,而不是隐式转型
枚举和字符串的相互转换:
1,使用Enum.Parse()将字符串转换为枚举
public static void Main() { ThreadPriorityLevel priority = (ThreadPriorityLevel)Enum.Parse( typeof(ThreadPriorityLevel), "Idle"); //第一个参数是枚举类型 Console.WriteLine(priority); }
Enum.Parse()方法返回一个object类型的值,然后通过显式方式强转为枚举类型。当然还可以使用TryParse方式:
public static void Main() { if (Enum.TryParse("Idle", out ThreadPriorityLevel priority)) { Console.WriteLine(priority); } }
在转换枚举的同时做是否转换成功的检查。
2,直接使用ToString方法将枚举值转为字符串值
总结一下,这一篇博客其实就是介绍了值类型的本质到底是什么,装箱和拆箱操作的行为底层到底是什么,以及枚举和字符串类型的互转方式。