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


73

考虑:

#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;
}

为什么此代码会导致运行时多态,为什么在编译时无法解决?


13
如果要编译时多态,请查看奇怪的重复模板模式
保罗·鲁尼2015年

4
您是在问这个简单案例还是一般案例?
Theodoros Chatzigiannakis 2015年

13
一般情况是无法确定的,因为它涉及解决停止问题(或等效问题,例如程序等效)。从实际的角度来看,它也是没有意义的,因为它可能取决于运行时信息,例如用户输入。
詹斯(Jens)

2
@Jens当我用谷歌搜索为什么运行时多态性涉及停止问题时,我得到了这个SO页面。您能否提供更多信息?
卡洛斯

8
@Carlos一般而言,这不是运行时多态性,而是一个问题,即使编译器不依赖于任何运行时信息,编译器是否可以静态找出哪个方法被调用。在这种情况下,它基本上会导致程序等效性或可到达性下降,由于停止问题,二者均无法确定。在给定的示例中,编译器应该没有问题删除动态调度。
詹斯(Jens)

Answers:


115

因为在一般情况下,这是不可能在编译时确定什么样它会在运行时。您的示例可以在编译时解决(请参阅@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,其中所述virtual成员函数show()首先通过调用bp):是否该指针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

现在,如果ifbp_points_to_base确定“ q.cpp”中的:bp指向Baseat的实例,A则“ q.cpp”指向的bp其他对象A。并且,如果确定“ q.cpp”bp中的对象不指向Baseat的实例A,则“ q.cpp”指向atbp的实例。这是一个矛盾。因此,我们最初的假设是不正确的。所以不能写成一般情况BaseAbp_points_to_base


12
也许阅读起来cin会比rand因为rand可以很容易地预先计算出更好的例子。
nwp

3
您应该随时间查看兰特,否则在编译时可以解决。没错,我正在做书。
g24l

72
伙计们,@ Paul_Evans在这里没有写加密货币。只需回答有关编译时预计算的问题!
loneboat

3
哇,证明有关C ++的东西!图灵肯定早于他的时间!= P
user541686

2
@Mehrdad他最肯定是!证明成立是因为C ++(以及任何可以用C ++编写的C ++编译器)是图灵完备的:P
Paul Evans

81

当已知对象的静态类型时,编译器通常会将这些调用虚拟化。将您的代码按原样粘贴到Compiler Explorer中将产生以下程序集:

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"在于可执行文件中。动态分配不仅得到了优化,整个基类也得到了优化。


如果您只想包括装配体的相关零件,那么前9行是重要的。
edmz 2015年

30

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

是什么让您认为它呢?

您有一个普遍的假设:仅仅因为该语言将这种情况标识为使用运行时多态性,并不意味着将实现保留在运行时进行调度。C ++标准具有所谓的“假设”规则:C ++标准规则的可观察效果是针对抽象机描述的,实现可以自由实现其所希望的可观察效果。


实际上,非虚拟化是用来指代编译器优化的通用词,旨在解决在编译时对虚拟方法的调用。

目标并不是要减少几乎不明显的虚拟呼叫开销(如果分支预测工作正常),而是要删除黑匣子。在优化方面,最好的收益在于内联调用:这打开了恒定的传播并进行了大量优化,并且内联只能在编译时知道要调用的函数体时才能实现(因为它涉及删除该调用并由功能主体替换它)。

一些虚拟化机会:

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

但是,有关最新技术,您将需要阅读HonzaHubič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个部分的帖子:


大多数虚拟函数调用是否都大致相当于链式if语句(尽管更多的是switch / jumptable-type结构)?还是仅仅为了提供某些可能性的内联定义?
2015年

@JAB:我不太了解“ if”与“ switch”的确切硬件成本(因为虚拟调用与Switch类似,因为存在多个目标)。正如您所指出的,尽管真正的好处是static_cast<A&>(b).call();可以内联该语句(及其兄弟姐妹),这反过来又带来了各种优化。另外,请注意,我提到计算获得“ A”,“ B”或“ C”的概率的原因是:(1)只有那些超出某个概率阈值的派生类才会这样显示(以避免膨胀)和(2 ),可以按照从最高概率到最坏概率的顺序对它们进行排序。
Matthieu M.

间接分支比条件分支对高度流水线架构的影响更大,这是因为分支预测难以实现,因此,根据目标架构所支持的编译器/优化级别/功能,某些switch语句的确会编译为链接的ifs(或某些条件+跳转表的组合)。另一方面,如果条件合适,编译器也可以将一系列链接的ifs转换为简单的间接分支。
JAB 2015年

@JAB:谢谢小费!
Matthieu M.

这是很好的知道,G ++可能很快赶上20世纪80年代的Smalltalk技术:-D
约尔格W¯¯米塔格

13

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

首先,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虚拟化实现的有趣博客文章


12

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

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


1

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

问题在于一般情况下,当您实际构建一个复杂的情况时,除了与库相关的情况之外,还有分析后编译多个编译单元的复杂性,这将需要保留同一代码的多个版本,这会使AST崩溃一代,真正的问题归结为可判定性和停顿问题。

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

停止的问题是决定一个程序设定的输入将停止(我们说的程序输入对暂停)。众所周知,没有通用算法,例如编译器,可以解决所有可能的程序输入对。

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

为此,编译器将需要一种算法A,该算法A决定给定程序P1和程序P2,其中P2进行虚拟调用,然后程序P3 {while({P1,I}!= {P2,I}}}暂停任何输入I。

因此,能够弄清楚所有可能的去虚拟化的编译器应该能够确定所有可能的P3和I上的任何对(P3,I);这对于所有对象都是不确定的,因为A不存在。但是,可以针对某些具体情况做出决定。

这就是为什么在您的情况下可以取消呼叫虚拟化的原因,但无论如何都不能。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.