一、实例代码
看了下面的例子,可能问题已经非常清晰了,但是这个例子就是为了让问题看起来清晰而故意这么写的,如果是在一个大型的项目中,特别是使用了大量的第三方库,这个问题并不是大家现在见到的这么显而易见的。下面是复现问题的demo:
tsecer@harry: cat callpure.cpp
#include <stdio.h>
struct Base
{
void operator delete(void *pMem, size_t nSize)
{
return ;
}
virtual int pure() = 0;
virtual int callme()
{
printf("call me in base
");
}
virtual ~Base()
{
printf("destructing self
");
}
};
struct Derive: public Base
{
~Derive() {}
virtual int pure()
{
printf("derive pure
");
}
virtual int callme()
{
printf("call me in derive
");
}
};
int main()
{
Derive b, *pb = &b;
pb->pure();
pb->callme();
delete pb;
pb->callme();
pb->pure();
}
tsecer@harry: g++ callpure.cpp -g
tsecer@harry: ./a.out
derive pure
call me in derive
destructing self
call me in base
pure virtual method called
terminate called without an active exception
Aborted (core dumped)
tsecer@harry:
这里的析构函数什么都没有做,但是确实必不可少的,否则delete的时候会触发C库内存管理的异常。当然在实际环境中没有人会犯这么明显的错误,这里只是提取了复现问题的必要条件。
二、谁提示了"pure virtual method called"
这个最有可能的是C库,然后就是C++的std库,搜索了一下gcc4.10的代码,可以确定这个提示是在gcc的支持库中定义的,具体的代码布局情况是这样的:
gcc-4.1.0libstdc++-v3libsupc++pure.cc
extern "C" void
__cxxabiv1::__cxa_pure_virtual (void)
{
writestr ("pure virtual method called
");
std::terminate ();
}
gcc-4.1.0gcccpdecl.c
abort_fndecl
= build_library_fn_ptr ("__cxa_pure_virtual", void_ftype);
gcc-4.1.0gcccpclass.c
static tree
build_vtbl_initializer (tree binfo,
tree orig_binfo,
tree t,
tree rtti_binfo,
int* non_fn_entries_p)
……
/* You can't call an abstract virtual function; it's abstract.
So, we replace these functions with __pure_virtual. */
if (DECL_PURE_VIRTUAL_P (fn_original))
{
fn = abort_fndecl;
if (abort_fndecl_addr == NULL)
abort_fndecl_addr = build1 (ADDR_EXPR, vfunc_ptr_type_node, fn);
init = abort_fndecl_addr;
}
else
{
if (!integer_zerop (delta) || vcall_index)
{
fn = make_thunk (fn, /*this_adjusting=*/1, delta, vcall_index);
if (!DECL_NAME (fn))
finish_thunk (fn);
}
/* Take the address of the function, considering it to be of an
appropriate generic type. */
init = build1 (ADDR_EXPR, vfunc_ptr_type_node, fn);
}
也就是说,gcc在实现的过程中,对于纯虚函数的函数指针(实现)并不是赋值为0(或者其他魔术字),而是填充了一个实实在在的函数,函数的名字就是__cxxabiv1::__cxa_pure_virtual,所以当遇到这种异常情况的时候,程序并不是出现段错误,而是“优雅”的打印了足够多的信息,让我们能够有一个直观的认识。
三、谁提示的"terminate called without an active exception"
gcc-4.1.0libstdc++-v3libsupc++vterminate.cc
// A replacement for the standard terminate_handler which prints
// more information about the terminating exception (if any) on
// stderr.
void __verbose_terminate_handler()
{
static bool terminating;
if (terminating)
{
fputs("terminate called recursively
", stderr);
abort ();
}
terminating = true;
// Make sure there was an exception; terminate is also called for an
// attempt to rethrow when there is no suitable exception.
type_info *t = __cxa_current_exception_type();
if (t)
{
// Note that "name" is the mangled name.
……
catch (...) { }
}
else
fputs("terminate called without an active exception
", stderr);
abort();
}
四、谁提示的"Aborted (core dumped)"
这个我记得之前看过这个问题,这个问题是在bash中提示的,再具体的说就是通过waitpid获得进程退出的状态,然后转换为更加详细的信息打印在终端上。手头上没有bash的源代码,略过。
五、C++相关的辅助知识
《ANSI C++手册》中关于析构函数有这样的一个说明,也就是说对象的析构函数的构造顺序需要和构造函数的执行顺序相反。对于C++有构造函数的对象来说,它们的执行顺序就是声明顺序(这一点在手册中没有找到明确的说明),因为说构造函数是特殊的函数,编译器不能假设说函数调用的顺序可以调换(除非说inline函数?)。
12 Special member functions
12.4 Destructors
5 Bases and members are destroyed in reverse order of their construction (see 12.6.2). Destructors for elements of an array are called in reverse order of their construction (see 12.6).
具体到这个问题,我们调用一个类的delete函数,C++的机制是先执行这个对象的析构函数,然后将再将这个对象使用的内存地址和大小作为参数传递给这个对象定义的delete函数,也就是说这个对象的析构函数是由编译器不可见的形式调用的。
六、终于要切入主题了
再具体说派生类的析构函数,为了说明方便,我们使用例子中的Base和Derive两个类做说明,在Base执行构造函数的时候,它需要初始化它的虚函数表指针,然后再Derive执行构造函数的时候,同样会初始化自己的虚函数指针。这里要注意的是,这两个虚函数指针在对象中的位置是相同的,所以派生类的初始化将会覆盖基类初始化的虚函数表指针。迄今为止应该还没有什么复杂或者不然理解的地方。
在前面已经说过,C++规定了构造函数的执行顺序是先执行基类的构造函数,然后执行派生类的构造函数;反过来,析构的时候就要先执行派生类的析构函数,然后执行基类的析构函数。那么,终于,“问题来了”:基类析构的时候它的虚函数表已经被派生类覆盖成了派生类的虚函数指针,这个时候如果基类的析构函数执行虚函数的时候就可能会访问到派生类的虚函数(而不是自己的虚函数)。
七、析构函数执行了什么
由于二进制不太容易分析,所以这里还是看gcc生成的汇编代码
tsecer@harry: gcc -S callpure.cpp -g
tsecer@harry: cat callpure.s | c++filt > c++filted.s
然后看文件中的汇编代码
Base的析构函数,其中关键的代码列在下面
96 movl vtable for Base+16, %edx
97 movq -8(%rbp), %rax
98 movq %rdx, (%rax)
96行开始获得虚函数表,从后面 433 vtable for Derive:可以看到,开始的一个8字节为0,接下来8字节为typeinfo指针,所以vtable for Base+16是虚函数表。vtable for Base开始的0我记得是和多重继承有关的,由于这里关注点不在这里,所以略过。这里的关键是在Base的析构函数是会重新恢复虚函数表的,这一点和构造函数相同,而这一点通常是大家所不关注或者说容易忽略的。由于这里的代码比较多,所以我就不列出Base构造函数的汇编代码了,有兴趣的同学可以自己看下。
85 Base::~Base():
86 .LFB5:
87 .loc 1 14 0
88 pushq %rbp
89 .LCFI7:
90 movq %rsp, %rbp
91 .LCFI8:
92 subq $16, %rsp
93 .LCFI9:
94 movq %rdi, -8(%rbp)
95 .loc 1 15 0
96 movl vtable for Base+16, %edx
97 movq -8(%rbp), %rax
98 movq %rdx, (%rax)
99 .loc 1 16 0
100 movl $.LC0, %edi
101 call puts
102 .loc 1 17 0
103 movl $0, %eax
104 testb %al, %al
105 je .L11
106 movq -8(%rbp), %rdi
107 movl $8, %esi
108 call Base::operator delete(void*, unsigned long)
109 .L11:
110 leave
111 ret
……
427 .size Derive::pure(), .-Derive::pure()
428 .weak vtable for Derive
429 .section .rodata._ZTV6Derive,"aG",@progbits,vtable for Derive,comdat
430 .align 32
431 .type vtable for Derive, @object
432 .size vtable for Derive, 48
433 vtable for Derive:
434 .quad 0
435 .quad typeinfo for Derive
436 .quad Derive::pure()
437 .quad Derive::callme()
438 .quad Derive::~Derive()
439 .quad Derive::~Derive()
440 .weak typeinfo for Derive
顺便列下包含虚函数的Base类的虚函数表的构成,验证前面对于gcc代码的分析
474 vtable for Base:
475 .quad 0
476 .quad typeinfo for Base
477 .quad __cxa_pure_virtual
478 .quad Base::callme()
479 .quad Base::~Base()
480 .quad Base::~Base()
八、再总结下
原理已经讲清楚了,为了避免有些同学最后还是有点晕,这里再明确说明下:关键的问题在于执行析构函数的时候,编译器生成的代码和构造函数一样,会重新初始化对象的虚函数表。在开始的例子中,可以看到,同样的指针,两次调用callme的时候执行的代码是不一样的,
derive pure
call me in derive
destructing self
call me in base
可以看到析构之后同样的指针调用同样的函数,已经调用的是基类的函数了,这从另一个侧面验证了析构函数重新初始化虚函数表的动作。
本文里构造构造的例子看起来比较极端,甚至看起来有些荒诞,但是在大型的C++开发过程中,的确是可能出现这种问题的。例如某函数foo会假设这个传入的对象必定是new出来的(而不是堆栈中的),所以殷勤的执行了析构函数,当你事实上给foo传递一个堆栈变量的时候,就会导致堆栈变量在执行析构函数之后只能被使用一次,因为析构函数中已经修改了它的虚函数表。