一、前言

  本文的动机需求在于对任意一个类型MyClass,可以在不需要书写任何额外代码的同时自动进行深拷贝以及深比较,并且需要保证一定的性能。之前的方案是两年前使用Python正则自动生成代码,性能当然是最高的,但是使用起来比较麻烦:使用Python自动生成代码。最近学习了C#的IL以后,发现了一种不错的解决方案。

二、IL介绍

  IL是C#编译后生成的一种中间代码,之后才会被JIT解释生成对应的机器码进行执行。同时,C#提供了手动书写IL,并转换为方法的API,具体使用方法不做过多赘述,可以参见之前的一篇文章,文中提供了利用IL Emit进行动态创建一个类型的方法:通过IL Emit来创建类型

  简而言之,利用IL可以书写任意类型的C#代码(因为C#本来就被编译生成IL),然后转化成运行时function,这样的性能基本相当于手写C#代码的速度。

三、思路

  需求是对一个类型生成对应的深拷贝代码,然后利用IL进行书写并生成,那么怎么递归获取内部所有成员变量呢,答案是利用反射。当然,反射显然比较慢,于是我们利用空间换时间的思路,将对应类型所生成的深拷贝方法保存在一张表里,下次直接使用即可。

  于是整个流程如下:传入一个类型,检查表内是否已经存在对应深拷贝方法,如果有则直接返回;如果不存在,则利用反射递归查找所有的成员变量,之后利用IL生成相应的方法,存入表中并返回。(显然,第一次生成的时候会有一定开销)

四、应用

  思路其实比较简单,但是实际上细节和坑还是很多的:

  1. 需要熟悉OpCodes码,这部分可以查阅资料进行了解。

  2. 需要知道各种C#代码怎么转换成IL,这部分可以通过将ildasm将C#生成的dll转化成IL进行查看,可百度了解使用方式。

  3. 需要验证生成的IL代码对应怎样的C#代码时,可以利用API将书写的IL代码生成dll,然后使用Reflector进行转化成C#代码,有时候不知道书写的IL代码为何不对时,这个方法非常好用。不过Reflector是收费的,据说ILSpy也可以使用,但是笔者没有用过,以后有机会试试。

  4. 书写IL代码是一件困难的事,因为其语法类似于汇编,所以需要事先构建一个简单的框架来书写面向过程的代码,笔者提供的框架中,利用闭包简单的封装了方便使用的for和if功能,不然用汇编写这些确实有点繁琐。

  5. Debug是一件困难的事。可以在每个OpCodes语句后面加Log,来观察生成的语句,并且书写同样的C#代码使用ildasm生成IL之后进行比对;或者使用第三点来比对C#代码。

五、工程

  笔者提供一个git作为参考,可直接使用。https://github.com/523810185/TypeCmpAndCloneGenerator

  

-----------------------------------------------------------------------------------------------------分割线----------------------------------------------------------------------------------------------------------------

  后面给公司年刊投稿,重写了这篇文章,丰富了内容。(但是不知道怎么放上pdf,只能把整篇文章粘过来了。。格式直接乱了= =)

 
基于IL的C#任意类型高性能自动深拷贝与深比较工具

一、前言:需求与动机

本文最初的需求在于对C#的任意一个类型MyClass,在不需要书写任何额外代码的同时,完成深拷贝和深比较的任务,并且需要保持较高的性能。
可能第一反应,显而易见的可以利用序列化或者反射之类的工具来完成这一需求,但是这些都是性能大户,不能被接受。
最初在W3的时候我采取的方案是在需要的变量上面挂载标签,利用Python正则该标签,并借此对对应变量生成相应的代码,这显然是性能最高的做法,因为已经几乎等同于手写代码了,只是将手写的过程交付给脚本去处理而已。但是这个做法使用起来比较麻烦,并且对于非本地代码,无法完成此操作,因为无法对源代码挂载标签。
最近,在研究了C#的IL以后,发现了一种不错的解决方案。

二、IL介绍

