虚函数:什么时候我们真的需要它?
虚函数是函数指针的一种特殊的,可优化的语法糖,详见这个问题:https://stackoverflow.com/q/7046739/14033810
作为语法糖,它所做的让语言更简单,更安全的改进是限制了函数调用的范围。函数指针不再指向每一个类型匹配的函数(或任一个函数,如果bypass C的类型检查),而是被限制在只能调用一个类与其子类的同名函数里。
而更优化的点在于,通过type deduction,编译器将indirect call变成direct call,从而减少一次取地址的开销。
直观上,在可执行文件里,所有虚函数调用都可以被devirtualize,因为经过链接后,所有类的类型均已知,通过虚函数调用的真正函数已经全部可以通过人力推断。但事实上,仍然有必须使用vtable的场合:
class A
{
public:
virtual void foo();
}
class A1: public A
{
public:
void foo() override;
}
class A2: public A
{
public:
void foo() override;
}
class B
{
private:
A *a;
public:
virtual void foo() {a->foo();};
}
int main()
{
A1 a1;
A2 a2;
B b1, b2;
b1.a = &a1;
b2.a = &a2;
b1.foo();
b2.foo();
}
在上例中,在链接结束后,我们可以充分地知道每个indirect call调用的具体函数是哪个。这使得b1.foo
和b2.foo
都没有必要再通过vtable实现(尽管在定义上它仍然是虚函数)。
然而,对a->foo
而言则不然。由于B:foo()
的代码只有一份,而这个函数指针可能指代多个函数,此时就必须以某种方式区分对多个可能的函数指针的访问,也就是vtable。在上例中,B::foo()
必须通过vtable调用a->foo
。
判断是否能devirtualize似乎可以通过this
指针的类型确定。在上例中,b1
和b2
本身作为this
指针时,调用一个自身定义的虚函数,此时有充足的信息进行判断。而在B::foo()
中,由于this
指针类型为B
,而调用的函数为A::foo()
,超出了这个类本身的定义,所以必须使用vtable。
讨论:
-
多态和动态链接的概念有很大的相似性,都属于“不知道地址是多少,在运行时确定”。然而,因为动态链接中函数的signature独一,函数指针指向的函数固定,所以基于PLT的调用是可以被完全消灭的,只是会牺牲动态链接带来的好处。而多态则不然,调用的函数不固定,带来的最大特点就是同一个
call
有多个可能调用的函数。因此,如果想要做到彻底的devirtualize,就必须为同一个函数引入多版代码,从而产生无理论上界的代码大小。 -
除去代码尺寸外,完全的devirtualization无法在一个pass内做完,继承链有几层,就要做几个pass,可能产生很大的时间开销。这件事可以在编译时做,也可以在运行时做。这理应是一个tradeoff。
-
inline可以函数不同版本的问题。如上例中,虽然
B::foo()
的代码只有一份,但B::foo()
的本质是调用this->a->foo()
,因此把这一层函数调用去掉之后,此时this->a
成为了新的this
,又可以推断虚函数类型了。当然,函数是否inline是一个较复杂的话题,实现中使用了heuristic来确定,这里没有深究。一个合理的heuristic设计应当能发现inline这个函数能够消除虚函数调用,从而增加其被inline的概率。