评论区实在是看不下去,把书本知识当作死教条,所以必须要出来答一下。
Rule 1,严格来说,各个编译器会怎么实现虚函数是各个平台自己的事,标准并没有一个统一的要求。甚至都不一定要用虚表实现。这里我们忽略这条,只讨论现有主流的实现方式。
一、
首先,我们回顾以下这个最经典的例子,也是题主在题目中举到的例子。
void f(); void g(); struct Base { virtual void virtualFun() { f(); } }; struct Derived : Base { virtual void virtualFun() { g(); } }; void fun(Base * b) { b->virtualFun(); }
编译结果:
在编译函数 fun
时,函数参数 b 表面上是个指向 Base
类型的指针。但是,由于多态机制的存在,“指向 Base
类型”只是个马甲,实际上这个 b 可能指向 Base
类型的变量,可能指向的是 Derived
类型的变量,甚至也有可能指向的是一个定义在其他源文件里的、编译器暂时还不知道的某个子类。再甚至,还有可能是一个程序员还没有编写出来的类型。它们可能直接复用了基类 Base
的 virtualFun
,也有可能是自己定义了新版的 virtualFun
覆盖掉了上一个祖先类的版本。不管怎样,在编译器编译 fun
函数的这一刻,尚无法知道 b 实际指向对象的 virtualFun
是谁。因此,只能去虚表中取函数指针 (movq (%rdi), %rax
),然后再调用 (call *(%rax)
)。
这便是我们所讲的“运行期绑定”(或者动态绑定/迟绑定,都是同一个概念,不同叫法)。
二、
稍稍修改下 fun
,将参数改为传值:
void f(); void g(); struct Base { virtual void virtualFun() { f(); } }; struct Derived : Base { virtual void virtualFun() { g(); } }; void fun(Base b) { b.virtualFun(); }
编译结果:
发现编译结果变了。给出的汇编结果中,fun
里很明确地调用了 void f()
。
这是因为,此例下的 b 变量是真真实实的 Base
类型。函数调用方向 fun
里传的是参数,无论是什么样形形色色的子类,在值传递下,都是将子类中继承自 Base
的那部分单独拎出来,复制出一份副本,成为这里的 b。所以 b.virtualFun()
这里,尽管调用的是一个虚函数,但是决不会涉及到运行期的绑定。因为 b 的类型和它实际的 virtualFun
已经是确定的了。
所以,为什么教材上会说:
只有通过指针或者引用才能表现出多态性,值语义是不能表现出多态的。
虚函数运行期绑定的性质只有在指针或者引用下能用,通过值调用的虚函数是编译器静态绑定,是没有运行期绑定的性质的。
后来想到一条,回来补充一个 2.5
在使用限定名字查找时,即使是通过指针或者引用,虚函数也不表现多态性:
void f(Base * p, Base & r) { p->Base::virtualFun(); r.Base::virtualFun(); }
在此例中,已明确要求调用 Base
的 virtualFun
,故是不会在运行期去查找虚表的。
三、
以上两条是 C++98 时代人人都该会的老知识了,食大便了,该整点新的了。
C++11 引入的 final
关键字,乍一看只是一个从其他语言抄来的小功能,但对于这个问题而言,却是一个颠覆游戏规则的存在。
代码:
void f(); void g(); struct Base { virtual void virtualFun() { f(); } }; struct Derived : Base { virtual void virtualFun() final { g(); } }; void fun(Derived * p) { p->virtualFun(); }
编译结果:
第三例的 fun
里就明确地 call 了 void g()
。有的读者看到这里可能会问,之前不是说可能会有其他子类么,这里编译器怎么就这么肯定 p 一定是指向的 Derived
类呢?
其实,p 完全有可能指向的是其他子类,但是注意 Derived::virtualFun
后面的 final
。有了这个 final
就阻止了 Derived
的子类写新版的 virtualFun
覆盖 Derived::virtualFun
,肯定也就保证了无论怎么继承下去,这些子类一定都是用的 Derived
版的 virtualFun
。
因此,此处一定是编译时绑定,而不是运行时绑定。
再扩大一点,用 final 修饰类:
void f(); void g(); struct Base { virtual void virtualFun() { f(); } }; struct Derived final : Base { virtual void virtualFun() { g(); } }; void fun(Derived * p) { p->virtualFun(); }
编译结果:
这里 Derived
都成断子绝孙类了,也就不用担心将来会有什么子类覆盖 virtualFun
了。
因此,这里也是编译时绑定。
小结:
(since C++11) final 对虚函数的多态性具有向下阻断作用。经 final 修饰的虚函数或经 final 修饰的类的所有虚函数,自该级起,不再具有多态性。
代码优化小 Tips:
业务代码中,对于多态类,如果确定一个虚函数不会再被覆盖,或者该类不会再被继承,则推荐标上 final。这可以为编译器提供非常有价值的编译优化信息,总而将原本需要推迟到运行期才能确定的虚函数调用提前在编译期就已确定。如被调用的函数能与上层调用方一起进一步地做函数内联、常量折叠、无用代码消除等优化,则可以压榨出非常可观的性能提升。
四、
最后,必须指出,这个问题的探讨应当是结合现代编译器的优化能力谈的。2021 了还抱着一些“新世纪初编写的教材”只会让你越来越来与时代脱节。
题主问题代码改:
void f(); void g(); struct Base { virtual void virtualFun() { f(); } }; struct Derived : Base { virtual void virtualFun() { g(); } }; int main() { // 编译期就分配了虚拟地址 Derived d; // 编译期也有虚拟地址 Base & b = d; // 此时b 对象是分配了内存了的 // 且虚指针指向了他的虚函数表 // 就是说可以拿到该虚函数的地址 // 按道理来说,此时可以在编译期就在绑定他的虚函数地址 b.virtualFun(); }
这是当前最新的 g++-12 的编译结果:
哪怕是早在 2013 年发布的 g++-4.6.4 都已经不会去查虚表了。。。
这种生命周期比较局部的对象,从理论上讲,其虚函数调用完全可以在编译期就得到确定。实际上,也确实在这几年得到了实现。
这种通过人眼一眼都能看出指针/引用实际指向类型的简单情形,编译器没优化才是 silly 的。
题主甚至都可以大胆一点,加点动态内存分配,让代码再“动态”一点:
void f(); void g(); struct Base { virtual void virtualFun() { f(); } }; struct Derived : Base { virtual void virtualFun() { g(); } }; int main() { Derived * p = new Derived(); Base * pb = p; pb->virtualFun(); delete p; }
g++-4.6.4 依然可以认出 pb 实际指向的是 Derived 类型:
甚至在 g++-12 里面,new 跟 delete 都可以跟你优化没了:
正常人都能看得出调用的肯定是 Derived
的 virtualFun
,为什么必须留到运行时绑定?为什么不能优化?
五、
所以正是基于以上的优化的可能,C++20 在常量求值中放宽了 new/delete 和虚函数后,以下的代码也从原来的不敢想变成了可能:
struct Base { constexpr virtual ~Base() {} constexpr virtual int virtualFun() { return 1; } }; struct Derived : Base { constexpr virtual ~Derived() {} constexpr virtual int virtualFun() { return 2; } }; constexpr int f() { Base * p = new Derived(); int r = p->virtualFun(); delete p; return r; } int main() { constexpr int r = f(); static_assert(r == 2, ""); // 静态断言通过! return r; }
编译结果:
直接编译期算出 2,汇编中干干净净。
早期的编译期画风则是这样的:
所以呐,建议各位勤动手做做实践,死守着书本眼光真的会很狭窄啊!