IL是C#编译后生成的一种中间代码,之后运行时才会被JIT解释生成对应的机器码进行执行(当然,AOT下是事先生成机器码的,不过由于AOT本质也是调用了JIT,所以此处混为一谈)。同时,C#提供了手动书写IL,并将其转化为一个运行时函数的API。也就是说,C#可以在运行时,动态的生成一个函数,并进行执行。
这可以带来什么好处呢,第一,我们可以在运行时书写C#代码,这意味着,可以在运行时对任意变量进行任意操作,包括需求中提到的深拷贝与深比较;第二,这几乎可以带来原生代码级别的性能。
下面不妨看一个例子。
在C#上构建这样的代码,并查看其生成的IL代码(后文会提及如何查看):
可以看到,IL代码如同汇编语言一般。下面简单解释以下其中含义:
01~02行,加载参数a和b到内存(之所以参数下标为1和2是因为这是一个成员函数,参数0是隐藏的this),03行执行一次比较,并根据真假将1或者0放置栈顶,05行将这个值存于事先声明的0号局部变量V_0中(见前面的.locals init),06行读取这个值并放置栈顶,07行判断其真假,如果为false则跳到0e行,为true则接着执行后面的代码(其实看到这里可以明白自动生成的IL代码是有冗余的,05行和06行是多余的操作)。
如果为真,接着执行后面的代码(09行),nop表示这行是可以被断点的位置,我们直接无视它,下一行,存储一个4位的int变量1到栈顶,0b行将其存于局部变量V_1中,然后0c行无条件跳转到13行处,13行取出局部变量V_1的值,并返回。也就是说,如果参数1和参数2是相等的,最后返回1(也就是true),显然0e到11行代码是将一个0存储到V_1变量中,这里不再赘述其含义。
那么如何使用C#生成该方法呢,下面对其简单介绍:
由于注释很详细,不再过多解释代码含义,与IL代码基本能一一对应(其中有部分顺序不一样是因为我优化了生成的IL代码,让其更加简洁一些)。
下面我们让调用端生成这个方法,并使用这个动态生成的函数。简单的写一个测试代码如下:
结果为:
显然结果没有任何问题。
你可能会觉得这是在多此一举,直接写一个对应的方法不就行了么?
事实上,这种方法生成的函数是动态的,也就是说,我们可以根据运行时根据不同条件生成不同的函数,甚至根据反射,动态获取类型信息创建更加丰富的运行时函数。

三、思路

以深拷贝为例,需求是对任意类型递归检索其所有内部成员,并一一进行赋值到新的一份内存中,首先我们需要访问所有的内部成员,如何递归获取内部所有成员变量呢,答案是利用反射。
前文提到反射是性能大户,但是借由IL这一工具,我们可以转换思路,利用空间换时间的方法,建立一张类型与其深拷贝器的表,当第一次需要该类型的拷贝器时,我们使用反射去获取类型的所有内部成员,然后使用IL书写拷贝器的代码,生成为一个函数后,保存在表内并返回。此后,再需要获取该类型的拷贝器时,直接从表中取出并返回即可。
那么如何利用反射去获取类型并操作呢?下面我们不妨查看一个自定义类的拷贝方法的C#与IL代码作为例子:
其实C#生成的IL也有一些冗余,简单分析一下各行:
01行,根据构造器创建一个新实例,06行,将这个新实例存入到id为0的局部变量V_0中,07行加载V_0到栈上,08行加载第一个参数(同样的,此时第0个参数为this),09行加载一个成员intVal,此时栈上变为V_0和参数1的intVal值,0e行调用存储intVal字段,对栈上的倒数两个值进行操作,此时倒数第一个值(参数1的intVal值)被存储到倒数第二个值(V_0)的对应字段中。13行读取V_0并加载到栈上,14行将栈顶的值存储到id为1的局部变量V_1中,15行直接跳转到17行(冗余生成的代码),17行加载局部变量V_1,18行作为值返回。
限于篇幅原因,这里只解释如何使用C#书写ldfld和stfld来对成员进行操作,我们只需要使用反射获取类型的field,并且使用Emit来书写IL语句即可:
而对于传入一个任意类型,我们事先不知道其中字段名,这时我们按需使用反射获取所有字段:
对获取到的字段进行操作即可,当然内部字段该递归的还是需要继续递归,这里的细节不再赘述。
需要再次提醒的是,我们采取空间换取时间的做法,只有在第一次获取该类的深拷贝器时,我们才需要使用反射,所以整体的性能是很高的。

四、最终效果展示

