0%

C++对象模型(四)继承与多态

当C++刚刚问世时,它的两大卖点是:

  • 与C兼容。
  • 面向对象。

而说到面向对象时,就绕不开多态。说到多态,就绕不开继承。

所谓多态,即同样的代码,不同的行为。根据这种行为差异的发生时机,我们把多态分成了编译时多态和运行时多态。继承能实现的就是运行时多态。

本文想讨论的是C++为继承和运行时多态准备了什么样的对象模型

  • 注1:本文中的“多态”特指“运行时多态”。
  • 注2:本文不讨论虚继承及其背后的对象模型。
  • 注3:本文主要内容来自《深入探索C++对象模型》

对象模型

简单对象模型

第一种模型十分简单,每个对象就是一个表格,其中每个slot按成员的声明顺序指向对应的成员,包括成员函数与成员变量。

即对于下面的类:

1
2
3
4
5
6
7
8
struct Base {
Base();
void F();
int64_t x;
virtual int G() const;
int32_t y;
virtual void H();
};

它的一个对象为:

one slot per member

这个模型是为了尽量降低C++编译器的设计复杂度,这样我们不需要知道每个成员的大小,只要知道成员的数量,就能计算出对象本身需要占的空间了。每个成员都有着固定的偏移量,因此如果要实现多态,只要改变这个slot对应的函数地址即可。实际我们可以看到,这个模型下每个函数都可以是虚函数,即派生类可以改写基类的任何函数。

它的缺点也很明显:不与C的struct兼容;访问成员需要至少一次间接寻址,开销大。

没有哪个编译器真的采用了这个模型,但它的思想,即每个成员对应一个slot,被吸收到了“指向成员的指针”中。

双表格对象模型

第二种模型使用了两个表格,一个对应成员函数,一个对应成员变量,而对象内则只有指向这两个表格的指针。

对于上面的Base类,新的对象模型为:

double_table

这个模型的好处是令所有对象都有着相同的大小和表现形式。它也是一种停留在理论中的模型,但“函数表格”这一思想却启发了后面的虚表模型。

虚表对象模型

Stroustrup在设计C++时的一个理念就是,让用户不使用的特性零开销。C++的class就体现了这一点。

从演化路径来看,从C的struct到C++的class,大致过程为:

纯数据的结构体 -> 数据+操作的抽象数据类型 -> 能表现多态的类型。

参考之前的文章,我们可以看到:

  • 纯数据的结构体,对应C++中的标准布局类,其相比C的struct没有任何额外开销。
  • 抽象数据类型(ADT),对应C++中的无虚函数的class,其成员变量均有着固定的偏移,与纯结构体相比无额外开销;其成员函数不占用对象本身体积,且调用一个成员函数与调用一个全局函数相比也无额外开销。

现在到了最后一种,当class需要能支持多态,我们该如何设计,来保证上面这两种使用方式不受影响?

Stroustrup选择了一种折衷的方案,即:

  • 每个有虚函数的类型对应一个表格,称为虚表,其中每个slot对应一个虚函数的实际地址。另外虚表的第0个slot指向了这个类型的type_info,用于RTTI。
  • 有虚函数的对象内会增加一个指向虚表的指针,这样在运行时可以通过虚表跳转来实现多态。

对于上面的Base类,虚表对象模型为:

vtpr

当我们不向class中增加虚函数时,编译器不会生成虚表,也不会向对象内增加一个虚表指针,一切都和原来一样。当我们加入虚函数,编译器才会为了这种运行时特性而做上述工作。

之所以不把虚表直接放到对象中,是为了避免对象体积太大,因此我们宁愿多一次虚表指针的跳转。

这个模型下每个成员变量就在对象中,因此在定义类时我们要能看到每个成员变量的布局,知道它的大小,因此无论哪个成员变量发生了变化,我们都要重新编译、链接。这是为了运行效率而付出的代价。

