从COM到.Net

来源:互联网 发布:微信猜骰子源码 编辑:程序博客网 时间:2024/05/10 08:13

转自 这里

 

COM的问题

COM的许多操作都依赖注册表

  • 动态创建(CoCreateInstance)
  • 接口列集
    夸进程夸套间调用都依赖于接口列集
  • 获取对像的类信息

COM根据ClassID在注册表中找到DLL的位置把DLL加载到内存中,从DLL中获得导出函数DllGetClassObject调用DllGetClassObject获得ClassObject,再用ClassObject的CreateInstance创建对像。这就要依赖于Class所在的DLL事件已经在注册表中注册了Class相应的信息,但是注册表的权限问题导致安装必须在管理员权限下运行。而且也导致一个DLL只能安装在同一个目录下,同一个DLL在多个目录下也会只有最后注册的DLL会被加载。

COM使用代理与存根实现夸进程与套间的调用,但在生成代理与存根的过程需要Type library,Type library需要从注册表中获得它的位置。

接口转换问题QueryInterface

  • 尽管高效但要小心实现
  • 每一个Class都要实现自己的QueryInterface
  • 不同的接口地址值不一样

由于COM的接口是通过虚函数表来表现的,因此它需要有一种机制能够在不同的接口之间转换。COM要求每一个接口都继承处IUnknown接口,IUnknown接口中有一个QueryInterface,从名字就可以了解这个函数就是用来获得其它接口的。但每一个CLASS实现的接口不同,因此每一个CLASS都要实现一编自己的QueryInterface,QueryInterface根据调用者传入不同的InterfaceID返回不同的接口地址。

为什么要不同呢?前面提到接口是能过虚函数表来表示接口中的函数地址,也就是说调用者要调用某一个函数时,会在接口指针的基础上加上偏移量获得要调用的函数地址,因此给调用者不同的虚函数表是必须的。我们也可能看出QueryInterface要非常小心的实现,如果给错了接口指针将是灾难。即使非常小心的实现了QueryInterface,不同的接口地址也给调用者带来了很多麻烦。当调用者拿到了两个接口指针想要知道这两个指针所指的是不是同一个对象变是非常困难,因为不同的接口所指向的地址的确是不同的。像下面这样的代码是不可能得到正确的结果的:

bool bIsSingle = pA == pB;

上面的例子中,isSingle为真可以肯定pA与pB指的是同一个对像。但isSingle为假,就不能说明pA与pB指的不是同一个对象。好在COM还有一个约定每一次QueryInterface获得IUnknown都要返回相同的值,那么前面的例子可以改成这样:

IUnknown *pUn1, *pUn2;

pA->QueryInterface(__uuidof(IUnknown), &pUn1);

pB->QueryInterface(__uuidof(IUnknown), &pUn2);

bool isSingle = pUn1 == pUn2;

看到没有,要判定两个指针是不是指向同一个对象是如此的困难。真正的问题远还没有搞定呢,通常一个CLASS不是孤立的,它要与其它CLASS一起合作才能给使用者提供服务。而这两个类之间合作的接口又不希望让外面知道或使用,这就难办了有时候就需要从一个接口得到C++对像的指针。由于不同的接口指向的地址是不同的,所以接口与C++对像的指针也可能不同。对于COM来说这种不同是必须的,但带来的问题也变得非常难处理。

QueryInterface是二进制调用约定,运行时无法对得到的接口指针判定是否是正的相要的接口。如下代码

IB * pB = ….;

IA *pA = NULL;

pB->QueryInterface(__uuidof (IA), &pA);

ASSERT(pB == pA); // 这里的断言可能正确也可能不正确

以上例子红色部分说明COM的一个特色,在调用者不知道类的实现细结的情况下是不能下任何断言的,尽管调用者知道IB从IA继承也不能下这断言。看下面这个CLASS的实现就明白了

 

    interface IA : IUnknown

    {

        ………………

    };

    interface IB : IA

    {

        …………………..

    };

    interface IC : IA

    {

        …………………

    };

 

class CObj :

    public IB,

    public IC

{

    …………….

}

在这个例子中如果pA是指向CObj的对像,这个断言显然是有问题的。说了这么多就是要说明一个问题,在调用QueryInterface之前是无法确定所要的接口会指向哪里,不然还要QueryInterface有什么用。调用者无法对获得的接口指针做简单的判断,更无法对接口指针做类型检查。看来只无条件的信任接口的实现者,但要是实现者不小心……

兼容性问题

  • 一个接口发布之后是不能修改了,加一个函数也不行
  • 方法与属性的顺序在发布之后不能变
  • 给开发者提出了太高的要求

