虚函数的实现方法

来源:互联网 发布:淘宝怎么搜115会员 编辑:程序博客网 时间:2024/05/22 11:55
在对面向对象语言的研究中,可以发现很多种对多态函数的存储方法和调用方法。

首先,因为函数的多态性,它必须使用某种方法和引用它的对象产生关联。否则,多态就无从谈起。

那么,最简单,最直观的方法,就是:

语言在运行时保存对象的动态类型信息。在调用函数时,先检查对象的真正类型,然后调用不同版本的函数,类似这样:

代码:

if(obj is type1){
    type1::f();
}
else if(obj is type2)[
    type2::f();
}
......

以上这段代码可以由编译器生成,所以,并没有一般的rtti的繁琐和不安全的问题。

这种方法的问题是:

1。需要储存对象的类型。需要RTTI
2。结构是封闭的。编译器在生成代码时,需要知道一共可能有几种不同的类型,以便生成这个if-else结构。
3。代码会有膨胀。因为如果一旦有一百种不同的类型,每个对虚函数的调用都要有这么个大if-else。
4。效率低,一则要动态判断类型,二则if-else采用的是链表式的搜索。对在表尾部的匹配效率很低。

当然,它也有优点:
顺序执行的代码对cpu的指令prefetch和流水线比较友好。



于是,又出现了很多更聪明的方法。我们把这些不同的组织虚函数的方法叫做:method suite.
  • 方法一:在对象中存入函数指针。在c中如果要模拟多态,这是一种可以经常采用的方法。[list:9a837e5f8f]优点:一个虚函数调用就是一个call [ptr]这样的调用,效率最高。
    缺点:如果一个类有很多虚函数。函数指针非常耗费对象的内存。

方法二:在对象中存入vtable的指针。而在vtable中存入所有虚函数的入口。每个类的vtable在所有类对象实例间共享。这也是c++所采用的方法。
优点:节省空间。每个对象只需要储存vtable的指针。
缺点:调用虚函数时需要多一次间接寻址。

方法三:
既然链表式查询较慢,那么我们就查hashtable嘛。把所有的函数入口存入一个hashtable. 然后调用时通过这个hashtable来查找函数指针。据说java的实现就采用了这个方法。
优点:简单。
缺点:hashtable的效率不如vtable. (不过,据说java使用了cache等技术,使得这个效率差距不是很大)[/list:u:9a837e5f8f]



以上三种方法,在接口的多重继承上,又有些需要考虑的地方。
假设,我有以下一个设计:
代码:

interface IA{
    void f();
};
interface B{
    void g();
};
class C:IA, IB{
......
};

有这样一段调用代码:
代码:

void testa(A* pa){
  if(pa){
    pa->f();
  }
}
void testb(B* pb){
    if(pb){
      pb->g();
    }
}
void testc(C* pc){
    testa(pc);
    testb(pc);
}

对编译型语言来说,需要给pa->f(); 和pb->g();定义一个调用规则。一种普遍的方法是,从接口定义出发。第一个函数在vtable的偏移0处,第二个函数在vtable偏移4处。
于是,从接口IA来看,f()函数的指针应该在vtable的偏移0,而从接口IB来看,g()函数入口也应该在vtable的偏移0。

可问题是,f()和g()都在类C中。



对方法一,这个矛盾怎么解决呢?既然偏移要不同,函数指针也不同,那么唯一的可能是:两个vtable的指针不同。如果我们让IA的指针等于C的指针,那么,IB的指针就必然要向下移动一些距离,把所有IA的虚函数指针都让出来。(这个距离在此例中是4,在IA的函数多于一个得时候,就要相应的大。)这样才能对接口IB保证偏移量的正确。
于是,当我们调用testb(pc)时,编译器需要偷偷地把pc移动到合适的位置上去。

可是,这又引出另一个问题。当我们调用pb->g()的时候,我们会把pb当作g()函数的this指针传入函数g。而如果我们调用pc->g()的时候,我们会把pc当作g()函数的this指针传入。
而从g()函数内部,我们不可能知道别人是调用的pb->g()还是pc->g().

而pb和pc是不相等的!我们怎么可能期待这个函数对不相等的this指针产生一样的结果呢?