标准中并没有规定编译器一定要这么实现,但目前几乎所有编译器都采用了这种虚表模型,且几乎都选择了把虚表指针放到对象头部(而CFront则放到尾部)。

虚表

以下内容参考GCC的实现,部分脑补,总之理论上是个可以工作的模型。

每个有虚函数的类都对应着一个单独的虚表,而这个类的所有对象中只有指向它的指针。

非派生类型的虚表长度为N+2,其中N为虚函数的个数,按虚函数声明顺序对应,另外的2则分别为第0个位置的type_info*,和最后一个位置的NULL

派生类型的虚表长度为S0+S1+…+N+1,其中S0、S1等分别为其第0个基类、第1个基类等的虚表长度,N为该派生类型自己增加的虚函数数量,1是最后一个位置的NULL(如果N不为0的话)。按上一条,S0、S1的长度是N0+2、N1+2,已经包含了type_info*NULL,因此派生类的虚表中有K个type_info*,且都指向派生类自己的type_info,这里K是其基类数量。

因此派生类的虚表取不同的偏移,就可以得到与其某个基类完全兼容的虚表,但其中每个slot指向的函数则可能是派生类自己的实现。

单继承下的对象模型

从前文中我们知道派生类对象中会有一个基类的子对象,而标准规定了这个基类子对象要“有其完整原样性”,即与一个独立的基类对象有着完全相同的性质。

编译器要首先保证这一点,再去安排派生类对象自己的成员。标准未规定基类子对象在派生类对象中该处于什么位置,但几乎所有编译器都将基类子对象放到了派生类对象的头部。

无虚函数

当基类与派生类均无虚函数时,也就意味着派生类对象中不需要有虚表。此时的派生类对象的内存布局见为struct添加一个无虚函数的非虚继承基类

这种继承有一个很不一样的地方:它没有产生多态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Base {
void Print() const {
cout << "Base" << endl;
}
};

struct Derived {
void Print() const {
cout << "Derived" << endl;
}
};

void Func(const Base& b) {
b.Print();
}

这个例子中,当我们在Func中调用b.Print()时,编译器知道Base::Print不是虚函数,也就意味着它不可能被任何派生类改写,因此编译器会直接将其绑定到Base::Print上,即Func的运行时行为在编译时已经确定了。

基类有虚函数

当基类有虚函数时,意味着派生类也有虚函数,即派生类对象与其中的基类子对象都需要有一个虚表指针,且要指向正确的虚表。

前面我们介绍虚表的时候提到,对派生类的虚表加上不同的偏移量,就能得到与其每个基类虚表完全兼容的虚表,其中第0个基类的偏移量就是0。因此单继承下,派生类对象中也不需要有多个虚表指针,只要头部放置一个虚表指针,就可以同时满足基类子对象与派生类对象的需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct Base {
int64_t x;
int32_t y;

virtual ~Base() {}
virtual void F() {}
};

struct Derived: public Base {
int32_t z;

~Derived() override {}
};

int main() {
Derived d;
Base& b = d;
printf("sizeof(Base):%d sizeof(Derived):%d &d:%x &b:%x\n", sizeof(Base), sizeof(Derived), &d, &b);
}
1
sizeof(Base):24 sizeof(Derived):24 &d:edcc4968 &b:edcc4968

此时Derived对象长成这样:

single

可以看到:

  • 基类子对象的偏移确实是0,说明它与派生类对象共享了虚表指针。
  • 基类子对象与派生类自己的成员之间没有加padding,这与标准布局差别很大,更紧凑了。实际上标准没有对非标准布局有任何明确规定,且对于有虚函数的类型,直接bitwise操作本身就是未定义行为,因此编译器就可以自由选择一种比较紧凑的布局,而不需要担心我们直接操作基类子对象时把派生类的成员变量给破坏了。

基类无虚函数,派生类有虚函数

