每个虚类对象都有一个指向虚函数表的指针吗?
或者只有具有虚函数的基类对象才有?
vtable 存储在哪里?代码段还是进程的数据段?
每个虚类对象都有一个指向虚函数表的指针吗?
或者只有具有虚函数的基类对象才有?
vtable 存储在哪里?代码段还是进程的数据段?
所有具有虚方法的类都会拥有一个单独的vtable(虚函数表),这个vtable会被该类的所有对象共享。
每个对象实例都会有一个指向该vtable的指针(这就是如何找到vtable),通常称为vptr。编译器会隐式生成代码,在构造函数中初始化vptr。
注意,C++语言没有强制要求以上内容——如果愿意,实现可以使用其他方式处理虚函数调用。然而,这种实现方式是我熟悉的每个编译器都在使用的。Stan Lippman的书《深度探索C++对象模型》很好地描述了这个过程。
正如其他人所说,C ++标准并不强制要求使用虚拟方法表,但允许使用。 我已经使用gcc和这段代码以及最简单的可能情况进行了测试:
class Base {
public:
virtual void bark() { }
int dont_do_ebo;
};
class Derived1 : public Base {
public:
virtual void bark() { }
int dont_do_ebo;
};
class Derived2 : public Base {
public:
virtual void smile() { }
int dont_do_ebo;
};
void use(Base* );
int main() {
Base * b = new Derived1;
use(b);
Base * b1 = new Derived2;
use(b1);
}
添加数据成员是为了防止编译器将基类大小指定为零(这被称为空基类优化)。这是GCC选择的布局:(使用-fdump-class-hierarchy打印)
Vtable for Base
Base::_ZTV4Base: 3u entries
0 (int (*)(...))0
4 (int (*)(...))(& _ZTI4Base)
8 Base::bark
Class Base
size=8 align=4
base size=8 base align=4
Base (0xb7b578e8) 0
vptr=((& Base::_ZTV4Base) + 8u)
Vtable for Derived1
Derived1::_ZTV8Derived1: 3u entries
0 (int (*)(...))0
4 (int (*)(...))(& _ZTI8Derived1)
8 Derived1::bark
Class Derived1
size=12 align=4
base size=12 base align=4
Derived1 (0xb7ad6400) 0
vptr=((& Derived1::_ZTV8Derived1) + 8u)
Base (0xb7b57ac8) 0
primary-for Derived1 (0xb7ad6400)
Vtable for Derived2
Derived2::_ZTV8Derived2: 4u entries
0 (int (*)(...))0
4 (int (*)(...))(& _ZTI8Derived2)
8 Base::bark
12 Derived2::smile
Class Derived2
size=12 align=4
base size=12 base align=4
Derived2 (0xb7ad64c0) 0
vptr=((& Derived2::_ZTV8Derived2) + 8u)
Base (0xb7b57c30) 0
primary-for Derived2 (0xb7ad64c0)
每个类都有一个虚函数表,你可以看到。前两个条目是特殊的。第二个指向类的RTTI数据。第一个 - 我知道它但忘记了。在更复杂的情况下它还是有些用处的。好吧,正如布局所示,如果你有一个Derived1类的对象,那么vptr(虚函数表指针)当然会指向Derived1类的虚函数表,其中仅有一个条目指向Derived1版本的函数bark。Derived2的vptr指向Derived2的虚函数表,其中有两个条目。另一个是它添加的新方法smile的条目。它重复了Base :: bark的条目,当然会指向Base版本的函数,因为它是最派生的版本。
我还使用-fdump-tree-optimized对GCC进行了一些优化(构造函数内联等)之后生成的树,输出使用GCC的中间端语言GIMPL,这是与前端无关的,并缩进为类似C的块结构:
;; Function virtual void Base::bark() (_ZN4Base4barkEv)
virtual void Base::bark() (this)
{
<bb 2>:
return;
}
;; Function virtual void Derived1::bark() (_ZN8Derived14barkEv)
virtual void Derived1::bark() (this)
{
<bb 2>:
return;
}
;; Function virtual void Derived2::smile() (_ZN8Derived25smileEv)
virtual void Derived2::smile() (this)
{
<bb 2>:
return;
}
;; Function int main() (main)
int main() ()
{
void * D.1757;
struct Derived2 * D.1734;
void * D.1756;
struct Derived1 * D.1693;
<bb 2>:
D.1756 = operator new (12);
D.1693 = (struct Derived1 *) D.1756;
D.1693->D.1671._vptr.Base = &_ZTV8Derived1[2];
use (&D.1693->D.1671);
D.1757 = operator new (12);
D.1734 = (struct Derived2 *) D.1757;
D.1734->D.1682._vptr.Base = &_ZTV8Derived2[2];
use (&D.1734->D.1682);
return 0;
}
正如我们可以清晰地看到的那样,它只是设置一个指针 - vptr - 它将指向我们在创建对象时看到的适当的vtable。我还使用c++filt
工具对其中的名称进行了解码,并转储了Derived1的创建和调用use的汇编代码($4是第一个参数寄存器,$2是返回值寄存器,$0始终为零寄存器):)
# 1st arg: 12byte
add $4, $0, 12
# allocate 12byte
jal operator new(unsigned long)
# get ptr to first function in the vtable of Derived1
add $3, $0, vtable for Derived1+8
# store that pointer at offset 0x0 of the object (vptr)
stw $3, $2, 0
# 1st arg is the address of the object
add $4, $0, $2
jal use(Base*)
如果我们想调用 bark
会发生什么?:
void doit(Base* b) {
b->bark();
}
GIMPL代码:
;; Function void doit(Base*) (_Z4doitP4Base)
void doit(Base*) (b)
{
<bb 2>:
OBJ_TYPE_REF(*b->_vptr.Base;b->0) (b) [tail call];
return;
}
OBJ_TYPE_REF
是GIMPL结构体,它被漂亮地打印出来(在gcc SVN源代码的gcc/tree.def
中有文档记录)。
OBJ_TYPE_REF(<first arg>; <second arg> -> <third arg>)
这意味着:在对象 b
上使用表达式 *b->_vptr.Base
,并存储前端 (c++) 特定值 0
(它是 vtable 的索引)。最后,将 b
作为“this”参数传递。如果我们调用出现在 vtable 中第二个索引处的函数(注意,我们不知道是哪种类型的 vtable!),则 GIMPL 将如下所示:
OBJ_TYPE_REF(*(b->_vptr.Base + 4);b->1) (b) [tail call];
当然,这里是汇编代码(堆栈帧截断):
# load vptr into register $2
# (remember $4 is the address of the object,
# doit's first arg)
ldw $2, $4, 0
# load whatever is stored there into register $2
ldw $2, $2, 0
# jump to that address. note that "this" is passed by $4
jalr $2
请记住,vptr 恰好指向第一个函数。 (在该条目之前,存储了 RTTI 插槽)。 因此,无论出现什么在该插槽中被称为。 它也标记调用为尾调用,因为它发生在我们的 doit
函数的最后一个语句。
在家尝试一下:
#include <iostream>
struct non_virtual {};
struct has_virtual { virtual void nop() {} };
struct has_virtual_d : public has_virtual { virtual void nop() {} };
int main(int argc, char* argv[])
{
std::cout << sizeof non_virtual << "\n"
<< sizeof has_virtual << "\n"
<< sizeof has_virtual_d << "\n";
}
Vtable(虚函数表)是每个类的实例,即如果我有一个类的10个对象,其中有一个虚方法,则只有一个vtable,它在所有10个对象之间共享。
在这种情况下,所有10个对象指向相同的vtable。
class A { virtual void f(); int a; };
class B: public A { virtual void f(); virtual void g(); int b; };
class C: public B { virtual void f(); virtual void g(); virtual void h(); int c; };
class D: public A { virtual void f(); int d; };
class E: public B { virtual void f(); int e; };
A's vtable;
int a;
一个B类的实例会是这样:
A's vtable;
int a;
B's vtable;
int b;
您可以从这个布局中生成正确的虚拟分派代码。
您还可以通过组合具有相同布局或其中一个是另一个子集的vtables的vtable指针来优化布局。因此,在上面的示例中,您也可以将B布局为:
B's vtable;
int a;
int b;
由于B的虚函数表是A的超集。B的虚函数表中有A::f和B::g的条目,而A的虚函数表中有A::f的条目。
为了完整起见,这是目前我们所见过的所有虚函数表的布局:
A's vtable: A::f
B's vtable: A::f, B::g
C's vtable: A::f, B::g, C::h
D's vtable: A::f
E's vtable: A::f, B::g
实际的条目将会是:
A's vtable: A::f
B's vtable: B::f, B::g
C's vtable: C::f, C::g, C::h
D's vtable: D::f
E's vtable: E::f, B::g
对于多重继承,您需要进行相同的分析:
class A { virtual void f(); int a; };
class B { virtual void g(); int b; };
class C: public A, public B { virtual void f(); virtual void g(); int c; };
最终的布局如下:
A:
A's vtable;
int a;
B:
B's vtable;
int b;
C:
C's A vtable;
int a;
C's B vtable;
int b;
int c;
所有虚拟类通常都有一个虚函数表,但这并不是C++标准所要求的,存储方式取决于编译器。
不一定
几乎每个具有虚函数的对象都将有一个虚表指针。并不需要为每个具有虚函数的类派生出来的对象都有一个虚表指针。
新的编译器可以充分分析代码,在某些情况下可能能够消除虚表。
例如,在一个简单的情况下:如果您只有一个抽象基类的具体实现,编译器知道它可以将虚调用更改为常规函数调用,因为无论何时调用虚函数,它都将始终解析为完全相同的函数。
另外,如果只有几个不同的具体函数,编译器可以有效地更改调用站点,以便使用“if”选择正确的具体函数进行调用。
因此,在这种情况下,并不需要虚表,对象可能最终没有虚表。