.NET本质论 方法

方法和JIT编译

CLR只执行本机的机器代码.如果一个方法体由CIL组成,那么它就必须在调用之前被转换为本机的机器码(将MSIL编译为本机代码,运行库提供了两种方式.一种就是在安装与部署时的预编译(由NGEN.EXE和MSCORPE.DLL产生).另一种就是用实时(JIT)编译器动态地将其转换为本机代码.)在第1章曾经简要地讨论过,将CIL转换到本机的机器码由两种解决的方法.默认的场景是推迟转换,直到该组件被加载到内存中时.这种解决之道被称为实时编译[just-in-time(JIT) compilation],或简称为JIT编译器(JIT-compiling);另一种场景是当组件被首次安装到部署机器上时,生成一个本机映像(native image).这种解决之道被称为预编译(precompiling).CLR提供了一个部署工具(NGEN.EXE(本机映像生成器创建托管程序集的本机映像,并且将该映像安装到本地计算机的本机映像缓存中.本机映像缓存是全局程序集缓存的保留区域))和一个底层库(MSCORPE.DLL),用于在部署时产生本机映像

当NGEN.EXE和MSCORPE.DLL产生本机映像时,这个本机映像便以一种机器码缓存的形式存储在磁盘上,以便加载器可以发现它.当加载器试图加载一个基于CIL版本的程序集时,它就会在缓存中查找对应的本机映像,并尽可能地使用本机的机器码.如果没有发现合适的本机映像,CLR将使用程序集初始加载时基于CIL的版本

尽管在部署时产生本机代码听起来很有吸引力,但它并不是没有缺点.代码尺寸就是不将本机映像缓存到磁盘上的理由之一.按照常规,IA-32机器码比对应的CIL要大的多.对于一个常规组件,常用的可能只是很少的几个方法.当CIL产生一个本机映像时,新的DLL将会包含每个方法的本机代码,包含那些可能从来不会被调用的方法,或者偶尔被调用的方法,例如,初始化,终结代码或错误处理代码.由于包含了每个方法的实现,因而导致内存代码总的尺寸不必要地增长.更糟糕的是个别方法体的位置没有顾及到运行程序的动态性.由于在代码生成后你不能改变由NGEN.EXE所生成本机映像中方法的位置.因此,对于本来就不多的必须方法,各个方法还可能占用不同的虚拟内存页.这个碎片对于应用程序的工作集尺寸将产生负面的影响

关于缓存本机映像的第二个问题是:处理跨组件的约定(cross-component contract).对于CLR生成的本机代码,方法使用的所有类型对于译码器(translator)必须是可见的,因为本机代码必须包含传统的C,C++,COM和WIN32约定下的非虚拟偏移.当方法依赖于另一个组件的类型时.这种跨组件依赖性就会产生问题,原因就是对于另一个组件的任何改变将会使得缓存的本机代码无效.由此,每个模块在它被编译时都被赋予了一个模块版本标识符(module version identifier,MVID).MVID是一个简单的唯一标识符,它保证对于一个模块的特定编译是唯一的

当CLR生成并缓存一个本机映像时,用于生成本机映像(包括那些来自外部的程序集)的每个模块的MVID就存储在本机代码中.当CLR加载器试图加载一个缓存的本机映像时,在CIL到本机代码生成过程中,它首先检查组件的MVID,以核实它们没有被重新编译.如果发生了重新编译,CLR将忽略缓存的本机代码,并退回到包含CIL的组件版本

如果在缓存中没有发现本机映像(或者由于依赖项重新编译而失效),CLR就会加载该组件的基于CIL的版本.在这种情形下,CLR在方法被首次执行前实时编译(JIT)它们.当方法被实时编译时,CLR必须加载该方法用作参数或局部变量的任何类型.这时,CLR就可能需要(也可能不需要)实时编译任何将该方法调用的下级(subordinate)方法.为了理解实时编译是如何工作的,让我们看一小段比较乏味的代码.回想一下第4章关于强制类型转换的讨论,CLR为每个类型在其初始化时分配了一个内存数据结构.在CLR1.0的版本下,该数据结构在内部被称为CORINFO_CLASS_STRUCT,并且通过存储在每个对象中的RuntimeTypeHandle引用.对于IA-32处理程序,在方法表之前的CORINFO_CLASS_STRUCT有40个字节的头信息.方法表是一个带有长度前缀的内存地址数组,每个方法都有一个入口项.与C++或COM不同的是,CLR方法表既包含实例方法的入口项,又包含静态方法的入口项

CLR通过方法的声明类型的方法表路由(route)所有的方法调用.例如,对于下面这个简单的类Bob,从Bob.f到Bob.c的调用总是通过该类的方法表.

实际上,对于Bob.f方法,对应的IA-32的本机代码是这样的:

在IA-32 call指令中使用的地址依次对应的方法表入口为:Bob.c,Bob.b和Bob.a

类型方法表的每个入口项指向一个惟一的存根例程(stub routine)(stub:也有译为"桩"的.当加载类型(初始化)时,加载器创建存根(stub)并将其附加到类型的每个方法.每个存根例程包含对JIT编译器的调用;当对方法进行初始调用时,存根(stub)将控制传递给JIT编译器,而编译器将该方法的MSIL转换为本机代码.同时修改存根(stub)以直接执行到本机代码的位置(jmp)指令,参见图6.1).初始化时,每个存根例程包含一个对于CLR的JIT编译器的调用(它由内部的PreStubWorker程序公开).在JIT编译器生成本机代码后,它会重写存根例程,插入一个jmp指令跳转到刚才JIT编译的代码.不会有别的开销.这个技术酷似Visual C++ 6.0新增加的延迟加载(delay-load)特征.

图6.1展示了正在被实时编译时的这个简单的C#类.尤其展示了Bob.f的调用过程中的Bob方法表的快照.具体是在Bob.f调用Bob.c之后,且调用b或a之前这一瞬间的快照.注意,由于Bob.c方法已经被调用,c的存根只是一个jmp指令,它简单地将控制权传递给Bob.c的本机代码.相比之下,由于Bob.a和Bob.b将要被调用,a和b的存根例程将包含通用的call语句,它将控制权传递给JIT编译器

单一的jmp指令也可能产生性能问题.然而,扩展的jmp指令所提供的间接性,使得CLR能够很快地调整应用程序的工作集.如果CLR发现给定的方法不再需要,就会"抛弃"本机方法体,重新将jmp指令指向JIT例程.不妨设想一下,本机方法体甚至可以在内存中被重新部署,以使得那些被频繁访问的方法被放在同一个(或相邻的)虚拟内存页中.由于所有的调用都是通过jmp指令.因此CLR只需要重写一个内存位置,就能够实现这种改变,而不管有多少调用点引用这个重定位的方法