由于COM的接口是通过函数表来实现的调用者根据接口描述在基地址上加上索引值来获得函数指针,所以函数的索引在发布之后是不能改变了。COM没有任何方式能够验证指定的索引是否是所要的函数,只能依靠接口的声明确定函数的索引值。如果接口的设计者更改了函数的顺序,调用者不一定知道这一变更仍然按老的索引值在使用,结果是无法预知的灾难。如果在最后加一个函数呢?使用老接口的调用者虽然不知道新的函数,但没关系不会有任何问题。不过反过来呢?调用者用新的接口而实现者只实现了旧接口,当调用者在调用新函数时结果还是无法预知的灾难。看来加一个函数也不行,COM约定减少函数、调整函数的顺序是想都别想,增加函数就要增加新的接口再好好设计一下要加些什么函数。这种约定给设计者提出了很高的要求,设计者要在开发之初就要精心设计接口以免在发布之后对错误的设计买单。废弃的函数是永远也别想去掉,这个污点成为设计者永远的痛。

灵活性

  • 不是所有的C++对象都能跨二进制模块传递的
  • 参数类型的严格限制给开发带来了很大的麻烦
  • 对于集合的实现没有很好的方法

COM虽然有很多语言都可以开发和使用,但是它有独立的型别系统不兼容所有语言。C++就是最典型的,许多时候想传一个STL集合、精心设计的结构都变成比买房子还难的事情。C++对像根本就不适合在二进制模块间传递,不然也不会有COM这玩意了。刚才说了STL的集合不能传,但还是要有集合不然复杂的数据结构怎么表示成COM呢。COM自然就一套集合的约定,从接口到IDispatch接口听DispID都有约定。但实现起来还是非常困难,想想都知道好多接口要实现、还有很多要注意的细节,反这些东西都写出来就不怕Blog里没有东东了。当然还可以自己定义集合的接口,把STL做掉的事情全部再做一编,两个字痛苦。

重用性

  • 原有的实现不能通过继承而获得(尽管聚合是一个办法但实现起来难度非常高)
  • COM不是真正面向对象的,相反是利用面相对象的一种技术

通常当有人做了一些工作,而我们只想在它的基础上做一些扩展自然就会选择继承,但是COM没有继承。你也许会说COM不是有聚合吗,不错聚合从某些方面可以代替继承。当A聚合B时,B的所有接口与实现都变成了A的一部份。调用者调用A的QueryInterface可以获得B的所有接口,获得这些接口之后就可以直接调用B的实现,看下面的例子。

HRESULT QueryInterface(const IID & riid, void **ppvObj)

{

    // 判断riid自己有没有实现

    HRESULT hr InnerQueryInterface(riid, ppvObj);

    if (SUCCEEDED(hr) && *ppvObj != NULL)

        return hr;

 

    // 调用B的QueryInterface

    hr = m_pB->QueryInterface(riid, ppvObj);

 

    return hr;

}

这个例子里这是简单的将QueryInterface转发到m_pB,但调者再通过B获得A的接口呢?B也要把QueryInterface委托给A,看下面这行代码。

HRESULT hr = CoCreateInstance(CLSID_B, this, CLCTX_ALL, IID_IUnknown, (void**)&m_pB);

这行代码将聚合对象创建出来,第一处标红的地方把自己传给了聚合对象。CoCreateInstance的这个参数也只有在创建聚合对象时有用。为了保证实体统一性B必须反QueryInterface、AddRef、Release无条件委托给A。慢一点变成无限递归了,为了解决这个问题CoCreateInstance返回一个特殊的IUnknown实现,它的QueryInterface并不会委托给A。这就是第二处标红的地方必须指定IUnknown,因为这里必须得到一个IUnknown的实现才能工作。

到此一个可被聚合的Class与外部对象都能正常工作了,开发起来困难吧?好在ATL做了很多事情,加上Visual Studio的Class Wizard中只要选中支持聚合就能自动生成聚合的代码,看来开发可聚合的COM Class问题不大。接下来又有一个问题,我们不是用聚合代替继承吗。 如果我要重置一个B的函数呢,也就是要让调用者调用某个函数时调用A的实现而不是B的实现?在COM中只能由A将这个函数所在的接口实现,并且把所有调用者委托给B除了要重置的这个函数。为了一个函数要实现一堆函数,无论是开发还是在运行时的开消都的非常大。最后得出一个结论”COM不是真正面向对象的,相反是利用面相对象的一种技术

内存管理

  • 引用计数对C++程序员来说是噩梦
  • 循环引用领人非常的头疼

COM是用引用计数来实现内存管理的,对于C++程序员来说简直就是噩梦。通常什么地方要加引用计数,什么地方要减引用计数是有一个引用计数规则的。但是在实际开发过程中各种各样的类传来传去还是会出一些差错,一但出了差错就是灾难。引用计数出错再所难免就像软件必定会有BUG一样,要找到引用计数出错的位子非常的困难只能告Review代码加上一编又一编的测试来发现问题。