一个解决方法是这样:
在编译C::g()函数时,我们假设this指针指向一个C类型的对象。不考虑偏移调整的问题。对从类型C调用的f()函数,我们采用静态解析,根本不用存在对象中的函数指针。
而对于多态调用pb->g(), 我们单独生成一个小函数,并把它的地址放入vtable. 它的唯一的作用就是把传入的pb指针朝上再挪回去,然后调用那个真正的g()函数。
于是,这个指针就会在调用时向下挪动,然后在真正达到那个函数之前,又被挪上去。





对方法二, 这里,因为对象里并不储存虚函数,只储存虚函数表指针。
既然两个函数都要求0偏移,它们不可能被储存在一个vtable中。
(注意,此处,pb和pa不可能象方法一那样被移动到vtable当中,因为它们还要能照顾到this指针的需求,能够找到对象本身才行。)
于是,IB和IA要有各自不同的vtable。编译器只好对类C生成两个vtable, 然后在每个C类对象中放入两个vtable指针。

剩下的事就和方法一差不多了,我们可以采用同样的调整指针的方法。而且同样需要移动指针两次。
唯一的区别是,方法一中挪动的距离是跨过所有间隔的虚函数。而方法二中只是移动到合适的vtable指针上去。
COM的二进制规范就是严格按照这个vtable scheme定义的。

方法三。不同于前两个方法,虚函数的解析不依赖偏移,而是依赖于函数的签名。而java中,一个类中,相同的虚函数签名只表示相同的函数。不管是从哪一个接口中来得。于是,调用的时候不需要移动指针。只要查一下hashtable就行了。





以上谈的方法一和方法二,都还共有一些问题。这些问题,都是因为移动指针造成的。

  • 1。 对于特殊的0指针,在从C* upcast到IA*和IB*的时候,就不能做偏移。因为,程序中可能依赖于对0指针的判断。
    既然如此,那么为了安全,编译器在所有它不能肯定值是否为0的从C*到IB*的转换处,都要加入对0指针的判断。效率因此受到影响。(注意,在c++中,即使是引用也无法保证不是0指针)

    2。面向对象程序中最常见的指针upcast, 因为这个指针移动,从0开销变成了有开销。

    3。如果从IB*转换到IA*, 编译器将不知道怎么移动指针,需要程序员来告诉编译器转换的方法。这容易产生混淆。

    4。允许指针指在对象的中间而不是开始,使得开发垃圾收集算法更加困难。


上面的第三种方法,虽然hashtable本身效率较低,但却没有这些问题。可以说有得有失。




那么,有没有既有vtable的高效率,又没有上面提到的各种麻烦的方法呢?下面介绍的一种有趣的vtable解决方法,就兼有vtable的效率和hashtable的简单。


让我们回顾一下前面提到的vtable的方法。困难出现在IA和IB都要求自己的函数出现在0偏移处。

我们的解决方法是通过挪动vtable指针来满足这两个冲突的接口的要求。
那么,如果我们想办法不让接口IA和接口IB要求同样的偏移呢?
如果,可以让接口IB使用偏移4,接口IA使用偏移0,那么不就相安无事了吗?

这种方法就是放弃对每个接口采用同样的固定布局。
每个类将只有一个vtable。编译器通过分析所有接口和实现这些接口的类,来在编译时给接口确定各个函数的偏移位置。

基本上,被同一个类实现的接口都是冲突的,它们不能共用相同的偏移 (除非是同样的签名)。而不搭届的接口则是无所谓的。

于是,IA::f()和IB::g()就是互相冲突的。如果让f()占用偏移0,g()就只能占用偏移4。

算法上,这是个最小涂色问题。两个互相冲突的函数就是两个之间有连线的节点。否则就没有连线。通过找到最小涂色方案,就可以分配偏移。

因为涂色问题是np hard的,所以,可以采取一些近优算法来找寻方案。

  • 优点:效率高。没有指针移动。不需要判断0指针。指针永远指向对象开始处。
    缺点:封闭式结构。编译器需要静态知道所有的接口实现情况。(象一个无所不知的上帝)。象com这种分布式的开放结构将不能工作。


总结以上的几种方法。
要高效和支持分布式组件: 使用c++的vtable方式。
要高效和简单:使用这个变种的vtable。效率最高!
要简单和支持分布式组建: 可以使用java的hashtable方式。
原创粉丝点击