“纯虚函数调用”崩溃是从哪里来的?

128

我有时会在我的电脑上注意到崩溃并出现错误信息:“纯虚函数调用”。

当一个抽象类无法创建对象时,这些程序是如何编译的呢?


不确定为什么这个问题一直被标记为仅限C++。这是R6025,一个C运行时错误。除了C++之外,许多Windows虚函数表都是用C编写的,并且必须手动设置,无论您使用的是C、C++还是Java(例如,请参考DirectX虚函数表)。如果您的程序忘记填充一个函数并将虚函数表发送回操作系统,可能会出现此错误。 - c z
8个回答

125
如果您尝试在构造函数或析构函数中调用虚函数,则可能会出现问题。因为您无法在构造函数或析构函数中调用虚函数(派生类对象尚未构造或已被销毁),它会调用基类版本,而在纯虚函数的情况下,基类版本不存在。
class Base
{
public:
    Base() { reallyDoIt(); }
    void reallyDoIt() { doIt(); } // DON'T DO THIS
    virtual void doIt() = 0;
};

class Derived : public Base
{
    void doIt() {}
};

int main(void)
{
    Derived d;  // This will cause "pure virtual function call" error
}

此外,还可以参考雷蒙德·陈关于该主题的2 文章


12
通常情况下,编译器为什么不能捕捉到这个错误呢? - Thomas
27
通常情况下无法捕获虚函数的调用,因为构造函数中的流程可能会走向任何地方,任何地方都可以调用纯虚函数。这就是停机问题的基础知识。 - shoosh
10
回答略有误:虚函数可能仍然被定义,详见维基百科。正确表达应为:可能不存在。 - MSalters
8
我认为这个例子过于简单:构造函数中的doIt()调用很容易被静态地解析并分派到Base::doIt(),从而导致链接错误。我们真正需要的是在动态分派期间动态类型是抽象基础类型的情况。 - Kerrek SB
2
如果您添加额外的间接级别,可以使用MSVC触发此操作:让Base::Base调用非虚拟的f(),然后再调用(纯)虚拟的doIt方法。 - Frerich Raabe
显示剩余13条评论

70

除了在具有纯虚函数的对象的构造函数或析构函数中调用虚函数的标准情况外,如果在对象被销毁后调用虚函数,则也可能发生纯虚函数调用(至少在MSVC上是如此)。显然这是一件非常糟糕的事情,但如果你使用抽象类作为接口并且搞砸了,那么你可能会遇到这种情况。如果你正在使用引用计数的接口,并且有一个引用计数错误,或者在多线程程序中存在一个对象使用/对象销毁竞争条件,那么这种情况可能更容易发生...这些纯调用的问题在于往往不容易弄清楚发生了什么,因为在构造函数和析构函数中进行虚拟调用的检查通常会通过。

为了帮助调试这些问题,你可以在各个版本的MSVC中替换运行时库的purecall处理程序。你可以提供一个具有此签名的自定义函数:

int __cdecl _purecall(void)
在链接运行时库之前,链接对象文件。这样一来,您就可以控制检测到purecall时会发生什么了。一旦您掌握了控制权,就可以做比标准处理程序更有用的事情了。我有一个处理程序,可以提供纯虚函数调用发生位置的堆栈跟踪;请参见此处:http://www.lenholgate.com/blog/2006/01/purecall.html 以获取更多详细信息。
(注意,在某些 MSVC 版本中,您还可以调用 _set_purecall_handler() 来安装您的处理程序)。

2
谢谢你提供的指针,关于在删除实例时出现_purecall()调用,我之前并不知道,但是通过一些测试代码证明了它。在WinDbg中查看后期转储时,我认为我正在处理一个竞争条件,即另一个线程在完全构造派生对象之前尝试使用它,但这为问题带来了新的视角,并且似乎更符合证据。 - Dave Ruske
1
我还要补充一点:如果基类使用了__declspec(novtable)优化(仅限于Microsoft),则通常在调用已删除实例的方法时发生的_purecall()调用将不会发生。有了这个,完全有可能在对象被删除后调用重写的虚拟方法,这可能会掩盖问题,直到以其他形式咬你。_purecall()陷阱是你的朋友! - Dave Ruske
很有用的信息,Dave。最近我遇到了几种情况,我本以为应该得到纯调用(purecalls),但实际上没有。也许我是因为这种优化而受到了影响。 - Len Holgate
2
@LenHolgate:非常有价值的答案。这正是我们的问题所在(由竞态条件引起的错误引用计数)。非常感谢您指出了正确的方向(我们原本怀疑是虚函数表损坏,一直在疯狂地寻找罪魁祸首的代码)。 - BlueStrat

10

我遇到了这样一种情况,纯虚函数由于已销毁的对象而被调用,Len Holgate 已经有了一个非常好的回答,我想通过一个示例来进行说明:

  1. 创建了一个Derived对象,并将指针(作为Base类)保存在某个地方
  2. 删除派生对象,但某种方式下仍引用了指针
  3. 指向已删除的Derived对象的指针被调用

Derived类的析构函数将vptr重置为Base类的vtable,其中包含纯虚函数。因此,当我们调用虚函数时,实际上会调用纯虚函数。

这可能是因为明显的代码错误或多线程环境中复杂的竞态条件导致的。