假如出现这样一种情况A引用B、B引C、C又引用A,这样就出现了循环引用。一但出现的循环引用对象的生命期就变的无限长,只有打破这种循环引用才能有效的释放对象。在开发过程中,一定要注意设计是不是够成了循环引用,如果是就一定要在合理的时候打破这个环。实际开发中循环引用往往是无意中发生的,一个复杂的系统动态情况下一不小心就形成了环,最终导致资源没有被释放甚至一些稀缺资源没有释放导致程序不能正常运行或死锁。

连接点(事件)

  • 一个有三年C++开发经验的工程师还在问我连接点怎么用?
  • 用连接点接收事件也不是两下就能搞的定的
  • 事件的接收者需要显式的将连接断开,不然有循环引用之忧

连接点(Connection Point)用于为COM提类似于事件的方式,当一个组件运行到某个点完成了一些事情,就可以通过连接点向外发送事件。至今我如果不参考文档还是无法自己开发出一个支持连接点的组件,要接收事件也要实现事件接口,显式的调用Advice才能收到。同样如果不显式的调用Unadvice很有可能循环引用导致对象生存期无限长。关于连接点的实现细节限于编幅,我就不再深入探讨了。

所有这些COM的问题在.NET里一切都迎刃而解决

  • 清单文件及程序集使.NET不再依靠注册表了
  • QueryInterface到isinst (JIT_IsInstanceof函数)
  • 由于方法的地址是在运行时由JIT确定,所以向中间插入方法不是问题(删除一个方法,如果这个方法被其它模块调用,还是会有问题的)
  • 跨语言、二进制模块继承不在话下(由于不同语言对CLR的支持程度不同,跨语言继承还是有一点问题,但还是能这样做的)
  • 引用计数到垃圾收集器,同时循环引用的问题也不复存在了。
  • 弱引用解决被全局对像引用,但又希望被垃圾收集器回收的问题。
  • CLR内建对事件的支持,使开发人员只要简单的像声明属性一样声明事件。而接收事件也像回调函数一般简单

模块、程序集

模块是一个字节流,通常作为一个DLL或EXE的形式存在,同样是一个有效的Win32模块。模块包含代码、元数据和资源。模块的元数据描述了模块中定义的类型,包含名字、继承关系、方法签名和依赖信息等。

程序集就是一个或多个模块的逻辑集合。程序集是部署的”原子”,被用来对CLR模块进行打包、加载、分发及版本控制。每一个程序集中有一个模块包含程序集清单,CLR根据清单来间接的加载相应的模块。

类型

常用类型构件

  • 结构
  • 枚举

.Net定义了一个中间语言(CIL),因此在.NET中所有的二进制模块都是用CIL来表示的。.NET同样还定义了类型描述,也就是说每一个二进制模块里都会有类型信息称之为元数据,这对于运行时类型检查非常有用。什么公有成员、私有成员、静态成员等就不多说了,这些一般语言有的功能.NET都有。

.NET的类型主要分为三种,类、结构、枚举。类就是最普通的哪种,必须在堆上被分配比如C#语言则必须用new运算符才能创建实例。结构与类的区别就在于,它可以在栈中分配。除此之外结构与类还有一个区别就是,结构在生命期结果所立刻被消毁,而类要过一段时间由垃圾收集器统一消毁。我们把在栈中分配的变量叫做值类型,在堆中分配的变量叫做引用类型,.NET的许多原生类型就是值类型。枚举就不多说了,大同小异。

 

类的基本成员

.NET中的类型成员基本被分为三类,字段、方法、嵌套类型。类型的每个成员都有自己的访问修饰符(access modifier)(如public、internal)控制对于成员的访问。还可以控制成员按实例访问(per-instance member)还是按类型访问(per-type member)。C#中使用static关键字表示按类型访问,就是通常所说的静态成员。

 

  • 字段

字段就是类的成员变量,控制内存如何分配。CLR(Common Language Runtime)使用类型的字段来决定分配多少内存给这个类型。CLR会在类型首次加载的时候,给静态字段分配一次内存,在每次创建实例时,为非静态字段分配内存。默认情况下,确切的内存布局是不透明的。CLR使用虚拟的内存部局,并经常会重新排序字段以优化访问和使用。如果CLR以类型声明的顺序布局字段,为了字节对齐不得不在某些字段中间插入一些空间。重新排序,可以有效的避免不必要的空间浪费。

有时要对一个字段进行约束,让它成为常量。CLR提供了两种将字声明为常量的方式。第一种方式,字段在编译时计算,在元数据中仅仅是一个字面值。运行时它会被编译器内联到任何访问这个字段的地方,在C#中使用const关键字声名。第二种方式,可以将initonly特性应用到一个字段,那么一旦构造函数执行完毕,就不允许现对字段值修改。C#使用readonly声明这类常量字段。

  • 方法