图6.1并没有从技术上进行全面解释.尤其是当每个方法的存根进行初始化时,既包含call语句,又包含指定方法的CIL的地址.方法存根调用了一小段序言(prolog)代码,从代码流中抽取方法的CIL的地址,然后,把地址传递给PreStubWorker(JIT编译器).图6.2详细地展示了这个过程

 

方法调用和类型

从对JIT编译和调用的讨论来看,类型和方法调用是密切相关的.尤其是CLR使用类型的方法表来定位目标方法和地址.考虑下面简单的类型定义:

忽略方法的序言(prolog)(所谓方法序言,就是指方法在正式调用之前CLR所做的一系列准备工作)和尾声(epilog)(所谓方法尾声,就是指方法调用后CLR所做的一系列收尾工作),JIT编译器将产生下面的IA-32本机代码,用于UseBob方法:

第一个指令把目标对象的引用放进ecx寄存器.这是因为JIT编译器通常使用_fastcall(_fastcall调用约定的主要特点就是快,因为它是通过寄存器来传送参数的.实际上,它用ECX和EDX传送前两个双字(DWORD)或更小的参数,剩下的参数仍旧自右向左压栈传送,被调用的函数在返回前清理传送参数的内存栈.注意它同_stdcall,_cdecl的比较)堆栈规则,这可能导致前两个参数被传递到ecx和edx寄存器.第二个指令间接调用目标.对于Bob而言,间接寻址是通过方法表中一个特定插槽(slot)(本章中,作者在许多地方用到了"插槽(slot)"这个词.这实际上是一个硬件的概念.读者很容易就联想到我们个人电脑主板上的各种插槽.如果我们将类型看成是一个主板的话,方法表就是上面的插槽)-这里是dword ptr[352108h].

注意,在刚才显示的IA-32 call语句中,Bob方法表插槽的确切地址被加进JIT编译过的方法中.这意味着当派生类型存在时(参见下面代码片段),即使派生类型中存在一个名字和签名恰好匹配的方法,UseIt方法也总是分发到Bob.f

为了使JIT编译器考虑对象的具体类型,需要把方法声明为virtual.

虚方法(virtual method)是一个实例方法,其实现可被派生类型替换或重写.虚方法通过virtual元数据特性的存在进行标识.在开发阶段,当编译器遇到一个对源码中virtual方法的调用时,它发射一个callvirt操作码而不是传统的call操作码.callvirt指令对应的本机代码与call指令对应的本机代码是不同的.如前一节所描述的那样,CIL的call指令静态地绑定本机IA-32 call指令到特定类型的方法表.相比之下,CIL的callvirt指令产生一个特别的IA-32指令,它将根据目标对象的RuntimeTypeHandle()决定使用哪个方法表.这就允许目标对象的具体类型决定调用哪个方法.由于CLR需要对象的具体类型决定使用哪个方法表,因此,虚方法机制不适用于static方法.此外,如果通过一个null引用执行callvirt指令,将会抛出System.NullReferenceException异常.

对于虚方法和非虚方法,CLR在方法表中分配的入口项是不同的.方法表有两个相邻的区域:第一个区域用于虚方法,第二个区域用于非虚方法.第一个区域将包含每一个被声明为virtual方法的入口项,不管该方法是在当前类型中,还是在所有基类型与接口中.第二个区域将包含在当前类型中每一个被声明为non-virtual方法的入口项.这种分离允许派生类型的方法表取代基类型的方法表,用于虚方法分发,原因就是在继承结构中用于特定虚方法的索引是相同的.

CLR通过目标对象的类型句柄访问所引用的方法表,并根据方法表分发虚方法调用.这样,对象的具体类型就能确切地决定执行哪段代码.如果在前面的例子中,Bob.f方法已经被声明为virtual,Bob.UseIt方法将会是下面这样的:

第一个mov指令简单地将目标对象的引用存储在IA-32 ecx寄存器中.该指令对于虚调用和非虚调用都是需要的,因为CLR的调用约定需要在调用前将this指针存储在ecx中.第二个mov指令对于虚方法分发是惟一的.该指令将对象的类型句柄存储在IA-32 eax寄存器中.该类型句柄被IA-32 call指令所使用,用于定位目标方法的实际地址.图6.3显示了该调用在内存中的表示

注意,在刚才描述的IA-32 call指令中,CLR根据一个固定的methodoffset(方法偏移量)索引类型的方法表.对于一个特定的虚方法,该方法偏移会因为程序的不同执行而可能不同;但在一个运行程序的生命期内将是常量.像字段偏移量一样,方法表偏移量(method table offset)是在类型加载时被计算出来的.每个虚方法的元数据特性都将控制所选择的偏移量.表6.1显示了影响方法表的元数据特性.对于方法偏移量影响最大的是newslot特性

在方法表中,CLR赋予每个虚方法一个插槽,它将包含一个指向方法代码的指针.CLR假定被声明为newslot的虚方法与在基类型中声明的任何方法都无关.CLR赋予被声明为newslot的虚方法一个新的methodoffset,它比基类型所使用的最高methodoffset至少大1.由于对于所有具体类型的最终基类型System.Object来说,它有四个虚方法,因此在每个方法表中,前四个插槽分别对应于这四个方法

如果一个虚方法没有设置newslot元数据特性,那么,CLR假定该方法是基类型中虚方法的代替品.在这种情形下,CLR将在基类型的元数据中查找名字和签名匹配派生类型方法的虚方法.如果找到一个匹配,那么,该方法的methodoffset将会被重用,并且在派生类型的方法表中对应的插槽将会指向派生的替换方法.由于使用这个索引的调用是通过基类型的引用,因为对派生类型的调用将被分发到派生类型的方法,而不是基类型的方法:至于调用有可能通过基类型的引用发出,这个事实并不重要

对于没有用newslot元数据特性标记的虚方法,被假定是基类型的虚方法的替换.然而,如果CLR在基类中没有发现匹配的方法,那么,它将假定该方法已被声明为newslot

你可能会使用abstract或final特性要求或者禁止一个虚方法被替换.abstract特性要求派生类型必须替换虚方法.抽象方法只是声明而已,没有方法实现,原因就是必须通过派生类型进行替换.由此类推,包含一个或多个abstract方法的类型本身也必须被标记为abstract,因为这些类型的规范是不完整的,只有等到抽象方法被替换之后,才算完整的类型.因此,通过接口声明的所有实例方法都要求被标记为abstract,绝大多数编程语言都隐式地这样处理.