如果基类没有虚函数,那么基类子对象就不需要有虚表指针;派生类有虚函数,那么派生类对象就需要有虚表指针。因此派生类对象内还是需要一个虚表指针。

GCC的实现是仍然把虚表指针放到派生类对象头部,而基类子对象在其后,此时基类子对象有一个指针的偏移。

1
2
3
4
5
6
7
8
9
10
struct Base {
int64_t x;
int32_t y;
};

struct Derived: public Base {
int32_t z;

virtual void F() {}
};
1
sizeof(Base):16 sizeof(Derived):32 &d:e1855960 &b:e1855968

此时Derived对象长成这样:
base_non_virtual

可以看到:

  • 基类子对象的偏移量为8。
  • 基类子对象后加了padding。此时基类是平凡类,是有可能被人直接以bitwise的方式操作,不加padding就会有危险。

多继承下的多对象模型

当派生类有多个基类时,每个基类自身可能有虚函数,可能没有。对于有虚函数的基类,派生类对象需要为其准备一个虚表指针。对于没有虚函数的基类,则不需要有虚表指针。

如果第0个基类是有虚函数的,那么派生类对象就可以与其共享虚表指针。因此GCC会将其第一个有虚函数的基类子对象放到派生类对象的头部,从而节省一个虚表指针。

而后面的基类则因为其虚表在派生类虚表中的偏移量不为0,无法共享虚表指针。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Base1 {
int64_t x;
int32_t y;

virtual void F() {}
};

struct Base2 {
int32_t z;
};

struct Derived: public Base1, public Base2 {
int32_t r;
};
1
sizeof(Base1):24 sizeof(Base2):4 sizeof(Derived):32 &d:e5fe3960 &b1:e5fe3960 &b2:e5fe3974

即使我们把public Base1, public Base2换成public Base2, public Base1,结果也没有任何变化。

此时Derived对象长成这样:

而当我们为Base2也添加一个虚函数:

1
2
3
4
5
6
7
8
9
struct Base2 {
int32_t z;
virtual void G() {}
};

struct Derived: public Base1, public Base2 {
int32_t r;
void G() override {}
};

结果为:

1
sizeof(Base1):24 sizeof(Base2):16 sizeof(Derived):40 &d:e2c5d958 &b1:e2c5d958 &b2:e2c5d970

此时Derived对象长成这样:

可以看到:

  • 每个基类子对象都有自己的虚表指针。其中第0个基类子对象的虚表指针与派生类对象本身是共享的。
  • 但两个虚表指针实际都指向派生类自己的虚表,只不过指向的位置不同。
  • 除了第0个基类子对象,其它基类子对象的偏移量都不是0。

指向成员的指针

C++中有一类指针比较特殊,它们是指向类型成员的指针,比如上例中的&Derived::x&Derived::G等,它们的类型分别是int64_t Base1::*void (Derived::*)()。这些指针是不能单独使用的,必须要通过一个对应类型的对象来解引用,例如:

1
2
3
4
5
6
7
8
Derived d;
d.x = 1;

auto px = &Derived::x;
d.*px = 2;

auto pf = &Derived::F;
int ret = d.*pf();

这里我们已经能感觉到它们的不一样了。

指向成员的指针,真的是指针吗?

是指针,但与普通的指针不一样:

  • 不能转换为void*intptr_t等类型。
  • 与普通指针的大小不一定相同,比如在我的环境(64位clang)下,&Derived::x是8字节,而&Derived::F则是16字节。
  • 其值不一定表示地址。

对于第三条,大致有以下规则:

  • 指向成员变量的指针,其值为该变量在对象内的偏移量,比如&Derived::x就是8,而&Base1::x则是0,这样我们能通过一个对象直接寻址到这个变量。
  • 指向非虚成员函数的指针,其大小仍是16字节(我的环境中),但其值真的是这个函数的入口地址,而不是偏移量。
  • 指向虚的成员函数的指针,其值是该函数在该类型的虚表中的偏移量。我们知道虚表的第0位不是虚函数,因此任何指向虚函数的合法指针都不可能是0,通过这一点我们也保证了,如果一个指向虚函数的成员指针为0,那么它一定是空指针。