同字段一样,方法也同样可以通过访问修饰符来限制对方法的访问。.NET也同样支持静态方法,所不同的是象C#语言不支持通过对象限定符访问类的静态成员。方法在传递参数时,可以有两种方式值传递、传引用,对于C#语言缺省是值传递你也可以有关键字ref或out限定传引用。看下面的例子,这个例子里fist使用值传递,second与ok都使用引用传递。

public void Method(double fist, ref double second, out bool ok);

.NET与C++一样可以将方法声明为虚方法,后面的方法调用一节会提到.NET的虚方法与C++的虚方法同样高效。

  • 嵌套类型

嵌套类型是一种在另一个类型的范围之内声明的类型。比较有代表性的运用构建辅助对象(例如,迭代器、序列化器),它支持声明类型的实例。不用多讲与C++中的嵌套差不多,它能够访问外部类的保护成员。

类型初始化

类型允许提供一个特别方法,在它首次被初始化时调用。一个简单的静态方法,类型初始化器(.cctor)。一个类型最多只有一个类型初始化器,没有参数和返回值,也不能被直接调用。它是被CLR作为类型初始化的一部份调用的。在C#中,可以编写一个名字与类型名相同的静态方法。看下面的例子

namespace Sample {

    public sealed class A{

        static A() {

            t1 = SystemDateTime.Now.Ticks;;

            Debug.Assert(t2 <= t3);

            Debug.Assert(t3 <= t1);

        }

        interal static long t1 ;

        interal static long t2 = SystemDateTime.Now.Ticks;

        interal static long t3 = SystemDateTime.Now.Ticks;

    }

}

C#支持两种方式创建类型初始器,第一种使用初始化表达式、第二种显式声明初始化方法。有趣的是C#还支持两种方式都同时用,看起来比C++好用多了。当两种方法都一起使用时,C#会先执行初始化表达式,然后是显式的类型初始化方法。因此字段将以t2、t3、t1这样的顺序初始化。

当类型实例每次被子分配时,CLR将自动调用另一个不同的方法。这个方法被称为构造函数(constructor),并有一个截然不同的名字.ctor。与C++一样,一个Class可以重载多个构造函数。看下面这个例子

namespace Sample {

    public sealed class A{

        interal long t1 ;

        interal long t2 = SystemDateTime.Now.Ticks;

        interal long t3 = SystemDateTime.Now.Ticks;

 

        public A() {

            t1 = SystemDateTime.Now.Ticks;

        }

 

        public A(long init){

            t1 = init;

        }

    }

}

在这个例子里同样出现了显式构造函数与初始化表达式。编译器会在显式的方法体之前插入非静态字段的初始化代码。

基类和接口

我们经常需要根据两个或更多的类型所设的公共假设将类型划分成不同的类别。这种归类相当于类型的附加文档,因为只有显式地声明属于这个类别的类型,才被认为是可以共享该类别的假设。在CLR中,将这些类型的类别称为接口(interface)。一个类可声明支持多个接口,但是一个类只能从一个基类继承。

现在重点来了,COM提昌二进制重用使用接口,也就是虚函数表,所以COM无法做继承。而前面提到.NET导出元数据描术类型信息,并且有一个中间语言CIL,所以实现继承非常容易。所有的语言都编译成CIL,所以继承也是继承自己编译成中间语言的基类。

运行时的类型

  • 类型转换
  • 运行时类信息
  • 访问成员字段
  • 反射
  • 属性
  • 事件
  • 索引

对象属于一个类型,因为对象总是通过对象引用来访问的,所以被引用对象的实际类型可能不匹配该引用的声明类型。当引用的声明是一个抽象类型时,就是这样的情形。显然我们需要某种机制来明确对象与其类型的从属关系,以处理这种情形。接下来让我们看下面的图,分析CLR的对象头。

CLR的每个对象都以一个固定大小的对象头开始,对象头不能通过程序直接访问,但它确实存在。对象头的确切格式没有正式文档说明,上图只是一些高手的推测。对象头有两个个字段,第一个字段是同步块索引。第二个字段才是我们关心的句柄(handle),它指向一个不透明的数据结构,用于表示该对象的类型。尽管这个句柄的位置没有正式文档说明,但通过System.RuntimeTypeHandle类型能够显式地支持。对象引用总是指向对象头的类型句柄字段,而不是指向fields的第一个字段。我想可能出于两个原因,第一类可能没有fields。如果要指向一个没有fields的对象,这个引用就没处可指了。第二个原因是在后面的计论会发现,在访问对象时,总是要借助类型句柄。将引用指向类型句柄,出于性成的考虑。

给定类型的每一个对象头中都会有同样的类型句柄值。当从一个对象的引转到另一个引用类型时,必须考虑两个类型之间的关系。如果初始类型与目标类兼容,那么最终只是简单的将引用值复制过去。通常把一个派生类的引用传给基类的引用,就是这样的情形。如果赋值是从一个基类或接口引用到一个深度派生的类型时,CLR必须运行一遍测试。以确定对象的类型与所需要的类型是否兼容。CLR定义了两个指令:isinst和castclass。这两个指命都有两个参数:一个对象引用和一个用于表示所期望的引用类型的元数据标记。这两个指命都会检查对象的类型句柄,以确定对象的类型是否与请求的类型相兼容。如果测试成功,两个指令只是简单地将对象引用保存在堆栈上,如果测试失败castclass会抛出System.InvalidCastException类型的异常,ininst只是简单的在堆栈上保存一个空引用。

