Unity3D高级编程:主程手记
上QQ阅读APP看书,第一时间看更新

2.5.2 装箱和拆箱

什么是装箱和拆箱?其实很简单,把值类型实例转换为引用类型实例,就是装箱。反之,把引用类型实例转换为值类型实例,就是拆箱。

针对这个解释,可能有读者还有疑问:什么是值类型?什么是引用类型?值类型的变量会直接存储数据,如byte、short、int、long、float、double、decimal、char、bool和struct,统称为值类型;而引用类型的变量持有的是数据的引用,其真实数据存储在数据堆中,如所有的class实例的变量、string和class,统称为引用类型。当声明一个类时,只在堆栈(堆或栈)中分配一小片内存用于容纳一个地址,而此时并没有为其分配堆上的内存空间,因此它是空的,为null,直到使用new创建一个类的实例,分配了一个堆上的空间,并把堆上空间的地址保存给这个引用变量,这时这个引用变量才真正指向内存空间。

我们解释得再通俗点,举个例子来说明:


int a = 5;

object obj = a;

这就是装箱,因为a是值类型,是直接有数据的变量,obj为引用类型,指针与内存拆分开来,把a赋值给obj,实际上就是obj为自己创建了一个指针,并指向了a的数据空间。继续上面的代码:


a = (int)obj;

这就是拆箱,相当于把obj指向的内存空间复制一份交给了a,因为a是值类型,所以它不允许指向某个内存空间,只能靠复制数据来传递数据。

为何需要装箱?

值类型是在声明时就初始化了的,因为它一旦声明,就有了自己的空间,因此它不可能为null,也不能为null。而引用类型在分配内存后,它其实只是一个空壳子,可以认为是指针,初始化后不指向任何空间,因此默认为null。

值类型包括所有整数、浮点数、bool和Struct声明的结构。这里要注意Struct部分,这是我们经常犯错误的地方,很多人会把它当作类来使用,这是错误的行为。因为它是值类型,在复制操作时是通过直接复制数据完成操作的,所以常常会有a、b同是结构的实例,a赋值给了b,在b更改了数据后,发现a的数据却没有同步的疑问出现,事实上,它们根本就是两个数据空间,在a赋值给b时,其实并不是引用复制,而是整个数据空间复制,相当于a、b为两个不同的西瓜,只是长得差不多而已。

引用类型包括类、接口、委托(委托也是类)、数组以及内置的object与string。前面说了,delegate也是类,类都是引用类型,虽然有点问题,也不妨碍它是一个比较好记的口号。虽然int等值类型也都是类,但它们是特殊的类,是值类型的类,因为在C#里万物皆是类。

这里稍微阐述一下堆内存和栈内存,因为很多人对堆栈内存有错误认知。

栈是用来存放对象的一种特殊的容器,它是最基本的数据结构之一,遵循先进后出的原则。它是一段连续的内存,所以对栈数据的定位比较快速;而堆则是随机分配的空间,处理的数据比较多,无论情况如何,都至少要两次才能定位。堆内存的创建和删除节点的时间复杂度是O(lgn)。栈创建和删除的时间复杂度则是O(1),栈速度更快。

既然栈速度这么快,全部用栈不就好了?这又涉及生命周期问题,由于栈中的生命周期必须确定,销毁时必须按次序销毁,即从最后分配的块部分开始销毁,创建后什么时候销毁必须是一个定量,所以在分配和销毁上不灵活,它基本都用于函数调用和递归调用这些生命周期比较确定的地方。相反,堆内存可以存放生命周期不确定的内存块,满足当需要删除时再删除的需求,所以堆内存相对于全局类型的内存块更适合,分配和销毁更灵活。

很多人把值类型与引用类型归为栈内存和堆内存分配的区别,这是错误的,栈内存主要为确定性生命周期的内存服务,堆内存则更多的是无序的随时可以释放的内存。因此值类型可以在堆内也可以在栈内,引用类型的指针部分也一样,可以在栈内和堆内,区别在于引用类型指向的内存块都在堆内,一般这些内存块都在委托堆内,这样便于内存回收和控制,我们平时所说的GC机制就会做些回收和整理的事。也有非委托堆内存不归委托堆管理的部分,它们是需要自行管理的,比如C++编写一个接口生成一个内存块,将指针返回给了C#程序,这个非委托堆内存就需要我们自行管理,C#也可以自己生成非委托堆内存块。

大部分时候,只有当程序、逻辑或接口需要更加通用的时候才需要装箱。比如调用一个含类型为object的参数的方法,该object可支持任意类型,以便通用。当你需要将一个值类型(如Int32)传入时,就需要装箱。又比如一个非泛型的容器为了保证通用,而将元素类型定义为object,当值类型数据加入容器时,就需要装箱。

下面我们来看看装箱的内部操作。

根据相应的值类型在堆中分配一个值类型内存块,再将数据复制给它,这要按三步进行。

第一步:在堆内存中新分配一个内存块(大小为值类型实例大小加上一个方法表指针和一个SyncBlockIndex类)。

第二步:将值类型的实例字段复制到新分配的内存块中。

第三步:返回内存堆中新分配对象的地址。这个地址就是一个指向对象的引用。

拆箱则更为简单,先检查对象实例,确保它是给定值类型的一个装箱值,再将该值从实例复制到值类型变量的内存块中。

装箱、拆箱对执行效率有什么影响,如何优化?

由于装箱、拆箱时生成的是全新的对象,不断地分配和销毁内存不但会大量消耗CPU,同时也会增加内存碎片,降低性能。那该如何做呢?

我们需要做的就是减少装箱、拆箱的操作。在编程规范中要牢记减少这种浪费CPU内存的操作,在平时编程时要特别注意。

整数、浮点数、布尔等值类型变量的变化手段很少,主要靠加强规范、减少装拆箱的情况来提高性能。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值类型数据结构的原理,用起来可能会存在很多麻烦,不要盲目认为使用结构体会让性能提升,在没有完全彻底理解之前就贸然大量使用结构体可能会对你的程序性能带来重创。