为什么不能在编译时解决运行时多态性?

Why can't run-time polymorphism be solved at compile time?

本文关键字:运行时 多态性 解决 不能 编译 为什么      更新时间:2023-10-16

考虑:

#include<iostream>
using namespace std;
class Base
{
    public:
        virtual void show() { cout<<" In Base n"; }
};
class Derived: public Base
{
    public:
       void show() { cout<<"In Derived n"; }
};
int main(void)
{
    Base *bp = new Derived;
    bp->show();  // RUN-TIME POLYMORPHISM
    return 0;
}
为什么

这段代码会导致运行时多态性,为什么不能在编译时解决?

因为在一般情况下,在编译时无法确定它在运行时的类型。 您的示例可以在编译时解决(请参阅@Quentin的答案(,但可以构造不能解决的情况,例如:

Base *bp;
if (rand() % 10 < 5)
    bp = new Derived;
else
    bp = new Base;
bp->show(); // only known at run time

编辑:感谢@nwp,这是一个更好的案例。 像这样:

Base *bp;
char c;
std::cin >> c;
if (c == 'd')
    bp = new Derived;
else
    bp = new Base;
bp->show(); // only known at run time 

此外,通过图灵证明的推论,可以证明,在一般情况下,C++编译器在数学上不可能知道基类指针在运行时指向什么。

假设我们有一个类似C++编译器的函数:

bool bp_points_to_base(const string& program_file);

它以输入program_file任何C++源代码文本文件的名称,其中指针bp(如在OP中(调用其virtual成员函数show()。 并且可以在一般情况下(在序列点A,首先通过bp调用virtual成员函数show()(:指针bp是否指向Base的实例。

考虑C++程序"q.cpp"的以下片段:

Base *bp;
if (bp_points_to_base("q.cpp")) // invokes bp_points_to_base on itself
    bp = new Derived;
else
    bp = new Base;
bp->show();  // sequence point A

现在,如果bp_points_to_base确定在"q.cpp"中:bp指向A处的Base实例,则"q.cpp"指向A处的其他bp。 如果它在"q.cpp"中确定了这一点:bp不指向A处的Base实例,则"q.cpp"bp指向A处的Base实例。 这是一个矛盾。 所以我们最初的假设是不正确的。 所以bp_points_to_base不能为一般情况写。

当对象的静态类型已知时,编译器通常会对此类调用进行非虚拟化处理。将代码按原样粘贴到编译器资源管理器中将生成以下程序集:

main:                                   # @main
        pushq   %rax
        movl    std::cout, %edi
        movl    $.L.str, %esi
        movl    $12, %edx
        callq   std::basic_ostream<char, std::char_traits<char> >& std::__ostream_insert<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&, char const*, long)
        xorl    %eax, %eax
        popq    %rdx
        retq
        pushq   %rax
        movl    std::__ioinit, %edi
        callq   std::ios_base::Init::Init()
        movl    std::ios_base::Init::~Init(), %edi
        movl    std::__ioinit, %esi
        movl    $__dso_handle, %edx
        popq    %rax
        jmp     __cxa_atexit            # TAILCALL
.L.str:
        .asciz  "In Derived n"

即使无法读取程序集,也可以看到可执行文件中仅存在"In Derived n"。不仅动态调度得到了优化,整个基类也得到了优化。

为什么

这段代码会导致运行时多态性,为什么不能在编译时解决?

是什么让你认为它确实如此?

您正在做出一个常见的假设:仅仅因为语言将此情况标识为使用运行时多态性并不意味着实现在运行时被调度。C++标准有一个所谓的"假设"规则:C++标准的可观察效果是针对抽象机器描述的,并且实现可以自由地实现他们希望的任何方式实现所述可观察的效果。


实际上,去虚拟化是用于谈论编译器优化的通用词,旨在解决在编译时对虚拟方法的调用。

目标不是削减几乎不明显的虚拟呼叫开销(如果分支预测运行良好(,而是删除黑匣子。就优化而言,最好的收益在于联调用:这打开了不断的传播和大量的优化,并且只有在编译时知道被调用的函数的主体时才能实现内联(因为它涉及删除调用并将其替换为函数体(。

一些去虚拟化机会:

  • final方法或final类的virtual方法的调用被简单地去虚拟化
  • 如果匿名命名空间中定义的类
  • 是层次结构中的叶子,则可以取消虚拟化对匿名命名空间中定义的类的 virtual 方法的调用
  • 如果在编译时可以建立对象的动态类型,则通过基类对 virtual 方法的调用可能会被取消虚拟化(您的示例就是这种情况,构造在同一函数中(

但是,对于最新技术,您将需要阅读Honza Hubička的博客。Honza 是一名 gcc 开发人员,去年他致力于推测性去虚拟化:目标是计算动态类型为 A、B 或 C 的概率,然后推测性地去虚拟化调用,有点像转换:

Base& b = ...;
b.call();

到:

Base& b = ...;
if      (b.vptr == &VTableOfA) { static_cast<A&>(b).call(); }
else if (b.vptr == &VTableOfB) { static_cast<B&>(b).call(); }
else if (b.vptr == &VTableOfC) { static_cast<C&>(b).call(); }
else                           { b.call(); } // virtual call as last resort

Honza做了一个由5部分组成的帖子:

  • C++中的去虚拟化,第 1 部分
  • C++ 中的去虚拟化,第 2 部分(通过将存储转发到负载的低级中端去虚拟化(
  • C++ 中的去虚拟化,第 3 部分(构建类型层次结构(
  • C++ 中的去虚拟化,第 4 部分(分析类型继承图以获得乐趣和利润(
  • C++中的去虚拟化,第 5 部分(反馈驱动的去虚拟化(

编译器通常不能用静态调用替换运行时决策的原因有很多,主要是因为它涉及编译时不可用的信息,例如配置或用户输入。除此之外,我想指出另外两个原因,为什么这在一般情况下是不可能的。

首先,C++编译模型基于单独的编译单元。编译一个单元时,编译器只知道正在编译的源文件中定义了什么。考虑一个编译单元,其中包含一个基类和一个引用基类的函数:

struct Base {
    virtual void polymorphic() = 0;
};
void foo(Base& b) {b.polymorphic();}

单独编译时,编译器不知道实现Base的类型,因此无法删除动态调度。这也不是我们想要的,因为我们希望能够通过实现接口来扩展具有新功能的程序。可以在链接时执行此操作,但前提是程序完全完成。动态库可以打破这一假设,如下文所示,总会有根本不可能的情况。

一个更根本的原因来自可计算性理论。即使有完整的信息,也不可能定义一种算法来计算程序中的某一行是否会被达到。如果可以的话,你可以解决停止问题:对于一个程序P,我通过在P末尾添加一个额外的行来创建一个新的程序P'。该算法现在能够决定是否达到该线,从而解决了停止问题。

无法

在一般情况下决定意味着编译器无法决定将哪个值分配给变量,例如

bool someFunction( /* arbitrary parameters */ ) {
     // ...
}
// ...
Base* b = nullptr;
if (someFunction( ... ))
    b = new Derived1();
else
    b = new Derived2();
b->polymorphicFunction();

即使在编译时知道所有参数,也不可能一般地证明将采用哪种路径通过程序以及b将具有哪种静态类型。近似可以通过优化编译器来实现,但总有一些情况它不起作用。

话虽如此,C++编译器非常努力地删除动态调度,因为它打开了许多其他优化机会,主要是通过代码内联和传播知识。如果你有兴趣,你可以找到一个有趣的关于GCC去虚拟化实现的博客文章。

如果优化程序选择这样做,这可以在编译时轻松解决。

该标准指定的行为与运行时多态性发生的行为相同。 它没有具体说明通过实际运行时多态性来实现的。

基本上,编译器应该能够弄清楚,在非常简单的情况下,这不应该导致运行时多态性。很可能有编译器确实这样做,但这主要是一个猜想。

问题是一般情况,当你

实际构建一个复杂的,除了库依赖的情况,或者分析编译后多个编译单元的复杂性,这将需要保留同一代码的多个版本,这将吹走 AST 生成,真正的问题归结为可判定性和停止问题。

后者不允许解决问题,如果呼叫可以 在一般情况下去虚拟化。

停止问题是决定给定输入的程序是否会停止(我们说程序输入对停止(。 众所周知,没有通用算法,例如编译器,可以求解所有可能的程序输入对。

为了使编译器能够为任何程序决定是否应将调用设置为虚拟调用,它应该能够为所有可能的程序输入对确定调用。

为了做到这一点,编译器需要有一个算法 A,该算法决定给定的程序 P1 和程序 P2,其中 P2 进行虚拟调用,然后程序 P3 { while( {P1,I} != {P2,I} ( } 对于任何输入 I 停止。

因此,能够找出所有可能的去虚拟化的编译器应该能够决定对于所有可能的 P3 和 I 的任何对 (P3,I(;这对于所有可能的 P3 和 I 都是不可判定的,因为 A 不存在。但是,对于可以眼球的特定情况,可以决定。

这就是为什么在您的情况下,呼叫可以去虚拟化,但不能 箱。