在笔者的实现中,TypeUtility提供了如下方法:
对于比较器,通过GetTypeCmp进行返回。
对于拷贝器,提供了三个方法,GetTypeClone对应的是clone(a, b)形式的函数,此时a不需要new出一个新的对象,性能最高,但是需要调用端保证a不为null;GetTypeCloneWithReturn对应的是a = Clone(b)形式的函数,此时必定会返回一个新的对象,性能较低,但是可以保证克隆出来的一定是全新的对象;GetTypeCloneWithReturnAndTwoParms对应的是a = clone(a, b)形式的函数,将a作为第一个参数传入,如果a为null,则使用第二种方式返回一个全新的对象,否则以第一种形式进行拷贝。三种方法各有利弊,目前比较推荐第三种方式,效率最高且限制最少。
下面提供笔者TypeUtility库的用法:
可以看到深比较和深拷贝都是正确运行的。

五、细节、难点与捷径

关于整个实现,思路还是比较简单的,但是实际上细节和坑还是挺多的:
1.需要熟悉IL的OpCodes码,这部分可以查阅资料进行了解。
2.需要知道各种C#代码怎么转换成IL,这部分可以通过使用ildasm将C#生成的dll转化成IL进行查看,可百度了解使用方式。
3.需要验证生成的IL代码对应怎样的C#代码时,可以利用API将书写的IL代码生成为dll,然后使用Reflector进行转化成C#代码,有时候不知道书写的IL代码为何不对时,这个方法非常好用。不过Reflector是收费的,据说ILSpy也可以使用,但是笔者没有用过,以后有机会试试。
4. 书写IL代码是一件困难的事,因为其语法类似于汇编,所以需要事先构建一个简单的框架来书写面向过程的代码,笔者提供的框架中,利用闭包简单的封装了方便使用的for和if功能,不然用汇编写这些确实有点繁琐。
5.Debug是一件困难的事。可以在每个OpCodes语句后面加Log,来观察生成的语句,并且书写同样的C#代码使用ildasm生成IL之后进行比对;或者使用第三点来比对C#代码。

六、性能

在比较性能之前,先介绍一下github上一个称为DeepCopy的深拷贝库,其代码主要是从微软的Orleans框架中适配而来,其github地址为https://github.com/ReubenBond/DeepCopy。
下面将比较一下手写代码、纯反射、本文的方法以及DeepCopy库的深拷贝以及深比较的性能。
数据结构如下:
总共跑1000w次来统计时间:
可以看到,对于深拷贝,ILEmit所需要的时间是手写代码的7倍左右,Orleans的深拷贝是手写代码的50倍左右,纯反射是手写代码的700倍左右;对于深比较,ILEmit是手写代码的2倍左右,纯反射是手写代码的200倍左右。
总体来说,ILEmit的性能可以满意。

七、后记:关于IL的思考与展望

关于使用ILEmit,需要注意的一点是,根据Unity的官方文档,使用IL2CPP打包以后因为没了Mono虚拟机,所以无法使用System.Reflection.Emit库,因此本文提到的方法在使用IL2CPP打包时是无法使用的;另外,在IOS中,由于JIT被禁止,ILEmit也是无法使用的。
但是有一个应用场景非常适用于使用该方法,那就是在Unity编辑器的应用,在编辑器下,对于各种各样的Asset,拷贝和比较方法都是不可或缺的,使用ILEmit,基本可以告别手写深拷贝方法了,甚至IsEqual都可以直接使用该方法来规避掉写无聊的比较代码的过程。事实上,Unity的一个编辑器扩展工具Odin也是基于IL来动态创建类型的。
IL还可以用来做什么呢?如果我们拥有一个AST,对一段C#代码进行语法分析,随后使用ILEmit去生成一个动态方法,那么其实离一个C#的REPL就不远了。我们可以使用此方法来搭建一个Unity的调试器,在运行时动态执行一些C#代码。事实上,Odin中的标签中代码执行也是使用了AST+ILEmit的方法来实现的。
不过需要注意的是,ILEmit和ILRuntime实际上是没有什么关系的,后者是通过某些手段获取了dll中的OpCodes码,之后使用内部搭建的栈,模拟了IL代码被执行的过程。
最后,不得不说,IL确实是一把利刃,如果使用得当的话,威力是无穷的。