isinst与castclass都使用类型句柄所指的数据结构,来确定对象是否兼容指定的类型。这个结构被称之为CORINFO_CLASS_STRUCT,它保含许多关键信息。

如上图每个类都有一张接口表,它包含了类型所兼容的接口的入口项,每个入口项包含了接口的类型句柄。对接口类型的转换将通过这张表进行匹配。

C#对isinst提供了两个关键字,as和is。我们来看下面的代码

IA a = ….;

bool bIsIB = a is IB;

IB b = a as IB;

is关键字返回一个bool值,表示对象是否与指定的类型兼容。As则返回一个目标类型的引用,如果对象与目标类型不兼容则返回NULL。看起来比COM的QueryInterface简单多了,关键是这个转换是由CLR完成的。不需要特定的语言做一些额外的事情,它要做的只是发一个isinst。

C#通过强制类型转换操作符公开castclass指令。

IA a = ….;

try {

IB b = (IB)a;

}

Catch (System.InvalidCastException ex) {}

注意上面例子中的try,在使用强制转换是会产生异常的。

尽管我们不知道类型句柄所指的结构的细结,基于CLR工作的程序也不能直接访问类型句柄。但程序员可以通过System.Type类型访问。我们可以调用System.Object.GetType方法,获得对象的类型信息。在.NET中的所有类型,都是从System.Object继承的。

这个System.Type中的信息可不是简单的一个类名了事,我们可以通过它获得所有的基类及所兼容的所有接口。不但如此,我们还可以通过System.Type获得字段的信息,并且获取或修改指定字段的值。同样我们也可以通过System.Type获得方法的信息,并调用指定的方法。惊叹吧,.NET如此的强大!

Public static void DumpTypes(object o)

{

    // 获取对象的类型

    System.Type = o.GetType();

    // 列出该类型直接的或者间接的基类型

    For (System.Type c = type; c != NULL; c = c.BaseType)

        System.Console.WriteLine(c.AssemblyQualifiedName);

// 列出该类型显式的或者隐式的接口

    System.Type[] itfs = type.GetInterfaces();

    For (int i = 0; I < itfs.Length; i++)

System.Console.WriteLine(itfs[i]. AssemblyQualifiedName);

}

上面的例子展示了,如何通过System.Type获得接口信息。

Using System;

Using System.Reflection;

public sealed class Util

{

public static void DumpMembers(Type type)

{

    // 获取类型的成员

    BindingFlags f = BindingFlags.Static |

                BindingFlags.Instance |

                BindingFlags.Public |

                BindingFlags.NonPublic |

                BindingFlags.FlattenHierarchy;

    MemberInfo[] members = type.GetMembers(f);

    // 列出成员

    for (int i = 0; I < members.Lenth; i++)

        Console.WriteLine(”{0} {1} “,

                    members[i].MemberType,

                    members[i].Name);

}

}

上面的例子列出这指定类型的,所有成员。在这个例子中惟一没有列出的成员就是基类的private成员。

我看了上面这两个例子之后,第一个想到的是写C#程序的智能感知肯定要比C++要快而准,实际上的确如此。有如此强大的运行时类型支持,所以在.NET中,夸二进制重用不再需要接口了。当然接口的存在还有别的价值,在.NET中将接口定义为类型的公共约定。若干个类可以共享同一个公共约定,这种约定在运行时也有非常高的可靠性保障。

程序员经常会针对一个命名的值定义一对方法,典型的情形就是用一个方法get(获取)这个值,另一个方法set(设定)这个值。CLR提供一个附加元数据,属性(property)。属性是类型的成员,它指定一个或两个方法,并与属于该类型的命名值相对应。与字段一样,属性有名字和类型。与字段不同的是,属性没有存储空间。属性只是一个指向同一类型中其他方法的命名引用。看下面的C#例子

public sealed class Utils

{

public static void Adjust(Invoice inv)

{

    decimal amount = inv.Balance;

amount *= 1.0825;

imv.Balance = amount;

}

}

 

public sealed class Invoice

{

    // 属性定义就如同字段

    public decimal Balance

{

    // c#属性定义包含一个或两个方法定义

    get {

        return currentBalance;

}

set {

    if (value < 0)

        throw new System.ArgumentOutOfRangeException();

    currentBalance = value;

}

Internal decimal currentBalance;

}

}

我们看到,set方法判断了value不能小于零。如果没有这个判断,直接使用字段应该更简单与直接。