这里是一个简单的示例(使用g++编译时关闭优化 - 简单的程序可以轻松地被优化掉):

 #include <iostream>
 using namespace std;

 char pool[256];

 struct Base
 {
     virtual void foo() = 0;
     virtual ~Base(){};
 };

 struct Derived: public Base
 {
     virtual void foo() override { cout <<"Derived::foo()" << endl;}
 };

 int main()
 {
     auto* pd = new (pool) Derived();
     Base* pb = pd;
     pd->~Derived();
     pb->foo();
 }

堆栈跟踪看起来像:

#0  0x00007ffff7499428 in __GI_raise (sig=sig@entry=6) at ../sysdeps/unix/sysv/linux/raise.c:54
#1  0x00007ffff749b02a in __GI_abort () at abort.c:89
#2  0x00007ffff7ad78f7 in ?? () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#3  0x00007ffff7adda46 in ?? () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#4  0x00007ffff7adda81 in std::terminate() () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#5  0x00007ffff7ade84f in __cxa_pure_virtual () from /usr/lib/x86_64-linux-gnu/libstdc++.so.6
#6  0x0000000000400f82 in main () at purev.C:22

要点:

如果对象被完全删除,也就是析构函数被调用并且回收了内存,那么我们可能会因为内存已经归还给操作系统,而在程序中无法访问它而导致分段错误。因此,这种“纯虚函数调用”情况通常发生在对象在内存池上分配时,当对象被删除时,底层内存实际上并没有被操作系统回收,而是仍然可以被进程访问。


关于结束部分:可能存在真实世界的例子,其中运行时库为了性能原因而将内存管理与操作系统解耦,而没有使用 placement new。但无论如何,感谢演示。 - Wolf

7
通常情况下,当您通过悬空指针调用虚函数时,很可能实例已经被销毁了。还有一些更“有创意”的原因:也许您已经切掉了实现虚函数的对象的一部分。但通常情况下,是实例已经被销毁了。

1
如果您使用Borland/CodeGear/Embarcadero/Idera C++ Builder,您可以直接实现。
extern "C" void _RTLENTRY _pure_error_()
{
    //_ErrorExit("Pure virtual function called");
    throw Exception("Pure virtual function called");
}

在调试时,在代码中设置断点并查看IDE中的调用堆栈,否则如果您有适当的工具,则在异常处理程序(或该函数)中记录调用堆栈。我个人使用MadExcept来实现这一点。
附注:原始函数调用位于[C++ Builder]\source\cpprtl\Source\misc\pureerr.cpp。

0

我使用VS2010,每当我尝试从公共方法直接调用析构函数时,在运行时会出现“纯虚函数调用”错误。

template <typename T>
class Foo {
public:
  Foo<T>() {};
  ~Foo<T>() {};

public:
  void SomeMethod1() { this->~Foo(); }; /* ERROR */
};

所以我将~Foo()方法中的内容移动到单独的私有方法中,然后它就像魔术般地有效了。

template <typename T>
class Foo {
public:
  Foo<T>() {};
  ~Foo<T>() {};

public:
  void _MethodThatDestructs() {};
  void SomeMethod1() { this->_MethodThatDestructs(); }; /* OK */
};

0

我猜测为了某些内部原因(可能需要某种运行时类型信息),抽象类会创建一个vtbl,然后出现了问题,真正的对象得到了它。这是一个错误。仅此就足以说明发生了不可能发生的事情。

纯属猜测

编辑:看起来在这个案例中我是错的。另一方面,如果我没记错,有些语言确实允许在构造函数析构函数中调用vtbl。


如果你的意思是编译器出了问题,那么这并不是编译器的bug。 - Thomas
你的怀疑是正确的 - C# 和 Java 允许这样做。在这些语言中,正在构建的对象具有其最终类型。在 C++ 中,对象在构建过程中会改变类型,这就是为什么和何时可以拥有抽象类型的对象。 - MSalters
所有的抽象类和由它们派生出来的实际对象都需要一个虚函数表(vtbl),列出应该在其中调用哪些虚函数。在C++中,一个对象负责创建自己的成员,包括虚函数表。构造函数从基类到派生类被调用,而析构函数从派生类到基类被调用,因此在抽象基类中虚函数表尚不可用。 - fuzzyTew

-2

这里有一个巧妙的方法可以实现它。今天我基本上就遇到了这种情况。

class A
{
  A *pThis;
  public:
  A()
   : pThis(this)
  {
  }

  void callFoo()
  {
    pThis->foo(); // call through the pThis ptr which was initialized in the constructor
  }

  virtual void foo() = 0;
};

class B : public A
{
public:
  virtual void foo()
  {
  }
};

B b();
b.callFoo();

2
至少在我的vc2008上无法重现,当vptr在A的构造函数中首次初始化时,它确实指向A的vtable,但是当B完全初始化后,vptr会更改为指向B的vtable,这是可以接受的。 - Baiyan Huang
1
无法在VS2010/12中重现。 - makc
今天我遇到了这个问题,显然不是真的,因为它是错误的:只有在构造函数(或析构函数)中调用callFoo()时才会调用纯虚函数,因为此时对象仍处于A阶段(或已经处于A阶段)。这里有一个运行版本,其中B b();的语法错误已被修复-括号使其成为函数声明,而你需要的是一个对象。 - Wolf

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接