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

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

===

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

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

创建委托其实就是创建了一个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.

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.

所以说,delegate关键字其实只是个修饰用的单词,背后都是由C#编译器来重写代码的,就相当于在编译时把delegate这一句换掉,变成class并继承System.MulticastDelegate的类。

那么什么是event?

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

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

为什么要限制?

因为公开的委托会直接暴露在外,随时会被‘=’赋值而清空了前面累积起来的委托链表,造成不可预测的问题。申明 event 后,编译器内部重新封装了委托,让暴露在外面的委托不再担心随时被清空和重置的危险了。因为经过 event 封装后不再提供赋值操作来清空前面的累加,只能一个个注册或者一个个注销委托(或者说函数地址)。

装箱和拆箱

什么是装箱和拆箱。

很简单,把值类型实例转换为引用类型实例,就是装箱。

相反,把引用类型实例转换为值类型实例,就是拆箱。

再简单点:

int a = 5;

object obj = a;

就是装箱

继续上面代码

a = (int)obj;

就是拆箱。

为何需要装箱。

值类型是在栈中分配内存,在声明时初始化才能使用,不能为null。

引用类型在堆中分配内存,初始化时默认为null。

值类型有,所有整数,浮点数,bool,以及 Struct 申明的结构

引用类型有,类,接口,委托(委托也是类),数组以及内置的object与string。

这里又引申出来,为什么要分栈内存和堆内存,简单快速阐述下:

    栈是本着先进后出的数据结构(LIFO)原则的存储机制, 对栈数据的定位比较快速, 而堆则是随机分配的空间, 处理的数据比较多, 无论如何, 至少要两次定位。堆内存的创建和删除节点的时间复杂度是O(logn)。栈创建和删除的时间复杂度则是O(1),栈速度更快。

    既然栈速度这么快,全部用栈不就好了。这又涉及到生命周期问题,由于栈中的生命周期是必须确定的,创建后什么时候销毁是一个定量,所以在分配和销毁时不灵活,相反堆内存可以存放生命周期不确定的内存块,满足当需要删除时再删除的需求,所以堆内存相对于引用类型的内存块更适合。

因此在当栈内存和堆内存相互转换时,有了装箱和拆箱的过程。大部分时候只有当程序、逻辑或接口需要更加通用的时候才会需要装箱。

比如调用一个含类型为object的参数的方法,该object可支持任意为型,以便通用。当你需要将一个值类型(如Int32)传入时,就需要装箱。

又比如一个非泛型的容器,同样是为了保证通用,而将元素类型定义为object。于是,要将值类型数据加入容器时,需要装箱。

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

装箱: 对值类型在堆中分配一个对象实例,并将该值复制到新的对象中。按三步进行。

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

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

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

拆箱:检查对象实例,确保它是给定值类型的一个装箱值。将该值从实例复制到值类型变量中。

装箱、拆箱对执行效率有哪些影响,如何优化。

由于装箱、拆箱时生成的是全新的对象,不断得分配内存会有时间损耗和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里拿到的参数就不用再担心装箱拆箱问题了。

感谢您的耐心阅读

Thanks for your reading

  • 版权申明

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

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

    Copyright attention

    Please don't reprint without authorize.

  • 微信公众号