实际上指向成员函数的指针占两个普通指针的长度,其中就包含了一些辅助信息,来帮助我们在运行时无论遇到虚函数指针还是非虚函数指针,都能正确跳转。

static_castdynamic_castreinterpret_cast

对于基类和派生类,我们有两种cast,分别是down-cast与up-cast,即基类->派生类和派生类->基类。

up-cast

up-cast通常不需要我们显式调用,因为这就是多态正常的使用方式:

1
2
3
4
5
6
7
8
void Func(Base& b) {
b.F();
}

int main() {
Derived d;
Func(d);
}

这里我们把Derived&传给Func,后者看到的却是一个Base&,这里就是发生了up-cast,也是一次隐式转换。如果在某个地方,我们要显式做up-cast,就要使用static_cast了。

重点来了:当编译器做up-cast时,它会根据基类子对象在派生类对象中的偏移量,修改对应指针的值。即当代码里写

1
Base* pb = pd;

时,实际发生的是(假设翻译成C):

1
2
void* p = pd;
Base* pb = (Base*)(p == 0? 0: p + offset);

因此每次up-cast都会有一次分支。而对于基类无虚函数派生类有虚函数,以及多基类场景下,我们都能看到地址发生了变化。

C++标准保证了即使我们使用C风格的转换(即(Base*)pd),编译器也会在其上进行正确的偏移。

而如果我们使用interpret_cast就得不到正确的结果了:

1
2
3
4
Derived d;
Base2* x = &d;
Base2* y = reinterpret_cast<Base2*>(&d);
printf("x:%p y:%p\n", x, y);
1
x:0x7ffee7990968 y:0x7ffee7990950

在我的环境中,这里还有个warning,提醒我换成static_cast

因此结论是:如果要做up-cast,一定不能用reinterpret_cast,要用static_cast,最差也要用C风格的转换。

down-cast

而down-cast就是基类指针转派生类指针。这里正确的做法是使用dynamic_cast,它会做以下事情:

  • 通过基类指针找到其虚表。
  • 从虚表的第0位找到type_info*
  • 对比type_info*与目标类型,如果无法转换,则返回nullptr
  • 根据基类子对象在派生类对象中的偏移,计算出派生类指针并返回。

而有些人会使用static_cast或C风格的转换来做down-cast。它们的问题都在于:不会做前三步的检查,只会做最后一步。

这就导致了,如果转换失败,dynamic_cast会返回nullptr,而static_cast或C风格转换则只会返回一个看似正确地减去了偏移量,实际指向了不知道哪里的派生类指针。

当然,reinterpret_cast就更不对了:它连偏移量都不会算。

警惕Slicing

为struct添加虚成员函数中我们提到,只有通过引用或指针调用虚函数,编译器才会走虚表,才会有多态。实际上,为了与C兼容,保证运行效率,标准规定了这一点。因此直接操作对象时,我们只能得到确定的结果,而不是预期中的运行时多态。

这里有一个陷阱:当我们用派生类对象去赋值或初始化一个基类对象时,派生类的信息会被抹掉,最终我们仅仅得到一个基类对象。这种现象叫Slicing。

1
2
3
Derived d;
Base2 b2 = d;
b2.G();

也许我们预期b2.G()会调用Derived::G,但实际此时b2完完全全就是Base2的对象,因此它只会调用Base2::G

这么做的原因是,b2是一个栈上对象,给它分配的空间就只有sizeof(Base2)这么多,因此它只能是一个Base2对象,而无法是派生类的对象。

这也是一个函数传递要传指针或引用的理由(除了开销与启用多态外),避免Slicing。