当你替换基类型中的虚方法时,还可以禁止下游(downstream)的派生类型对该方法作进一步替换.通过设置final元数据特性就能够实现这点.将final特性应用到方法上,就等于告诉CLR.不允许在派生类型中替换该方法.显然,不能将newslot abstract特性和final特性合在一起使用,因为前者要求由派生类型进行替换

每种编程语言都提供了其自身的语法,用于指定virtual,abstract,newslot和final元数据特性.表6.2显示了由C#所使用的关键字.如前面提到的那样,在C#中把一个方法标记为new,并不影响产生的代码或元数据;准确地说,该关键字只是消除了编译器的警告

当一个派生类型提供一个实现以重写基类型的方法时,所有对于该方法的调用将分发到该派生类型的代码.考虑示例6.1所显示的类型层次,这里没有使用虚方法.注意,当一个程序调用基类型的DoIt方法时,该方法忽略了派生类型中DoItForReal方法的存在,并且只是简单地调用基类型的版本.因为在基类型中DoItForReal方法没有被声明为virtual,所以Base.DoIt的方法代码将被静态地绑定到调用Base.DoItForReal,而与任何派生类型无关,然而,如果在基类型中DoItForReal方法被标记为virtual(如示例6.2所示),Base.DoIt方法将总是通过虚函数机制调用DoItForReal方法,它允许派生类型通过重写来替换基类型的方法

因此在不了解基类型内部工作相关只是的情况下,几乎不可能知道使用哪种途径工作

接口,虚方法和抽象方法

CLR与C++,COM相比,在处理对象和接口类型上是不同的.在C++和COM中,一个给定的具体类型对于每个基类型或支持的接口类型,都有一个方法表.而在CLR中,一个给定的具体类型只有一个方法表.以此类推,一个基于CLR的对象只有一个类型句柄.对于C++和COM而言.一个对象往往会按每基类型或每接口而有一个虚函数指针(vptr).由于这个原因,CLR的castclass在以C++的dynamic_cast或COM的QueryInterface的同样方式工作时,将不会产生第二个指针值

每个CLR类型都有一个与类型层次结构无关的方法表.方法表中开始的插槽将对应于由基类型声明的虚方法.紧接这些插槽之后的表项.将对应于派生类型引入的新的虚方法.CLR是这样安排这个方法表的区域的:对于一个特定的声明接口,它的所有方法表插槽都将被连续地安排.然而,由于不同的具体类型可能支持不同的接口,因此对于支持给定接口的所有类型,方法表对应表项范围的绝对偏移量可能是不同的.为了处理这种变化,当通过基于接口的对象(或者说是接口类型的引用,而不是类类型的引用)引用调用虚方法时,CLR将添加另外一层间接性

CORINFO_CLASS_STRUCT包含指向描述类型所支持接口的两个表(两个表:一个表是类型的接口表;另一个表就是下面要介绍的接口偏移量表)的指针.isinst和castclass操作码使用其中的一个表,确定类型是否支持给定的接口.第二个表是接口偏移量表,当由基于接口的对象引用分发虚方法调用时,CLR将会使用它

如图6.4所示,接口偏移量表(interface offset table)在类型方法表中是一个偏移量的数组.对于CLR初始化的每个接口类型在这个表中都有一个表项,而与该类型是否支持这个接口无关.当CLR初始化接口类型时,在这个表中它将赋予它们一个基于零的索引.当CLR初始化一个具体类型时,它将为该类型分配一个新的接口偏移量表.这个接口偏移量表将被稀疏地填充.但它至少有被声明的接口的索引.当CLR初始化一个具体类型时,它将适当的方法表偏移量存储到所支持接口对应的表项中,这样来填充接口偏移量表.因为CLR的验证器保证基于接口的引用只引用所声明类型的对象.对于不支持的接口的表项是不会用到的,并且,其内容也是无关紧要的

如图6.4所示,一个基于接口引用的方法调用,必须首先在该接口相应的方法表中定位表项的范围.在CLR找到这个偏移量后,它将添加方法相关的偏移量,并且分发调用.与基于类引用的虚方法调用相比,基于接口引用的方式导致代码有点臃肿,并且速度慢一些,其原因就是要使用额外的间接性,不过,如果相同的对象引用被多次使用,那么,对于JIT编译器来说,优化这种额外的间接性就是可能的

C#语言支持两种实现接口方法的技术:一种接口方法的实现技术是将其作为带有相同名字和签名(即与接口类型兼容的类型,它所实现的接口方法与接口类型中定义的抽象方法相比,名字和签名都是一样的.此外,它必须是公有的)的公有方法另一种是实现相同签名(与接口类型中的定义的方法相比,其签名是一样的,但必须加上接口名的前缀.此外,它还是私有的)的私有方法,但方法名字必须遵循InterfaceName.MethodName的约定(由于显式接口成员实现不能通过类或结构实例来访问,因此它们就不属于类或结构自身的公共接口.当需在一个公用的类或结构中实现一些仅供内部使用(不允许外界访问)的接口时,这就特别有用).例如,对于接口名为IDrawable的Display方法,其实现的方法名为IDrawable.Display.

这两种技术主要的不同在于:对于前者,方法变成了类的公有签名的一部分;对于后者,其方法只能通过一个向上类型转换(up-cast)转换为对应的接口类型.因而,当你必须重载一个给定的方法名,而它又是基于调用该方法的引用范畴时,后一种技术就是必不可少的.对于类的约定来说.当你想要一个类型更为安全的方法版本时,可能需要这么做.例如,考虑下面的类,它实现了System.ICloneable.

注意,Patient类型的公有约定包含一个强类型Clone方法,它返回精确的引用类型.这样使用Patient类型执行克隆,对调用方就更方便,原因就是第二个对象引用已经转换到了预期的类型.相比之下,尽管对于使用ICloneable引用来访问对象的调用方,仍然可以得到正确的行为,但那些客户端在使用ICloneable.Clone方法返回的结果之前,可能需要做一个向下类型转换(down-cast)

使用限定方法名实现接口抽象成员的另一个优点是:它可以让你很容易地处理跨接口的命名冲突.当一个类实现两个或多个接口,而这些接口又带有同样的方法声明,但语义却不同时,这种冲突就会发生.尽管这种情形很少见,但实际上也是可能的.考虑下面这个典型的例子:

注意,AcePowell类有三个Draw方法.CLR将根据用于调用方法的引用类别(类(或者结构)实例的引用,或者接口实例的引用)确定选择哪一个

