二多态原理


引入(多态原理)

计算下面虚类的大小:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Base{
public:
virtual void func() {}
private:
int _a;
char _b;
};

int main(int argc, char* argv[])
{
std::cout<<sizeof(Base)<<"\n";
return 0;
}

如果是一般的类,那我们会认为是计算结构体对齐之后的大小,结果应当是 8。

但计算结果发现,虚类的结果是 12 ,说明虚类比普通类多了一些东西.

实例化对象Base b; 可以发现对象的头部多了一个指针 _vfptr; 这个指针叫做虚函数表指针,它指向了虚函数表


虚函数表指针

指向虚表的指针,叫虚函数表指针,位于对象的头部.

定义:

​ 如果在类中定义了虚函数,则对象中会增加一个隐藏的指针,叫虚函数表指针__vfptr,虚函数表指针在成员的前面,直接占了 4/8 字节.


虚函数表/虚表

虚函数表指针所指向的表,叫做虚函数表,也叫虚表

虚函数表本质是一个虚函数指针数组,元素顺序取决于虚函数的声明顺序。大小有虚函数的数量决定。

虚表的特性(单继承)

  • 虚表在编译期间生成.

虚表是由虚函数的地址组成,而编译期间虚函数的地址已经存在,因此能够在编译期间完成.

  • 虚函数继承体系中,虚基类先生成一份虚表,之后派生类自己的虚表都是基于从父类继承下来的虚表.

特例,为了方便使用, VS 在虚表数组最后面放了一个 nullptr.(其他编译器不一定有)

  • 子类会继承父类的虚函数表(开辟一个新的数组,浅拷贝)
  • 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数,如果子类没有重写,则虚函数表和父类的虚函数表的元素完全一样
  • 派生类自己新增加的虚函数,从继承的虚表的最后一个元素开始,按其在派生类中的声明次序增加到派生类虚表的最后。
  • 派生类自己新增的虚函数放在继承的虚表的后面,如果是基类则是按顺序从头开始放,总而言之,自己新增的虚函数位置一定比继承的虚函数位置后
  • 虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中.另外对象中存的不是虚表,存的是虚表指针
  • 虚表是在编译阶段就完成了,在初始化列表完成的是虚表指针的初始化
  • 同一类型直接定义的对象共享同一个虚表
  • 子类对象直接赋值给父类对象后就变成了父类对象,只拷贝成员,不拷贝虚表,虚表还是父类的

注:虚表指针和成员谁先初始化由编译器决定


虚表的位置

虚表没有明确说必须在哪里,不过我们可以尝试对比各个区的地址,看虚表的大致位置

根据地址分析,虚表指针与常量区对象地址距离最近,因此可以推测虚表位于常量区.

另外,在监视窗口中观察虚表指针与虚函数地址也可以发现,虚表指针与虚函数地址也是比较接近,也可以大致推测在代码段中.(代码段常量区很贴近,比较 ambiguous ,模棱两可的)

从应用角度来说,虚表也应当位于常量区中,因为虚表在编译期间确定好后,不会再发生改变,在常量区也是比较合适的.


多继承虚表

先看虚函数多继承体系下内存布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Base1 {
public:
virtual void func1() { std::cout << "Base1::func1" <<std::endl; }
virtual void func2() { std::cout << "Base1::func2" << std::endl; }
private:
int b1 = 1;
};

class Base2 {
public:
virtual void func1() { std::cout << "Base2::func1" << std::endl; }
virtual void func2() { std::cout << "Base2::func2" << std::endl; }
private:
int b2 = 1;
};

class Derive : public Base1, public Base2 {
public:
//子类重写func1
virtual void func1() { std::cout << "Derive::func1" << std::endl; }
//子类新增func3
virtual void func3() { std::cout << "Derive::func3" << std::endl; }
private:
int d1 =2;
};

int main()
{
Derive d;
return 0;
}

简单分析可知,虚函数多继承体系下派生类会根据声明顺序依次继承父类.继承方式类似于虚继承.

那么多继承下子类自己新增的虚函数在哪?

我们知道,单继承中,子类自己新增的虚函数会尾插到虚表的末尾.

那么多继承呢?是每个父类都添加?还是只添加到其中一个?添加到一个的话添加到哪里?

  • 通过结果能证明,子类自己新增的虚函数只会添加进第一个继承的父类的虚表中,也就是尾插.
    子类会继承所有父类的虚表,有多少个父类就有多少个虚表

  • 结果也证明,子类重写会对所有父类的同名函数进行覆盖

  • 观察结果还发现,两个 func1 的地址居然不一样.这其实涉及到 C++ this 指针的原理问题 -> this 指针修正.


虚表中地址(概念修正)

  1. 虚函数地址:这是虚函数在程序内存中的实际地址,即函数体开始的位置。

  2. 虚表中的地址:虚表中存储的地址通常直接指向虚函数的实际地址。然而,在某些情况下,如为了实现一些优化,编译器可能不会直接在虚表中存储虚函数的地址,而是存储一个“跳跃”函数的地址,这个跳跃函数再跳转到虚函数的真实地址。这种跳跃函数可以用来做额外的检查或者优化,例如性能计数、调试信息插入等。


菱形继承+多态 与 菱形虚拟继承+多态

菱形继承的问题:菱形继承主要有数据冗余和二义性的问题。由于最底层的派生类继承了两个基类,同时这两个基类有继承的是一个基类,故而会造成最顶部基类的两次调用,会造成数据冗余及二义性问题。如下图所示,在Assistant的对象中Person成员会有两份。

什么是菱形虚拟继承?如何解决数据冗余和二义性的?

虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。

虚拟继承解决数据冗余和二义性的原理

在虚拟继承中,编译器会确保在整个继承体系中只有一个基类实例。编译器会在派生类的对象布局中使用指针来指向基类部分,而不是直接嵌入基类部分。这样,最终派生类中的基类部分指针指向同一个共享的基类实例。


内联函数inline 和 虚函数virtual

inline如果被编译器识别成内联函数,则该函数是没有地址的. 与虚表中存放虚函数的地址有冲突.

但是事实上,inline 和 virtual 是可以一起使用的

  • 这取决于使用该函数的场景:内联是一个建议性关键字,如果发生多态,则编译器会忽略内联.如果没有发生多态,才有可能成为内联函数,即:多态和内联可以一起使用,但同时只能有一个发生

静态函数static 与 虚函数

静态成员函数不能是虚函数,因为静态成员函数没有 this 指针,与多态发生条件矛盾

  • 父类引用/指针去调用

  • static函数没有隐藏this参数.不满足虚函数重写条件”三同”

  • 静态成员函数目的是给所有对象共享,不是为了实现多态

构造函数、拷贝构造函数、赋值运算符重载 与 虚函数

  • 构造,拷贝构造不能是虚函数

  1. 构造函数需要帮助父类完成初始化,必须一起完成,不能像多态那样非父即子(父对象调父的,子对象调子的);
  2. 虚表指针初始化是在构造函数的初始化列表中完成的,要先执行完构造函数,才能有虚函数
  3. 构造函数多态没有意义

  • 赋值运算符重载也和拷贝构造一样,不建议写成虚函数,虽然编译器不报错.