与属性一样CLR还提供了事件的支持,它引用同一类型中的其他方法。在被引用的方法中,有一个是用于注册事件处理程序。另一个则用于取消该注册。事件的类型必须派生于System.Delegate,在后面关于委托的讨论中会深入讨论这个类型。看下面的C#代码

using System;

public sealed class Utils {

    public static void FinishIt(Invoid inv, EventHandler eh)

{

    inv.OnSubmit += eh; // 调用add方法

    inv.CompleteWork(); // 进行可能产生事件的工作

    inv.OnSubmit -= eh;

    }

}

 

public sealed class Invoice {

    public event EventHandler OnSubmit

    {

        add {

            eh = (EventHandler)Delegate.Combine(eh, value);

        }

        remove {

            eh = (EventHandler)Delegate.Remove(eh, value);

        }

}

}

上例中前面的代码,用于注册与接收事件。其中有一个System.EventHandler类型,它从System.Delegate继承。后面我们讨论委托时,会发现创建一个这样的对象是非常简单的。为了满足你的好奇心,我先写一行代码给你看看。

EventHandler eventHandler = new
EventHandler(OnSubmit);

上面例子中的OnSubmit,就是一个参数类型符合EventHandler声明的函数。简单吧?

最后讨论一下索引属性。如果一个属性的方法接收的的参数不是setter方法的标准值参数,这个属性就被称为索引属性(indexed property)。C#中的每个类型只支持一个索引属性。看代码

public sealed class Utils

{

public static void Adjust(InvoiceLines iq)

{

iq["widget"] = 3; // 调用set方法

decimal cs = iq["qizmo"] // 调用get方法

iq["doodad"] = cs * 2; // 调用set方法

}

}

 

using System;

publid sealed class InvoiceLines

{

internal decimal cWidgets;

internal decimal cGizmos;

internal decimal cDooDads;

// 这里是索引器……….

public decimal this[string partID] {

get {

switch (partID)

case “widget”:

return cWidgets;

case “dizmo”:

    return cGizmos;

case “doodad”:

    return cDooDads;

default:

    throw new InvalidArgumentException(”partID”);

}

set {

switch (partID)

case “widget”:

cWidgets = value;

case “dizmo”:

    cGizmos = value;

case “doodad”:

    cDooDads = value;

default:

    throw new InvalidArgumentException(”partID”);

}

}

}

这个例子显的有些繁锁,不过也下说明了索引的灵活性。

方法

  • 方法和JIT编译
  • 调用与类型
  • 虚方法调用
  • 接口
  • 显式方法调用
  • 间接方法调用与委托

CLR只执行本机代码。如果一个方法体由CIL组成,那么它就必须在调用之前被转换为本机的机器码。JIT(just-in-time compilation),就是专门负责把CIL编译成机器码的组件。有了JIT使得.NET有着不亚于C++的性能。别跟我说你用C++写一个Hell world,我用C#写一个Hell world比一下谁的运行速度快。.NET的性能体现在,一个拥用许多二进制可执行模块的复杂应用之上的。系统越是复杂,就越能体现出.NET的优越性。下面让我们来看一下,JIT是如何工作的。

.NET最终发布的代码,都是基于CIL组成的。按照常规CIL的尺寸要比机器码小的多,原因很简单许多CIL指令,需要很多机器指令才能完成。CLR在第一次调用某个方法时,会对方法进行实时编译(JIT)。通常一个类中只有少数几个方法会被用到,那些没有被用到的方法也就没必要编译它了。CLR为每个类型在初始化时分配了一个内存数据结构(CORINFO_CLASS_STRUCT),并通过存储在每个对象中的RuntimeTypeHandle引用。在CORINFO_CLASS_STRUCT中存有一个方法表,这个方法表是一个带有长度前缀的内存地址数组,每个方法都有一个入口项。与COM不同的是,CLR方法表既包含实例方法的入口项,又包括静态方法的入口项。

CLR通过方法表路由所有的方法调用。

class Bob

{

static int x;

static void a(){x += 2;}

static void b(){x += 3;}

static void c() {x += 4;}

static void f()

{

        c(); b(); a();

}

}

实际上,Bob.f方法,经JIT编译后的代码是这样的:

;设立堆栈帧

push ebp

mov ebp, esp

 

;通过方法表调用Bob.c

call dword ptr ds:[37565ch]

 

; 通过方法表调用Bob.b

call dword ptr ds:[375658h]

; 通过方法表调用Bob.a

call dword ptr ds:[375654h]

 

;清理堆栈并返回

pop edp

ret

这三个call指令中使用的地值,分别对应于方法表中相应方法的入口项。它并没有直接调用方法编译后生成的代码地址,而是指向一个惟一的存根,让我想起了COM的代理与存根。初始化时每个存根包含一个JIT编译器的调用。在JIT编译生成本机代码后,它会重写存根例程,插入一个jmp指令跳转到刚才JIT编译的代码。这意味着对于方法随之而来的调用,除了调用点和方法体之间的jmp指令,不会有别的开销。

