《Unity3D高级编程之进阶主程》第一章,C#要点技术(四) 委托、事件、装箱、拆箱

委托(delegate)与事件(Event)的实质

===

使用过C或C++的同学都对指针很清楚,指针是个需要谨慎对待的东西,它不仅仅可以指向变量的地址,还可以指向函数的地址,本质上它是指向内存的地址。

在C#中万物皆是类,大部分时间里都没有指针的身影,最多也只是引用,因为指针被封装在内部函数当中。不过回调函数却依然存在,于是C#多了一个委托(delegate)的概念,所有函数指针都以委托的方式来完成的。委托可以被视为一个更高级的函数指针,它不仅仅能把地址指向另一个函数,而且还能传递参数、获得返回值等多个信息。系统还为委托对象自动生成了同步、异步的调用方式,开发人员使用 BeginInvoke、EndInvoke 方法就可以避开 Thread类 从而直接使用多线程调用。

那么究竟委托(delegate)在C#中是如何实现的呢?我们来一探究竟。

首先不要错误的认为委托是一个语言的基本类型,我们在创建委托delegate时其实就是创建了一个delegate类实例,这个delegate委托类继承了System.MulticastDelegate类,类实例里有,BeginInvoke、EndInvoke、Invoke三个函数,分别表示,异步开始调用,结束异步调用,以及直接调用。

不过我们不能直接写个类来继承 System.MulticastDelegate类,因为它不允许被继承在明文上,它的父类Delegate类也同样有这个规则,官方文档中写的就是这么个规则:

	MulticastDelegate is a special class. Compilers and other tools can derive from this class, but you cannot derive from it explicitly. The same is true of the Delegate class.

	MulticastDelegate是一个特殊的类,编译器或其他工具可以从它这里继承,但你却不能直接继承它。Delegate类也有同样的规则。

Delegate类中有个变量是用来存储函数地址的,当变量操作 =(等号) 时,把函数地址赋值给变量存起来。不过这个存储函数地址的变量是个可变数组,你可以认为是个链表,每次直接赋值时会换一个链表。

Delegate委托类还重写了 +=,-= 这两个操作符,其实就是对应 MulticastDelegate 的 Combine 和 Remove 方法,当对函数操作 += 和 -= 时,相当于把函数地址推入了链表尾部,或者移出了链表。

当委托被调用时,委托实例会把所有链表里的函数依次按顺序用传进来的参数调用一遍。官方文档中写的就是如上述所说:

A MulticastDelegate has a linked list of delegates, called an invocation list, consisting of one or more elements. When a multicast delegate is invoked, the delegates in the invocation list are called synchronously in the order in which they appear. If an error occurs during execution of the list then an exception is thrown.

MulticastDelegate 中有一个已经连接好delegate的列表,被称为调用列表,它由一个或者更多的元素组成。当一个multicast delegate被启动调用时,所有在调用列表里的 delegate 都会被按照它们出现的顺序调用。如果一个错误在执行列表期间遇到就会立马抛出异常并停止调用。

看到这里我们彻底明白了原来 delegate 关键字其实只是个修饰用词,背后是由C#编译器来重写的代码,我们可以认为是编译时把delegate这一句换成了Delegate,从而变成了一个class它继承System.MulticastDelegate类。

那么什么是event,它和delegate又有什么关系?

event 很简单,它在委托delegate上,又做了一次封装,这次封装的意义是,限制用户直接操作delegate委托实例中变量的权限。

封装后,用户不再能够直接用赋值(即使用 = 等号操作符)操作来改变委托变量了,只能通过注册或者注销委托的方法来增减委托函数的数量。也就是说被 event 声明的委托不再提供 ‘=’ 的操作符,但仍然有 ‘+=’ 和 ‘-=’ 的操作符可供操作。

为什么要限制呢?因为在平时的编程中,由于项目太过庞大,经手的人员数量太多,导致我们常常无法得知其他人在编写的代码是什么有什么意图,这样公开的delegate委托会直接暴露在外,随时会被‘=’赋值而清空了前面累积起来的委托链表,委托的操作权限范围太大导致问题会比较严重。申明 event 后,编译器内部重新封装了委托,让暴露在外面的委托不再担心随时被清空和重置的危险。因为经过 event 封装后不再提供赋值操作来清空前面的累加,只能一个个注册或者一个个注销委托(或者说函数地址),这样就保证了谁注册就必须谁负责销毁的目的,更好的维护了delegate的秩序。

装箱和拆箱

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

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

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

int a = 5;

object obj = a;

就是装箱,因为a 是值类型是直接有数据的变量,obj为引用类型是指针与内存是拆分开来的,把 a 赋值给 b 实际上就是 b 为自己创建了一个指针并指向了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#里万物皆是类。

话锋一转这里稍微阐述下堆和栈内存,因为很多人都错误的认识了堆栈内存,为什么要分栈内存和堆内存,用简短的语言阐述下:

	栈是本着先进后出的数据结构(LIFO)原则的存储机制,它是一段连续的内存,所以对栈数据的定位比较快速, 而堆则是随机分配的空间, 处理的数据比较多, 无论如何, 至少要两次定位。堆内存的创建和删除节点的时间复杂度是O(logn)。栈创建和删除的时间复杂度则是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 值类型数据结构如果没有理解它的原理用起来可能会引起很多麻烦,切记盲目认为使用结构体会让性能提升,在没有完全彻底理解之前就冒然大量使用可能会对你的程序性能带来重创。

· 书籍著作, Unity3D, 前端技术

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

    本文为博主原创文章,未经允许不得转载:

    《Unity3D高级编程之进阶主程》第一章,C#要点技术(四) 委托、事件、装箱、拆箱

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号,文章同步推送,致力于分享一个资深程序员在北上广深拼搏中对世界的理解

    QQ交流群: 777859752 (高级程序书友会)