到目前为止所展示的每个例子,其接口方法的实现都隐式地为final,如同存在sealed和override修饰符一样.如表6.3所示,当一个接口方法的实现被标记为public时,它还能被标记为virtual和abstract.使方法称为可替换的.这将允许派生类型能够重写这个方法.这种重写将替换基类型方法基于类和基于接口的用法.对于一个派生类型,只是通过重新声明支持接口,就可能替换基类型的接口实现方法的部分和全部.如果这么做,派生类型就可以随意地提供任何接口方法的新实现,而不用考虑基类型是如何声明它们的

示例6.5显示了一个C#程序,其中,基类型Base使用刚才描述的技术,实现了三个接口方法.注意,派生类型Derived1只能替换基类型的Turn方法.这是因为基类型并没有把其他任何方法声明为virtual.相比之下,Derived2类可以替换所有接口方法.这是因为Derived2显式地重新声明了对于IVehicle接口的支持.在这个例子中,程序决不会在Derived2实例上调用基类型Start的实现.这是因为Start方法能被调用的惟一方式是通过IVehicle接口,Derived2类已经为它显式地提供了一个Start方法.程序可以在Derived2的实例上调整基类型的Stop方法和Turn方法

这种情形会在用Base类型的引用指向Derived2的实例时发生.当使用这种引用的时候,Base中的Stop方法是非虚的,因此没有任何虚方法调用(或派生类型重载)生效,有点奇怪的(但却是预期的)是Turn方法仍将分发到Base中的方法实现.这是因为在Derived2中声明Turn的实现时,开发人员并没有使用override关键字.缺少override关键字将告知C#编译器使用newslot特性发出方法声明,这是一种导致编译器认为Derived2中的Turn方法与Base中的Turn方法毫无关系的措施

如果前面的讨论使你陷入迷惑,不妨看看示例6.6.这个示例演练了重写(override),重载(overload)和接口(interface)的大多数可能的组合,不妨试着指出这个程序的运行结果.特别是要指出是6个DoIt方法中的哪一个将被编译器和(/或者)CLR选中.用于Main方法中的4个方法调用

第一个调用将分发给e,因为对象的具体类型有一个名为DoIt的public方法;第二个调用将分发给e,因为Derived.DoIt被声明为virtual;第三个方法将分给b,因为即便是Base.DoIt被声明为virtual,但后续的派生方法还是重载了它的使用;第四个调用将分发到c,因为ICommon.DoIt隐式地为virtual.当然,你可能不会像这样编写代码,不过,这可能(当然也可能不能)有助于你认识到CLR支持这种方式

显式方法调用

前面的讨论是关于虚方法如何在调用点(call site)与执行的实际方法之间引入一层间接性.这层间接性对于调用方是非常透明的,CLR使用目标对象的具体引用自动地确定使用哪个方法.除了虚方法,CLR还提供了更灵活的方法调用功能,你可以发现并调用任意的方法,而不需要对它们的签名和方法名字有任何先验的了解.这个功能[显式方法调用(explicit method invocation)]对于构建高度动态的系统是很关键的

我们不妨回顾一下,对于CLR元数据,可以通过System.Type及其相关类库进行访问.System.Type的功能之一就是发现一个给定类型的方法.System.Reflection.MethodInfo类型公开了方法的元数据.如第4章中所描述的,MethodInfo类型使方法的签名为可用的,包括参数的类型和名字.而MethodInfo类型对于调用底层方法的能力我们还没有讨论到.事实上,它通过MethodInfo.Invoke方法公开了这个功能

MethodInfo.Invoke具有两个重载版本,其中较为复杂的一个允许调用方提供映射码(mapping code),以处理参数类型不匹配和重载解析.这个版本的MethodInfo.Invoke方法主要被用于在动态地类型化语言中支持管道(plumbing),这已经超出了本书的范围.两个方法中较为简单的版本假定调用方提供了底层方法期望出现的参数.示例6.7展示了两种方法的原型

要使用MethodInfo.Invoke的简单版本形式,需要提供两个参数,图6.5展示了这种用法.第一个参数是目标对象的一个引用.如果底层方法被声明为static,那么这个引用将被忽略.如果底层方法不是static,则这个引用必须指向一个与MethodInfo发射类型相兼容类型的对象.如果给这个参数传递了一个与该类型不兼容的对象.MethodInfo.Invoke就将抛出一个System.Reflection.TargetException异常

MethodInfo.Invoke的第二个参数接收一个object类型引用的数组,每个参数对应一个数组元素.这个数组的长度必须匹配参数所期望的个数.数组中每个引用对象的类型必须与对应参数的类型是类型兼容的.如果有任何一个不符合,MethodInfo.Invoke都将抛出System.Reflection.TargetParameterCountException异常或者System.ArgumentException异常

MethodInfo.Invoke的实现将使用所提供的参数值和对象引用来调用底层方法.为了完成这一点,MethodInfo.Invoke将形成一个堆栈帧(stack frame),它是基于CLR之下的底层方法声明和处理程序架构的,然后,MethodInfo.Invoke从对象引用的数组中将参数值拷贝到这个堆栈上.当堆栈帧完成形成后,MethodInfo.Invoke就对目标方法发出一个处理程序相关的调用(例如,IA-32中的call).当方法执行完成后,MethodInfo.Invoke将接着识别任何通过引用传递的参数,并把它们拷贝回参数值的当前数组中.最后,如果这个方法返回一个值,则该值将被作为MethodInfo.Invoke调用的结果而返回

示例6.8展示了一个C#程序,它在一个任意的对象上调用一个名为"Add"的方法.这段代码假定对象的底层类型中有一个Add方法.此外,这个例子还假定Add方法恰好接收三个System.Int32类型参数,并且,底层方法将返回一个System.Int32值.顺便说一下,这个例子使用了BindingFlags.NonPublic标志,标明非公有方法将会被考虑.这个功能允许你避开方法的访问修饰符(例如,private);然而,只有受信任的代码才能违反这种封装

图6.5展示了MethodInfo对象如何关联底层方法和目标对象.注意,有一个底层System.RuntimeMethodHandle引用,它指向描述该方法的CLR托管数据结构.你可以使用System.RuntimeMethodHandle.GetFunctionPointer方法访问底层方法代码的地址.当找个这个地址后,喜欢使用底层编程技巧的程序员就能够直接调用这个方法,而不用承担MethodInfo.Invoke的开销

由GetFunctionPointer返回的地址意味着它必须由CIL的calli指令调用.与call和callvirt指令不一样,calli指令直接将目标方法的元数据记号编码到指令流中.在运行时,calli指令所期望的目标方法的地址将被压入到堆栈中.这种间接性允许CLR支持C风格函数指针.例如,假设你有如下的C#类型声明:

你将能编写下面的C++代码:

遗憾的是,在.NET framework1.0版本下,C++编译器的CLR兼容模式(/CLR)并不支持使用_fastcall堆栈规则的函数指针声明,它通常是由CLR内部所使用的规则.尽管构造合适的IA-32机器代码是可能的,C++还是禁止在托管方法中内联程序集.这导致程序员只能使用ILASM编写必要的CIL用于调用函数,除此之外别无选择.这里,ILASM是随.NET framework SDK一起发布的CIL汇编器

下面的ILASM方法声明展示了如何调用前面例子中所展示的Add方法:

如果C++编译器支持_fastcall函数指针,则这个方法产生的机器代码将与C++函数指针产生的机器代码相同

为了调用实例方法Subtract,你可以使用这个ILASM方法:

在这两种情形下,Call的第一个参数都是一个由MethodBase.GetFunctionPointer所返回的函数指针.注意,在这两个例子中很重要的是,当JIT编译器把这个CIL翻译成机器代码时,似乎每个参数都被传递到堆栈,但是根据_fastcall的调用约定,前两个参数将被传递到ecx和edx寄存器

间接方法调用和委托

前面的讨论主要着眼于MethodInfo对象,开发人员可以藉此在类型兼容的对象上调用特定方法.因为MethodInfo对象属于一个类型(type)而不是一个对象,因此,在使用MethodInfo调用方法时,需要在每次调用方法时都显式地提供目标对象的引用.在很多方面,这都能满足要求.不过,你还经常需要将一个特定的方法绑定到一个特定的对象上,这就需要用到委托(delegate)

委托提供了一种机制,用于绑定一个特定方法到特定目标对象上.对于特定目标对象的绑定,委托不同于MethodInfo.Invoke,它不用在调用时显式地提供目标对象引用.因此,委托除了调用其底层方法外,几乎没做什么事情.

委托被用于基于CLR的库(library)中,表示调用特定方法的能力.因此,委托与一个单个方法接口很相似,其主要区别在于:接口需要目标方法的类型预先声明与该接口兼容.相比之下,委托可以被绑定到任何类型的方法,只要提供的方法签名与委托类型所期望的方法签名相匹配

如图6.6所示,委托是一个对象,它维护两个字段:一个是方法指针(委托适用于那种在某些语言中需用函数指针来解决的情况(场合).但是,与函数指针不同.委托是面向对象和类型安全的),一个是目标对象引用.其中,方法指针是一个C++风格的函数指针,例如,由System.RuntimeMethodHandle.GetFunctionPointer所返回的地址:而目标对象的引用则是一个System.Object类型的引用.当委托被绑定到一个静态方法时,该引用为null

不像MethodInfo类型那样,可以不管底层方法的签名,委托对象必须属于一个与底层方法签名相关的委托类型.如图6.7所示,委托类型(只有系统和编译器可以显式地从Delegate类或MulticastDelegate类派生.此外,还不允许从委托类型派生新类型.Delegate类和MulticastDelegate类(它们都是抽象类型)都不是委托类型,它们用于派生委托类型)总是直接派生于 System.MulticastDelegate类型.这两个基类型提供了多个基本函数,同时也通知CLR,该类型实际上是一个委托类型

像其他CLR类型一样,委托类型有类型名,并且可以有成员.但是,委托类型的成员被限制为带有固定名字的有限方法集合,在这些方法当中最重要的就是Invoke方法.

Invoke方法必须是一个公有实例方法,此外,Invoke方法必须被被标记为runtime,这意味着CLR将合成它的实现,而不是从类型模块的CIL中实时编译它,尽管名字和元数据特性是密不可分的.但实际的方法签名可以是任何CLR兼容的签名.Invoke方法的签名决定了委托类型可以如何使用.特别是,绑定到委托的任何方法都必须有于该委托的Invoke方法一致的签名.CLR将在编译时和运行时强制实施这种签名的匹配

除了Invoke方法之外,委托类型必须提供一个接收两个参数的实例构造函数方法,第一个参数是一个System.Object类型,指定了要被绑定的目标对象引用;第二个参数是一个System.IntPtr类型,必须指向被绑定方法的代码,和Invoke方法一样,构造函数必须被标记为runtime,因为CLR将在运行时合成它的实现

每种编程语言都提供了其自身的语法,用于定义委托类型.C#,C++和VB.NET都共享一个相似的语法,它看起来像一个方法声明,而实际上是一个类型声明语句.看看下面的C#语句:

这条语句定义了一个名为AddProc的新委托类型,其Invoke方法将接收两个System.Int32作为参数,并返回一个System.Int32作为结果,下面是对应于这个C#类型声明的ILASM

如同刚才所描述的,Invoke方法签名对于应用于用C#编写的类型定义语句.

为了实例化一个委托,需要一个方法和一个可选的目标对象引用.只有在绑定到一个实例方法时,才需要目标对象引用.当绑定到一个静态方法时,这是不需要的.System.Delegate类型提供一个CreateDelegate静态方法,用于创建一个新的委托,以绑定一个特定方法和对象.下面是CreateDelegate的四种重载形式:

第一对重载形式用于绑定一个新的委托对象到静态方法上.第二对用于在特定对象上绑定实例方法.在所有这些情形中,第一个参数是一个System.Type对象,它描述期望的委托类型.指定的目标方法必须确切地匹配委托类型的Invoke方法签名

下面的C#代码使用CreateDelegate方法,将委托绑定到一个静态方法和一个实例方法上:

调用CreateDelegate是调用委托类型构造函数的一种间接方法.每种编程语言都提供了自身的语法,用于直接调用构造函数.对于C#而言,可以简单地通过类型名字或对象引用限定,以指定方法的符号化名字(在这个Main方法中,MathCode.Subtract就是通过类型名字限定的,而target.Add是通过对象引用限定的).下面的Main方法等价于前面的示例:

在调用委托类型的构造函数之前,C#编译器将转换这些new表达式,使用底层CIL的ldftn或ldvirtftn操作符获取目标方法的地址.这个技术用于绑定委托比调用Delegate.CreateDelegate要快得多,原因就是不需要通过元数据遍历查找方法句柄

在CLR实例化委托并将它绑定到方法和对象后,其主要目标就是支持调用.你可以用两种方式使用委托进行调用.如果你需要一个通用机制(按MethodInfo.Invoke的方式),System.Delegate类型提供了一个DynamicInvoke方法(它主要用于动态调用(后期绑定)由当前委托所表示的方法)

注意,用于DynamicInvoke的签名与MethodInfo.Invoke的一样,除了目标对象的引用没有被显式地传递.准确地说,委托的_target字段充当了调用的隐式目标,如图6.8所示

