C#中装箱和拆箱
定义
把值类型实例转换为引用类型实例就是装箱。反过来,把引用类型转换为值类型就是拆箱。
值类型:值类型的变量会直接存储数据,如byte、short、int、long、float、double、decimal、char、bool和struct,统称为值类型。
引用类型:引用类型的变量持有的是数据的引用,其真实数据存储在数据堆中,如所有的class实例的变量、string和class,统称为引用类型。当声明一个类时,只在堆栈(堆或栈)中分配一小片内存用于容纳一个地址,而此事并没有为其分配堆上的内存空间,因此它是空的,也就是为null,直到使用new关键字创建一个类的实例,分配了一个堆上的空间,并把堆上空间的地址保存给这个引用变量,这时这个引用变量才真正指向内存空间。
举例如下:
int a = 5;//声明一个引用类型变量a
object obj = a; //声明一个引用变量obj,并把a赋值给obj
这就是装箱,因为a是值类型,是直接有数据的变量,obj为引用类型,指针与内存拆分开来,把a赋值给obj,实际上就是obj为自己创建了一个指针,并指向了a的数据空间。
int a = 5;//声明一个引用类型变量a
object obj = a; //声明一个引用变量obj,并把a赋值给obj
a = (int)obj; //将obj赋值给a
这就是拆箱,相当于把obj指向的内存空间复制一份交给了a,因为a是值类型,所以它不允许指向某个内存空间,只能依靠复制数据来传递数据。
作用
了解了装箱拆箱的定义,我们要进一步思考,为什么需要装箱拆箱?他们到底有啥作用?
装箱作用
简单来说,有时候需要将值类型当作对象来处理。
例如,当你需要将值类型存储在集合类(如ArrayList、List)中,或者传递给参数为object类型的方法时。
下面举几个具体的例子:
1. 将值类型存储在集合中:
List<object> myList = new List<object>();
int intValue = 42;
myList.Add(intValue); // 需要装箱
在这个例子中,List<object>
是一个集合,它可以存储各种类型的对象。由于集合存储的是对象,而不是值类型,所以需要将 int
值类型装箱成 object
。
2. 将值类型传递给参数为object
的方法:
void MyMethod(object parameter) {
// ...
}
int intValue = 42;
MyMethod(intValue); // 需要装箱
在这里,MyMethod
方法接受一个 object
类型的参数。当我们将 int
类型的值传递给这个方法时,会发生装箱。
3. 使用非泛型集合类:
ArrayList myList = new ArrayList();
int intValue = 42;
myList.Add(intValue); // 需要装箱
ArrayList
是一个非泛型集合,它可以存储任意类型的对象。当我们向其中添加值类型时,会发生装箱。
拆箱作用
当需要从引用类型中取出值类型的数据时,就会发生拆箱操作。
下面举几个具体的例子:
1. 从集合中取出值类型:
List<object> myList = new List<object>();
int intValue = 42;
myList.Add(intValue); // 装箱
object boxedValue = myList[0];
int unboxedValue = (int)boxedValue; // 拆箱
在这个例子中,myList[0]
返回一个 object
类型,我们需要将其拆箱为 int
类型。
2. 从数组中取出值类型:
object[] objectArray = new object[] { 42, "Hello" }; // 装箱
int unboxedValue = (int)objectArray[0]; // 拆箱
在这个例子中,数组中的元素被装箱成 object
,我们需要将其拆箱为 int
类型。
3. 从方法返回值类型:
object GetObjectValue() {
int intValue = 42;
return intValue; // 装箱
}
object boxedValue = GetObjectValue();
int unboxedValue = (int)boxedValue; // 拆箱
在这个例子中,GetObjectValue
方法返回一个 object
类型的值,我们需要将其拆箱为 int
类型。
更深一层的思考
因为装箱和拆箱的需求通常涉及到值类型与引用类型之间的转换,值类型和引用类型又涉及到堆栈内存,所以这里再深入介绍下它们几个。
值类型
值类型包括所有整数、浮点数、bool和Struct声明的结构体。这里需要注意Struct部分,这是经常犯错的地方,很多人会直接把结构体当做类来用,这是错误的,因为他是值类型,在复制操作时是通过直接复制数据完成操作的,所以常常会有a、b同是结构的实例,a赋值给了b,在b更改了数据之后,发现a的数据却没有同步的疑问出现,事实上,他们根本就是两个数据空间,在a赋值给b时,其实并不是引用复制,而是整个数据空间复制,相当于a、b为两个不同的东西,只是长得差不多而已。
引用类型
引用类型包括类、接口、委托(委托也是类)、数组以及内置的object与string。前面说了delegate也是类,类都是引用类型(注意,说类都是引用类型只是为了方便记,实际上int等值类型也是类,所以只要记住这几个特殊的值类型的类、其他都是引用类型的类就可以了)
栈内存与堆内存:
栈是用来存放对象的一种特殊的容器,是最基本的数据结构之一,遵循先进后出的原则。它是一段连续的内存,所以对栈数据的定位比较快速;而堆则是随机分配的空间,处理的数据比较多,无论情况如何,都至少需要两次才能定位。堆内存的创建和删除节点的时间复杂度是O(lgn)。栈创建和删除的时间复杂度则是O(1),栈速度更快。
既然栈速度快,全部用栈不就好了?这又涉及到生命周期问题,由于栈中的生命周期必须确定,销毁时必须按照次序销毁,即从最后分配的块部分开始销毁,创建后什么时候销毁必须是一个定量,所以在分配和销毁上不灵活,它基本都用于函数调用和递归调用这些生命周期比较确定的地方。相反,堆内存可以存放生命周期不确定的内存块,满足当需要删除时再删除的需求,所以堆内存相对于全局类型的内存块更合适,分配和销毁更灵活。
很多人把值类型与引用类型归为栈内存和堆内存分配的区别,这是错误的,栈内存主要为确定性生命周期的内存服务,堆内存则更多的是无序的随时可以释放的内存。因此值类型可以在堆内也可以在栈内,引用类型的指针部分也一样,可以在栈内和堆内,区别在于引用类型指向的内存块都在堆内,一般这些内存块都在托管堆内,这样便于内存回收和控制,我们平时所说的GC机制就会做些回收和整理的事。也有非托管堆内存、不归托管堆管理的部分,他们是需要自行管理的,比如C++编写一个接口生成一个内存块,将指针返回给了C#程序,这个非托管堆内存就需要我们自行管理,C#也可以自己生成非托管堆内存块。
装箱内部操作
根据相应的值类型在堆中分配一个值类型内存块,再将数据复制给他,这要按三步进行。
第一步:在堆内存中新分配一个内存块(大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex类)。
第二部:将值类型的实例字段复制到新分配的内存块中。
第三步:返回内存堆中新分配对象的地址。这个地址就是一个指向对象的引用。
拆箱内部操作
拆箱比较简单,先检查对象实例,确保它是给定值类型的一个装箱值,再将该值从实例复制到值类型变量的内存块中。
效率及优化
由于装箱、拆箱时生成的是全新的对象,不断的分配和销毁内存不但会大量消耗CPU,同时也会增加内存碎片,降低性能。那该如何做呢?
我们需要做的就是减少装箱、拆箱的操作。在编程规范中要牢记减少这种浪费CPU内存的操作,在平时编程时要特别注意。
整数、浮点数、布尔值等值类型变量的变化手段很少,主要靠加强规范、减少装拆箱的情况来提高性能。但Struct不一样,它既是值类型,又可以像类一样继承,用途多,转换的途径也多,但稍不留神,花样就变成了麻烦,所以这里再讲讲Struct变化后的优化方法。
Struct优化方法
1.Struct通过重载函数来避免拆箱、装箱。
比如常用的ToString()、GetType()方法,如果Struct没有写重载ToString()和GetType()的方法,就会在Struct实例调用它们时先装箱再调用,导致内存块重新分配,性能损耗,所以对于那些需要调用的引用方法,必须重载。
2.通过泛型来避免拆箱、装箱。
不要忘了Struct也是可以继承的,在不同的、相似的、父子关系的Struct之间可以使用泛型来传递参数,这样就不用在装箱后再传递了。
比如B、C继承A,就有这个泛型方法void Test(T t) where T : A
,以避免使用object引用类型形式来传递参数。
3.通过继承统一的接口提前装箱、拆箱,避免多次重复装箱、拆箱
很多时候装箱、拆箱不可避免,这时可以让多种Struct继承某个统一的接口,不同的Struct可以有相同的接口。把Struct传递到其他方法里,就相当于提前进行了装箱操作,在方法中得到的是引用类型的值,并且有它需要的接口,避免了在方法中完成重复多次的装箱、拆箱操作。
比如Struct A
和Struct B
都继承了接口I
,我们调用的方法是void Test(I i)
。当调用Test
方法时,传进去的Struct A
或Struct B
的实例相当于提前执行了装箱操作,Test
方法里拿到参数后就不用再担心内部再次出现装箱、拆箱的问题了。
最后依然要提醒一下,如果没有深入理解Struct值类型数据结构的原理,用起来可能会存在很多麻烦,不要盲目的认为使用结构体会上性能提升,在没有完全彻底理解之前就贸然大量使用结构体,可能会对程序性能带来重创。