前两个图,第一个图展示了Bob.f调用过程中的Bob方法表情况,由于Bob.c已经被调用,所以c的存根只是一个jmp指令,它简单的将控制权传递给Bob.c的本机代码。Bob.a和Bob.b还没有被调用,a和b的存根例程将包含通用的call语句,它将控制权传递给JIT编译器。想不到CLR用了如此巧妙的方法,存根。如此JIT生成的调用代码与C++的代码就没什么区别了。

单一的jmp指令也可能产生性能问题。然而,扩展的jmp指令所提供的间接性,使得CLR能够很快的调整工作集。CLR能名轻松的将目标代码移来移去,要移动一般代码只要改一下存根的jmp指令就可以了。CLR可以将经常被调用的方法,放在当前内存分页中。把不常用的方法放在,其它分页中被切换到磁盘。把不再被调用的方法抛弃,重新将jmp指令指向JIT例程。从这一点看.NET的性能,还可能比C++做的好。

CLR使用类型的方法表来定位目标方法的地址。在前面的讨论中,发现JIT生成的代码直接将方法表中对应的入口项地址应用于call指令。这意味着派生类型存在一个名字和签名恰好匹配的方法,也总是分发到自己的方法去。这类调用JIT是根据调用时,对象的引用类型去查找方法表,确定存根地址。