对委托的调用更为通用的方式是:使用类型相关的Invoke方法.不像DynamicInvoke方法,Invoke方法是强类型的.由于不具有一般性,它的性能更好.用于IA-32的CLR合成(CLR-synthesized)的Invoke实现,只是一个8条指令的垫片(shim:一般指那些过渡的小程序或者组件),它使用目标对象引用的指针替换ecx中的this指针.接着,该垫片直接jmp到目标方法地址.在jmp发生后,目标方法开始执行,就好像它是直接由调用方所调用的一样.实际上,因为调用方的返回地址仍然在堆栈上,目标方法将直接返回到调用方,这样便完全绕开了委托的方式.

由Invoke方法所使用的垫片可以普遍性地工作,原因是目标方法的签名保证与Invoke方法的签名恰好匹配.如图6.9所示,当Invoke分发到目标方法时,你可以重用来自Invoke调用的堆栈帧

C#编程语言处理委托调用时有一点奇怪.C#程序不能通过名字显式地访问Invoke方法.更进一步说,省略了Invoke名,就导致一种类似C风格函数指针的使用模型(在C#中,委托类型没有Invoke这种方法)

我认为,这种做法对委托几乎没有增加什么可用性.幸运的是,C++和VB.NET允许开发人员显式地使用Invoke方法

Invoke的CLR实现支持多路委托链(chain),因此,单个Invoke调用可以同时触发多个方法调用.如图6.10所示,System.MulticastDelegate类型增加了将多个委托对象链接到单个列表的支持.当你在列表头发出一个调用时,CLR合成的代码将依次遍历这个列表,并在列表的每个委托上调用目标方法.因为这些调用是依次发出的,所以,由方法对按引用传递(pass-by-reference)参数所做的任何修改.对于委托链中的下一个目标都是可见的.此外,如果Invoke方法返回一个类型化的值,则将只有最后一个方法的值返回给调用方.最后,当这些方法中的任何一个抛出异常时,调用便会停止在该点上,并将异常抛给调用方

System.Delegate类型支持两个方法用于管理委托链:Combine和Remove

这些方法都返回一个新的委托引用,指向更新后的委托链.这个引用可能指向(也可能不指向)作为参数传递的委托

示例6.9是一个例子,它使用Delegate.Combine将两个委托组合到一个链(chain)中.注意,委托组合的顺序是很重要的,因为Invoke方法将按这个顺序遍历这个委托链

改变委托链的调用方式也是可能的.System.Delegate类型提供一个方法(GetInvocationList),它把委托链中的所有委托作为一个数组返回.如果能够访问这个数组,则可以精确地确定某个调用.示例6.10展示了一个向后遍历委托列表的例子.这个示例还展示了每个调用个体的中间结果.在本示例中,将得到每次调用结果的平均值

异步方法调用

到目前为止,我们讨论的调用技术都是简单地改变执行过程,从某个方法到另一个方法.我们还进一步希望将执行过程分成两个分支,允许其中一个分支执行一个给定方法的指令,而剩下的分支独立地继续执行其正常的过程.图6.11表明了这个概念.在一个多处理程序的机器上,两个分支实际上可以并行地执行.在一个单处理程序机器上,CLR将在共享的CPU上抢占式地(preemptively)调度两个执行分支.

分叉执行的主要目的是:当程序的一部分被阻塞时(例如,等待I/O完成或用户输入指令),处理过程得以继续执行.分叉执行由于其并行性,还能增加多CPU的吞吐量(throughput);然而,这需要一个非常谨慎的设计风格,以避免对于共享资源的过渡争用(contention).

用于分叉指令流的主要机制是使用异步方法调用(异步委托提供以异步方式调用同步方法的能力.如果编译器支持异步委托,它将生成Invoke方法以及BeginInvoke和EndInvoke方法.如果调用BeginInvoke方法,CLR将对请求进行排队,并立即返回到调用方).异步方法调用将执行分成两个流.新的指令流执行目标方法体.原来的指令流继续其正常处理.

CLR通过使用一个工作队列(work queue)实现异步方法调用.当异步调用一个方法时,CLR方法参数和目标方法的地址打包到一个请求消息中.接着,CLR将这条小心插入到一个进程范围的工作队列中.CLR维护一个操作系统级别的线程池,用于监听这个工作队列,当队列上的请求到达时,CLR从线程池中分发一个线程来执行工作.就异步方法调用的情形来说,这个工作只是简单地调用目标方法

我们总是通过委托对象执行异步方法调用.回想一下委托类型,它有两个由编译器产生的方法,Invoke和构造函数.委托类型还有另外两个方法用于执行异步方法调用,BeginInvoke方法和EndInvoke方法.像Invoke一样,这两个方法都必须被标记为runtime,因为CLR将在运行时为它们提供基于其签名的实现.

CLR使用BeginInvoke方法发出一个异步方法请求.BeginInvoke方法的CLR合成的(使用用户指定的委托签名,编译器应发出具有Invoke,BeginInvoke和EndInvoke方法的委托类.因为BeginInvoke和EndInvoke方法被修饰为本机的,所以CLR在类加载时自动提供该实现,加载程序确保它们未被重写)(CLR-synthesized)实现只是简单地创建了一个包含参数的工作请求,并且将该请求插入到工作队列中.BeginInvoke通常于目标方法在线程池上开始执行之前返回,但由于底层线程调度的不可预测性,有可能(虽然可能性比较小)在调用线程从BeginInvoke返回之前,目标方法就已经执行完毕了

BeginInvoke的签名与Invoke的签名是相似的.考虑到下面的C#委托类型定义:

这个委托类型将可能有这样的Invoke方法签名:

对应的BeginInvoke方法则是这样的:

注意,BeginInvoke方法的签名有两点不同:一个就是BeginInvoke接收两个额外的参数,用于设定调用的处理方式.这两个参数在本节后面即将讨论到;另一个就是BeginInvoke总会返回一个调用对象(指支持IAsyncResult接口的对象.它们存储异步操作的状态信息,并提供同步对象以允许线程在操作完成时终止)的引用.这个调用对象表示该方法执行期间的状态,并且能够用于在过程中控制和检测调用.调用对象总是实现System.IAsyncResult接口

如示例6.11表示,IAsyncResult接口有四个成员.Completed Synchronously属性表明在BeginInvoke期间执行会不会发生.尽管CLR的异步调用管道从不这么做,但实现异步调用方法的对象可能会同步处理一个异步请求

IAsyncResult.IsCompleted属性表明该方法是否已经执行完毕.这允许调用方轮询(poll)调用对象,以确定调用实际是否已经完成执行

作为查询方式的一个选择,AsyncWaitHandle属性返回一个System.Threading.WaitHandle对象,这样,通过线程同步技术可以将它用于等待

这个变体被认为更有效率,因为调用方的底层操作系统线程将被置为睡眠状态,直到调用完成.这对系统中的其他线程来说,增加了它们对CPU更多的利用机会

