在 .NET 中存在一个的冲突,值类型不应该被设计为多态类型,但是 .NET Framework 又必须把 System.Object 设计为引用类型,并把它作为整个对象体系的基础。针对这一冲突 .NET 引入了装箱与拆箱。所谓的装箱就是把值类型放在非类型化的引用对象中,使得需要使用引用类型的地方也可以使用值类型,而拆箱指的是把已经装箱的值复制出来一份。在只能使用 System.Object 类型或接口类型的地方使用值类型,那么就必定设计到装箱和拆箱操作。但是装箱和拆箱操作严重的影响了所开发的应用程序的性能,并且在部分情况下还会创建对象的临时拷贝,进而会造成难以查找的 bug 。下面我们就具体来讲解一下如何减少装箱和拆箱。
零、基本方法需要注意
装箱操作会把值类型转换为引用类型,新创建的引用对象被分配在了堆上面,里面包含了对原值的一个拷贝,而且还实现了值类型的所有接口,当有外部代码查询里面的内容时,系统会将里面的原值拷贝一份返回给调用方。这种拷贝仅仅是一次性的,下次再次查询时会重新拷贝一份里面的原值。在 .NET 2.0 以后我们可以使用泛型类型及其方法来取代大部分装箱与拆箱操作,但是 .NET 中依然存在大量的方法接收 System.Object 类型的参数,因此在以值类型为参数调用这些方法的时候依然会发生装箱和拆箱操作。例如下面这段代码就用到了装箱:
int code=100; Console.WriteLine($"我的数学成绩是 {code}");
上面的代码看似简单,实际上系统进行了复杂的操作。首先系统会创建 System.Object 引用构成的数组,然后交给编译器生成的方法去解析,同时因为 code 是值类型的变量因此还需要进行装箱操作。另外代码中隐式的调用了 ToString() 方法,这个操作相当于在装箱后的原值上调用。这些操作类似于如下的代码:
int code = 100; object o = code; Console.WriteLine($"我的数学成绩是 {o.ToString()}");
针对 Object 数组来创建字符串的方法会产生和下面这段代码类似的逻辑来处理 Object :
object para =100; object o=para; int num = (int)o; string output = num.ToString();
如果要避免上述问题,我们可以提前把值手动转化为 string 类型,也就是显示的调用 ToString 方法,这样就可以防止编译器将其隐式的转换为 System.Object 。
int code=100; Console.WriteLine($"我的数学成绩是 {code.ToString()}");
避免装箱第一原则:注意代码中会隐式转换成 System.Object 的位置,避免在不需要使用 System.Object 的地方直接使用值类型。
一、泛型方法需要注意
开发人员还可以使用泛型集合来避免拆箱和装箱操作,但是这里需要注意的是 .NET 第一次实现集合所保存的是指向 System.Object 实例的引用,如果在里面放入值类型就会发生装箱操作,从集合中移除时就会发生拆箱操作。下面我们来可一个简单的例子:
public struct Student { public string Name {get;set;} public override string ToString() { return Name; } } var students = new List<Student>(); Student s = new Student { Name = "张三" }; students.Add(s); Student s1 = students[0]; s1.Name="李四"; Console.WriteLine(students[0].ToString());
在上述代码中 Student 是值类型,因此即时编译器会创建一个封闭泛型类型,这样就可以让 Student 对象可以以未装箱的形式放入集合中。但是当我们从旁那个集合中取出一个对象时,取出的是这个对象的一个拷贝,因此当我们修改这个对象的 Name 属性是实际上并不是修改的原来那个对象的 Name 属性。当我们在 students[0] 上调用 ToString 方法时又创建了一份拷贝。因此这里我建议将值类型设计为不可变类型。
二、小结
值类型可以转换为指向 System.Object 或其他接口的引用,因为这种转换是默认发生的,因此产生错误后很难排查。并且把值类型当成多态中的类型还会影响程序的应能,因此需要注意把值类型转换为 System.Object 或其他接口的地方。