为了使JIT考虑对象的具体类型,需要把方法声明为virtual。虚方法是一个实例方法,其实现可被派生类重写。在开发时编译器(指的是高级语言编译器如C#,并不是JIT)遇到一个虚方法调用时,它生成一个callvirt指令而不是传统的call指令。JIT遇到callvirt与call的处理结果是不同的。JIT对callvirt指令产生一个特别的指令,它将根据目标对象的具体类型决定调用哪个方法。

对于虚方法和非虚方法,CLR在方法表中分配的入口项是不同的。方法表中有两个相邻的区域,第一个区域用于虚方法,第二个区域用于非虚方法。第一个区域将包含每一个被声明为virtual的方法入口项,不管该方法是在当前类型中,还是在基类型与接口中。CLR总是把基类型的虚方法放在前面,这样更有利于虚方法的分发,因为在继承结构中用于特定虚方法的索引都是相同的。到这里我也明白,.NET为什么只能有一个基类不能多重继承。JIT生成的代码会获取,指定对象的方法表。然后在方法表上加一个固定的偏移量,执行call指命。目标代码大概是这样

mov ecx, esi

mov eax, dword ptr [ecx]

call dword ptr [eax + 38h]

第一个mov指令将目标对象的引用存储在ecx寄存器中。第二个mov指令将对象的类型句柄存储在eax寄存器中。第三条指定在给eax加上一个偏移量,执行call。一段高效的代码,看起来与C++生成的代码一样。这里的38h是个常量,JIT在编译时计算出来的。这里就算谁插入了一个虚方法在中间,JIT在编译时也会根据方法的新位置,计算出一个新的常量用于目标代码。既保证了灵活性,又确保了执行效率。

.NET的虚方法,有很大的灵活性。可以将一个方法声明为newslot,表示这个方法与基类的虚方法无关。如果不想让子类替换我的实现,可以将方法声明为final,禁止替换虚方法。

在C++和COM中,一个给定的具体类型对于每个基类或支持的接,都有一个方法表。而在CLR中,一个给定的具体类型只有一个方法表。以此类推,一个基于CLR的对象只有一个类型句柄。对于C++和COM而言,一个对象往往会按基类或每个接口而有一个虚函数指针。

由于一个类型可以支持多个接口,所以对于支持给定接口的所有类型,方法表对应表项的绝对偏移量可能是不同的。为了处理这种变化,当通过基于接口的引用调用虚方法时,CLR将添加另外一层间接性。

CORINFO_CLASS_STRUCT包含指向描述类型所支持接口的两个表的指针。类型转换使用其中的一个表,确定类型是否支持给定的接口。第二个表是接口偏移量表,当由基于接口的对象引用分发虚方法调用时,CLR将会使用它。一个基于接口引用的方法调用,必须首先在该对象相应的方法表中定位表项的范围。在CLR找到这个偏移量后,它将添加方法相关的偏移量,并分发调用。

在前面运行时类型中提到,System.Type及其相关类库可以访问类型信息。前面还提到通过System.Reflection.MethodInfo类型,可以获得方法的信息。事实上,它通过MethodInfo.Invoke方法公开了,调用相应函数的功能。

using System;

using System.Globalization;

namespace System.Reflection {

public abstract class MethodInfo : MethodBase {

public virtual object Invoke(object target,

                        BindingFlags invokeAttr,

                        Binder binder,

                        object[] args,

                        CultureInfo culture);

public virtual object Invoke(object target, object[] args);

}

}

Invoke有两个版本,其中较为复杂的一个允许调用方提供映射码,以处理参数类型不匹配和重载解析。较简单的方法,假定调用方提供了底层方法期望出现的参数。强啊!既支持高效的函数调用,又支持动态间接调用。MethodInfo.MethodHandle是一个指向System.RuntimeMethodHandle的对象的引用,通过RuntimeMethodHandle的GetFunctionPointer方法能获得底层代码的地址。得到这个地址后,能够直接调用这个方法,而不用承担Invoke的开销。

因为MethodInfo对象属于一个类型,而不是对象,所以在使用MethodInfo调用方法时,需要在每次调用方法时都显式地提供目标对象的引用。在很多方面,这都能满足要求。不过,你还需要经常需要将一个特定的方法绑定到一个特定的对象上,这就需要用委托。委托对象必须属于一个与底层方法签名相关的委托类型。委托有一个Invoke方法,帮定到委托的方法都必须有与该委托的Invoke方法一致的签名。CLR将在编译时和运行时强制实施这种签名的匹配。看看下面的C#语句:

public delegate int AddProc(int x, int y);

这条语句定义了一个名为AddProc的委托类型,有点像C++中定义函数指针。

using System;

public delegate int BinaryOp(int x, int y); // 声明委托类型

 

public class MathCode

{

internal int sum = 0;

public int add(int m, int n)

{

sum += m + n;

return m + n;

}

 

public static int Subtract(int a, int b)

{

return a – b;

}

}

 

class app

{

static void Main()

{

MathCode target = new MathCode();

 

// 只要方法参数类型能匹配,委托可以帮定到不同的方法上

BinaryOp op1 = new BinaryOp(MathCode.Subtract); // 创建委托

BinaryOp op2 = new BinaryOp(MathCode.Add); // 创建委托

 

int a = op1(3, 1); // a == 2

//int a = op1.Invoke(3, 1);

int b = op2(3, 1); // b== 4

//int b = op2.Invoke(3, 1);

}

}

上面的例子展示了声明委托、创建委托、调用委托帮定的底层方法的全过程。C#处理委托调用时,不能通过名字显式地调用Invoke方法。它省略了Invoke名,使看起来更像C风格的函数指针调用。我觉得这没增加什么好处,返而对代码的理解带来的困扰。

Invoke的CLR实现支持多路委托链,因此单个Invoke调用可以同时触发多个方法调用。System.Delegate提供两个方法用于管理委托链:Combine和Remove。

namespace System {

public abstract Delegate : ICloneable, ISerializable

{

static public Delegate Combine(Delegate a, Delegate b);

static public Delegate Combine(Delegate[], delegates);

static public Delegate Remove(Delegate src, Delegate node);

}

}

前面提到的事件,就是这个委托实现的。其中的类型EventHandler,就是一个委托类型的例子。前面提到,事件成员帮助我们解决Combine与Remove的工作。事件使用委托,具有强类型检查与高度灵活性。

CLR同样也支持异步方法调用,通过使用一个工作队列实现异步方法调用。当异步调用一个方法时,CLR将方法参数和目标方法的地址打包到一个请求消息中。接着,CLR将这条消息插入到工作队列中。CLR维护一个操作系统级别的线程池,用于监听这个工作队列。当队列上的请求到达时,CLR从线程池中分发一个线程来执行工作。要执行异步调用,必须通过委托对象。委托类型还有另外两个方法用于执行异步调用,BeginInvoke方法和EndInvoke方法。BeginInvoke方法与Invoke的签名相似,所不同的是BeginInvoke接收两个客外的参数,用设定调用的处理方式。BeginInvoke总是返回一个调用对象的引用,这个调用对象表示该方法执行期间状态,并且能够用于在运行过程中控制和检测调用。EndInvoke方法将返回与Invoke相同类型的返回值,同样它还接受Invoke的所有标记为ref及out的参数。这样调用者就可以通过,EndInvoke获得调用过程通过参数返回的值。

致此,我们对.NET有了一个较深入的认识。发现.NET通过元数据(二进制类型信息),为我们提供运态类型转换、运行时类型检查、完整的面向对象的二进制开发方案。通过CORINFO_CLASS_STRUCT中的方法表间接调用,确保调用的安全、高效、与灵活。与COM通过接口实现二进制重用不同,.NET通过类型的元数据实现二进制重用。通常如果要实现二进制重用,并不需要接口的参与。在.NET中使用接口的目的只有一个,构建高度抽象的程序架构。通常在整个应用程序中,只占了很少一部份。在.NET中,基于接口的调用,JIT会生成代码通过接口表动态的确定接口在方法表中的位子。对于调用是一个不小的开消息,但要是对同一个对象进行平凡的调用,JIT还是能够很容易的进行优化。而且在整个应用程序中占很少一部份,所以这样的开销可以接受的。由此我得出了一个结论,.NET是一种更好的二进制重用方案。