最后,你可以使用BeginInvoke签名的最后一个参数,这允许调用方将一个任意对象与该方法调用相关联.然后,通过调用对象的AsyncState属性使得该用户提供的(user-provided)对象可用.当你在发行方法(issuing method)(这里指被委托的方法)范围之外使用调用对象时,这个实用部件特别有用,原因是它允许调用方向那些最终将处理调用的完成代码提供额外的信息

当异步方法执行完成后,需要一种机制来收集调用的结果,以用于进一步处理.这个机制就是EndInvoke方法.EndInvoke方法是委托类型的第四个方法(对于C#的委托类型是这样的).像BeginInvoke方法一样,EndInvoke方法的签名与委托类型的Invoke方法的签名是相关的.考虑下面在本讨论中所使用的C#委托类型:

对应的EndInvoke将是这样的:

上述两个方法的签名在三个方面是关联的;其一,EndInvoke将返回与Invoke相同的类型化的值.这是可能的,因为EndInvoke直到底层方法执行完毕并且返回值可用时才返回;其二,在调用已经执行完毕后,工作线程发出调用完成的信号,并返回到工作队列.通过对额外的异步调用重用工作线程,可在进程的生存期内分摊线程创建所带来的开销

线程池的线程数量将会随时间增加或减少.当一个工作请求到达一个队列时,CLR就试图将该调用分发到一个现存的工作线程中.如果当前的每个工作线程都忙于服务上的一个请求,CLR将开启一个新的线程来服务于这个新请求

为了避免系统饱和,CLR设置了其创建工作线程数量的上限.默认的上限是每个CPU 25个线程,但承载CLR的进程可以使用 ICorThreadpool::CorSetMaxThreads方法改变这个默认值.你可以通过调用System.Threading.ThreadPool.GetMaxThreads方法从基于CLR的程序查询这个上限值.

如果系统中出现突发事件,那么,线程的数量达到上限就是可能的.然而,如果该状况只是瞬变的峰值,则不能代表应用程序的稳定状态.因而,当更少数量的线程就能完成同样的工作时,保持每个线程都存活将是很浪费的.因此,假如工作线程没有被使用,一段时间后便会减少.在编写本书时,工作线程消失的周期是30秒

前面异步方法调用的示例展示了:调用方的线程将在调用对象产生一个集合点(rendezvous),以处理异步调用的结果.如果调用的结果并不重要,那么,忽略对EndInvoke的调用也是合法的.这种调用方式有时被称为发出并遗忘(fire-and-forget),或者单向(one-way)调用.通常在方法没有返回值和按引用传递的参数时使用这种调用风格.当你可以安全地忽略方法失败时也可以使用这种风格,因为任何由目标方法抛出的异常,在单向调用的方式下都会被CLR取消(即在单项调用的方式下,目标方法抛出的任何异常都将被CLR所抑制,当方法执行失败时,你看不到任何反馈失败的异常信息)

你可以不通过调用对象的显式集合点处理异步方法调用的结果.为了达到这个目的,在发出调用时传递一个异步的完成例程(completion routine)给BeginInvoke方法

完成例程必须匹配 System.AsyncCallback委托的原型(System.AsyncCallback委托的原型参见示例6.11).你可以把完成例程作为倒数第二个自变量传递给BeginInvoke.在目标方法执行之后,完成例程将由工作线程立即调用.完成例程将被作为单独的参数,传递给调用对象.通常情况下,对于用于处理这个调用完成的任何状态,都将被作为最后一个参数传递给BeginInvoke;完成例程将通过IAsyncResult.AsyncState属性取回这个状态.

示例6.12展示了一个使用完成例程的异步方法调用.注意,在这个示例中,Completed方法负责调用EndInvoke方法,收集来自于方法调用的任何结果.为了在完成时调用EndInvoke方法,传递那个委托对象的引用将作为最后一个参数传递给BeginInvoke方法.如果需要更复杂的处理,那么,可以传递一个更复杂的对象

如图6.13所示,完成例程在工作线程上执行而不是在调用方线程上执行.由于工作方线程的数量是有限的,完成例程应该避免任何长时间的处理.如果长时间处理是必需的,完成例程应该把工作尽量分成小块,以便它们自身可以异步地执行

只讨论异步执行而回避并发问题是不可能的.使用异步方法调用必然在你的程序中引入并发性(concurrency).尽管并发性可以让你的程序充分利用多个CPU,优美地处理阻塞系统的调用,但并发性也会引入潜在的问题,而这些问题很难诊断,调试和修复.毫无疑问,它们是由锁(lock)而引起的

使用锁来解决并发问题是顺利成章的:关于多线程的大多数内容都是有关锁的问题,然而锁是一把双刃剑,你应该非常小心地使用它们.如果可能的话,尽可能地避免使用它们.尤其是使用锁的系统很容易产生死锁(deadock)的情形,而这时的系统将陷入冻结状态.另一个普遍的问题是,由于锁的争用(contention)将导致较差的可伸缩性.当在应用程序的关键部分上占用锁,并且持有锁的时间过长时,死锁的可能性就很大

避免使用锁的最好方式,就是确保并发任务不需要共享任何资源.这意味着异步方法需要小心地避免访问静态字段,这些字段可能也同时被调用方的线程访问.此外,如果对BeginInvoke的调用传送了任何对象引用,那么调用线程应该尽量避免在异步方法仍在执行的时候,去访问那个引用对象.通过避免对这些(还有其他)共享资源的访问,就可以在实现并发性的同时,而又不使用锁了

如果一个资源不得不被共享,你至少还可以考虑使用另外一种不用锁的技术.如果共享资源只是一个简单地System.Int32或System.Single类型.那么,System.Threading.Interlocked类型提供的一些方法,可用于以线程安全(thread-safe)的方式改写(overwrite),递增(increment)或递减(decrement)共享值.这些方法使用特定处理程序(processor-specific)的指令自动执行操作.使用这些方法比使用锁要快一些,并且决不会导致死锁,因为没有锁被占用

在某些不得不用到锁的情形下,CLR也提供了对锁的支持.CLR提供了两种基本类型的锁.一种是基于System.Threading.WaitHandle的锁(System.Threading.WaitHandle类封装Win32同步句柄(等待对共享资源的独占访问权的操作系统特定对象),并用于表示运行库中所有允许执行多个等待操作的同步对象.虽然WaitHandle对象表示操作系统同步对象并因此而公开高级功能,但它们还是不如Monitor的可移植性好;Monitor是完全托管的,某些情况下在使用操作系统资源方面更为有效),它反映Win32事件以及互斥地同步共享资源,适用于跨进程的同步;另一种令人感兴趣的锁是监视器(monitor)和ReadWriterLock

监视器和ReadWriterLock仅限于在单进程内使用[实际上是在单个AppDomain(应用程序域)].监视器支持排他锁(exclusive lock),即在同一时间只有一个线程获得对锁的访问.ReadWriterLock支持排他锁和共享锁(shared lock).这使得多个线程可以获取对锁的访问,以提供对受锁保护的资源的只读访问

监视器可以让你通过系统中的任意对象关联一个锁.然而,由于很少通过锁来使用对象,所以对象在实例化时是没有锁的.当监视器试图在对象上应用一个锁时,CLR将首次消极地分配这个锁.为了让一个对象的锁能被高效地发现,CLR将在对象头(object header)中存储一个索引到同步块(sync block)中.没有同步块的对象也没有同步块索引.监视器被首次使用在对象上时,将会有一个同步块被分配给对象,而其索引将被存储在对象头上.

我们可以通过System.Threading.Monitor类型公开基于监视器的锁.该类型有两个静态方法(Enter和Exit).分别用于获取和释放一个对象的锁.这个锁是一个排他锁,并且,在同一时间只有一个线程可以获得它.如果另外一个线程试图在已经被锁定了的对象上获取一个锁,该线程就将被阻塞.直到这个锁变得可用时为止.为了使用这两个监视器方法,C#通过lock语句提供了一个对于异常安全的(exception-safe)构件.例如,考虑下面的方法:

CLR的监视器还提供了Java风格的pulse-and-wait(脉冲并等待)能力,用于执行底层线程同步.

方法终止

除了进程终止,AppDomain终止和线程终止之外,方法进入后结束的方式有两种:正常终止和异常终止,如图6.14所示.CIL的ret指令可以终止每个方法的指令流,它将触发正常终止(normal termination).ret 指令还可以出现在指令流的其他位置,这往往取决于C#,C++或者VB.NET中的return语句(return 语句终止它出现在其中的方法的执行并将控制返回给调用方法.它还可以返回可选表达式的值.如果方法为void类型,则可以省略return语句).当一个方法正常终止时,它的类型化的返回值对于调用方是可用的,并且,CLR保证任何按引用传递的参数将会反映由方法做的修改.

异常终止与正常终止有两点不同:其一,当一个方法异常终止时,其类型化返回值对于调用方是不可用的;其二,按引用传递的参数可能受到方法体的影响.虽然在异常终止的情况下,方法调用的结果是不可用的,但还是有一个系介质可用传递输出给被调用方.这个介质就是异常(exception)对象.

引发异常触发了方法的异常终止(abnormal termination).CLR自身可以引发异常作为对异常条件的响应(例如,使用一个空引用,除以零).应用程序代码也能通过CIL的throw指令引发异常(在C#,C++和VB.NET中是由throw语句触发的).归根结底,无论是CLR还是应用程序抛出的异常,其异常处理的工作方式都是一样的,因此,后面的讨论重点是使用throw指令引发的异常

throw指令需要一个异常对象的引用,该异常对象将传递异常终止的原因.异常对象是System.Exception或者其派生类型的实例.CLR(像C++与Java一样)使用异常的类型表明错误的原因,而不是使用错误代码.为此,CLR定义了两个System.Exception类型的通用子类型:CLR采用System.SystemException作为基类型,用于系统级别的异常类型;CLR采用System.ApplicationException作为基类型,用于应用程序相关的异常类型.图6.15展示了一些系统级别的异常

异常不仅带有来自方法的可替换结果或指示,而且异常的抛出将导致CLR改变正常执行的路线.尤其是,CLR将通过遍历当前执行线程的堆栈,查找合适的异常句柄.每个堆栈帧有一个异常表(exeception table),标明方法的异常句柄的定位,以及它们所适用的指令范围.CLR查看指令计数,以便堆栈帧确定哪个句柄是适合的

 

每种编程语言都提供了其自身的语法用于构成方法的异常表.图6.16展示了最简单的C#异常句柄,在这个例子中,divide方法在其异常表中恰好有一个入口项,受保护部分的范围为赋值语句和对b的调用.这个句柄还将延伸到对e的调用.这是一个无条件句柄,也就是说在这个方法执行时,即便在受保护体中有任何异常被抛出,该句柄代码仍会执行,这个异常也就被认为是处理了.在异常被处理以后.程序将会从调用g的指令位置继续执行

不论异常对象的类型如何,CLR都将使用这个异常句柄.当然,你也可以给一个异常句柄增加判定(predicate)(也就是指定一个异常类,它与当前引发异常的运行时类型属于同一个类或是该运行时类型所属类的一个基类.注意,没有指定异常类的catch子句可以处理任何异常),限制该句柄兼容于给定的异常类型.例如,考虑图6.17所展示的C#异常句柄.这个方法有一个带有三个入口的异常表.对于异常表中的每个入口项,都有相同的保护范围,也就是它们都对应于同一个try块.在该表中第一个入口项将有一个基于类型的判定,假设当前的异常与FancyException不兼容,那么,该句柄将被忽略.同理,假设当前异常的类型与NormalException不兼容,那么,第二个句柄也将被忽略

最后,异常表的第三个入口项有一个基于类型的判定(也就是没有指定具体异常类的情形),它需要异常与System.Object兼容.实际上,第三个入口项成了无条件句柄.需要注意的是,CLR将按顺序遍历异常表.因此,像这样让更具体的类型句柄判定在通用句柄之前出现是很关键的.C#编译器将实施这项检查,并报告相应的编译错误.不过,你所使用的编译器有可能不那么细致.

异常表还包含有终止句柄的入口项.当控制权离开受保护的指令范围时,终止句柄(termination handler)都将被引发.当异常导致执行跳出保护体时,CLR将执行终止句柄;当受保护的指令正常地执行完毕时,CLR仍将执行终止句柄.在C#中,你可以使用try-finally语句创建终止句柄,如图6.18所示.在这个例子中,受保护范围的指令是赋值语句和对b的调用.在该范围的指令执行后,CLR保证终止句柄语句将被执行(在这里,就是对f的调用).如果没有异常被抛出,那么,对f的调用将在对b的调用之后立即发生.如果方法在指令受保护范围内的执行过程中,有一个异常被抛出了,那么,在CLR展开该方法堆栈帧之前,将会调用f方法

你可以为一个给定的C# try块内指定多个异常句柄,还可以在异常句柄列表之后指定一个终止句柄,如图6.19所示.这里,每个句柄(终止句柄或异常句柄)在方法的异常表中都有它自己的入口项

 

转载于:https://www.cnblogs.com/revoid/p/6680003.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值