多态:原理及虚函数表

目录

前言

1.多态的原理

1.1虚函数表

1.2多态的原理

1.3 动态绑定与静态绑定

1.4虚基类表

1.5为什么对象无法调用多态

1.6栈区、堆区、静态区、代码段的辨析

栈区(Stack)

堆区(Heap)

静态区(Static Area)

代码段(Code Segment)

总结:

2.单继承和多继承关系的虚函数表

2.1 单继承中的虚函数表

2.2 多继承中的虚函数表

2.3. 菱形继承、菱形虚拟继承

普通菱形继承:

菱形虚拟继承:

3. 继承和多态常见的面试问题


前言

上文我们介绍了多态的使用及其实现,本文讲更加深入介绍多态,从原理和虚函数表对多态进行深入剖析。(本文的环境位32位,而不是64位)

本文将通过

1.多态的原理
2.单继承和多继承关系中的虚函数表
3.继承和多态常见的问题
对多态进行深入剖析

1.多态的原理

回顾

在结构体阶段,我么学习了结构体的内存对齐规则及大小计算规则:

1. 第一个成员在与结构体偏移量为0的地址处。

2. 其他成员变量要对齐到某个数字(对齐数)的整数倍的地址处。

注意:对齐数 = 编译器默认的一个对齐数 与 该成员大小的较小值

VS中默认的对齐数为8

3. 结构体总大小为:最大对齐数(所有变量类型最大者与默认对齐参数取小)的整数倍

4. 如果嵌套了结构体的情况,嵌套的结构体对齐到自己的最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

1.1虚函数表

很多同学以为是4,实际上他的大小是8,这是为什么呢?

通过监视窗口可以看到,里面多了一个指针,vfptr。v:virtual   f:function 

这个指针叫做虚函数指针,虚函数指针指向虚函数表。

一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。

让我们通过如下代码来探索虚函数表

class Base
{
public:
 virtual void Func1()
 {
 cout << "Base::Func1()" << endl;
 }
 virtual void Func2()
 {
 cout << "Base::Func2()" << endl;
 }
 void Func3()
 {
 cout << "Base::Func3()" << endl;
 }
private:
 int _b = 1;
};


class Derive : public Base
{
public:
 virtual void Func1()
 {
cout << "Derive::Func1()" << endl;
 }
private:
 int _d = 2;
};

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

Derive类继承Base类。

通过观察和测试,我们发现了以下几点问题:
1. 派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,另一部分是自己的成员。
2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现 Func1完成了重写,所以d的虚表
中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数
的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函
数,所以不会放进虚表。
4. 虚函数表本质是一个 存虚函数指针的指针数组一般情况这个数组最后面放了一个nullptr
5. 总结一下派生类的虚表生成:a.先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生
类重写了基类中某个虚函数, 用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己
新增加的虚函数按其在派生类中的 声明次序增加到派生类虚表的最后(也就是说有虚函数(继承、重写),就有虚表)。同时派生类的虚表指针跟父类的 虚表指针、虚表不是同一个!!!
6. 这里还有一个童鞋们很容易混淆的问题: 虚函数存在哪的?虚表存在哪的? 虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么 虚表存在哪的 呢?虚表存在静态区(不允许改变)

1.2多态的原理

上面分析了这个半天了那么多态的原理到底是什么?让我们看下面的例子,这与虚函数的覆盖有很大的关系。

class Person {
public:
 virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
 virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
p.BuyTicket();
}
int main()
{
Person Mike;
 Func(Mike);
Student Johnson;
Func(Johnson);
 return 0;
}

1. 观察下图的红色箭头我们看到,p是指向mike对象时,p->BuyTicket在mike的 虚表中找到虚
函数是Person::BuyTicket。
2. 观察下图的蓝色箭头我们看到,p是指向johnson对象时,p->BuyTicket在johson的虚表中
找到虚函数是Student::BuyTicket。
3. 这样就实现出了不同对象去完成同一行为时,展现出不同的形态(由于存在不同的虚表,所以要去不同的虚表寻转虚函数)。
4. 反过来思考我们要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调
用虚函数。
5. 再通过汇编代码分析, 看出满足多态以后的函数调用,不是在编译时确定的,是 运行
起来 以后到对象中取找的。不满足多态的函数调用时 编译时确认 好的

 

1.3 动态绑定与静态绑定

1. 静态绑定又称为前期绑定(早绑定), 在程序 编译 期间确定了程序的行为也称为静态多态
比如:函数重载
2. 动态绑定又称后期绑定(晚绑定),是在 程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数, 也称为动态多态
3. 之前买票的汇编代码很好的解释了什么是静态(编译器)绑定和动态(运行时)绑定。

1.4虚基类表

这其实与虚表的关系不大,虚基类表重点是virtual修饰过的基类,防止数据冗余和二义性问题而产生的。

虚拟继承是C++中用于解决多继承时可能出现的菱形继承问题(即一个类多次继承自同一个基类)的一种机制。虚拟继承允许在继承层次中共享基类的一个实例,而不是为每个直接或间接的基类副本都保留一份基类实例。以下是虚拟继承产生额外消耗的原因及其原理:

原理:
共享基类实例: 在虚拟继承中,无论基类在继承层次中被继承多少次,都只存在一个共享的基类实例。这意味着所有继承了这个虚拟基类(被virtual继承的类)的派生类都会引用同一个基类部分。

虚基类表: 为了实现这种共享,编译器会为每个含有虚基类的对象插入一个额外的指针,这个指针指向一个虚基类表(vtable)。虚基类表包含了虚基类的偏移量信息,用于在运行时调整指针,使得能够正确地访问共享的基类实例

调整: 当通过指针或引用访问虚基类成员时,编译器生成的代码会使用虚基类表来调整指针,确保指向正确的共享基类实例。

1.5为什么对象无法调用多态

指针和引用指向的是原个体而对象,在切片时,得到的是原对象的“基类”部分,从而去自身的虚函数指针去找到虚表,去找到重写过的虚函数。

对象调用则是把你的值拷贝给我。但是不会去拷贝虚表指针,而是生成自己的虚表指针。

否则就可能析构函数也可能出问题,出现虚表的重复析构。

1.6栈区、堆区、静态区、代码段的辨析

栈区、堆区(动态分配)、静态区(静态、全局)、代码段(只读)
虚函数存在代码段(和普通函数一样)
虚表(虚函数的地址)在代码段
在编程语言中,尤其是像C/C++这样的语言,程序在运行时会将内存划分为不同的区域,以存储不同类型的数据。以下是栈区、堆区、静态区和代码段的辨析:

栈区(Stack)

  1. 存储内容:局部变量(包括函数参数)、返回地址、栈帧信息等。
  2. 管理方式:由系统自动管理。函数调用时会自动分配栈空间,函数返回时会自动释放。
  3. 生命周期:短生命周期,通常与函数调用的生命周期相同。
  4. 大小限制:通常有限制,比堆小。
  5. 访问方式:按序访问,后进先出(LIFO)。
  6. 优缺点:分配和释放速度快,但灵活性较低。

堆区(Heap)

  1. 存储内容:动态分配的内存,如使用malloc、new分配的对象。
  2. 管理方式:程序员负责管理。需要手动分配和释放。
  3. 生命周期:长生命周期,直到被显式释放。
  4. 大小限制:相对较大,受限于系统的虚拟内存。
  5. 访问方式:随机访问。
  6. 优缺点:灵活性高,但管理复杂,容易产生内存泄漏。

静态区(Static Area)

  1. 存储内容: 全局变量、静态变量(包括局部静态变量)。
  2. 管理方式:由系统管理,程序启动时分配,程序结束时释放。
  3. 生命周期:与程序运行周期相同。
  4. 大小限制:通常有限制,但比栈大。
  5. 访问方式:全局访问。
  6. 优缺点:一直存在于程序的整个运行期间,但过多使用全局变量可能导致命名冲突和维护困难。

代码段(Code Segment)

  1. 存储内容: 可执行代码、常量数据(如字符串常量)
  2. 管理方式:由系统管理,通常 只读
  3. 生命周期:与程序运行周期相同。
  4. 大小限制:取决于程序大小。
  5. 访问方式:执行时按需加载。
  6. 优缺点:只读属性 保证了代码的安全性,但不易修改
理解这些内存区域的区别对于进行有效的内存管理和优化程序性能是非常重要的。不当的内存使用,如栈溢出、堆内存泄漏等,都可能导致程序崩溃或运行不稳定。

总结:

一个类就一张虚表,但是可能有很多个对象。

但是不同的类,不是一张虚表,不管你到底有没有重写虚函数。

2.单继承和多继承关系的虚函数表

需要注意的是在单继承和多继承关系中,下面我们去关注的是派生类对象的虚表模型,因为基类
的虚表模型前面我们已经看过了,没什么需要特别研究的。

2.1 单继承中的虚函数表

class Base { 
public :
 virtual void func1() { cout<<"Base::func1" <<endl;}
 virtual void func2() {cout<<"Base::func2" <<endl;}
private :
 int a;
};
class Derive :public Base { 
public :
 virtual void func1() {cout<<"Derive::func1" <<endl;}
 virtual void func3() {cout<<"Derive::func3" <<endl;}
 virtual void func4() {cout<<"Derive::func4" <<endl;}
private :
 int b;
};
func1()函数被Derive覆盖。并且两者的虚表地址不一致。

2.2 多继承中的虚函数表

class Base1 {
public:
 virtual void func1() {cout << "Base1::func1" << endl;}
 virtual void func2() {cout << "Base1::func2" << endl;}
private:
 int b1;
};
class Base2 {
public:
 virtual void func1() {cout << "Base2::func1" << endl;}
 virtual void func2() {cout << "Base2::func2" << endl;}
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
 virtual void func1() {cout << "Derive::func1" << endl;}
 virtual void func3() {cout << "Derive::func3" << endl;}
private:
 int d1;
};
typedef void(*VFPTR) ();
void PrintVTable(VFPTR vTable[])
{
 cout << " 虚表地址>" << vTable << endl;
 for (int i = 0; vTable[i] != nullptr; ++i)
 {
 printf(" 第%d个虚函数地址 :0X%x,->", i, vTable[i]);
 VFPTR f = vTable[i];
 f();
 }
 cout << endl;
}
int main()
{
 Derive d;
 VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
 PrintVTable(vTableb1);
 VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));
 PrintVTable(vTableb2);
 return 0;
}
(强转成int*,再解引用是为了得到对象的前四个字节的内容)
观察下图可以看出: 多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中
  1. 虚函数表数量

    • Derive类将有两个虚函数表,一个来自Base1的继承,另一个来自Base2的继承。这是因为Derive类继承了两个基类,每个基类都有自己的虚函数表。
  2. 虚函数指针数量

    • Derive类的对象中,将有两个虚函数指针,每个指针分别指向其继承的基类的虚函数表。
  3. 虚函数表内容

    • Derive::func1()覆盖了Base1::func1()Base2::func1(),因此两个虚函数表中func1的条目都会指向Derive::func1()
    • Derive::func3()Derive类的新增虚函数,多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中

2.3. 菱形继承、菱形虚拟继承

普通菱形继承:

菱形继承其实就是一种多继承。继承几个父类就有几个虚表(父类存在虚函数就有一个虚表),菱形继承就是一个特殊的多继承,本质还是多继承。

菱形虚拟继承:

   A
 /   \
B     C
 \   /
   D

 

  1. 一个虚表:对于最终的派生类D来说,只会有一个虚表。这个虚表包含了D类及其所有基类中声明的所有虚函数的指针

  2. 虚基类指针:类B和类C各自会有一个指向其虚基类A的指针。在最终的派生类D中,这两个指针会被合并,以确保无论通过哪个路径访问虚基类A,都是访问的同一个实例

  3. 虚函数解析:当通过类D的实例调用一个虚函数时,编译器会根据虚表中的条目来确定应该调用哪个函数的实现

因此,对于菱形虚拟继承的最终派生类D来说,内部只有一张虚表。这张虚表中包含了从类A、B、C继承下来的所有虚函数的指针,并且通过这些指针可以调用正确的函数实现,即使是在多继承和虚拟继承的情况下也能够保证正确的多态行为。

3. 继承和多态常见的面试问题

1.

class Base1 {  public:  int _b1; };
class Base2 {  public:  int _b2; };
class Derive : public Base1, public Base2 { public: int _d; };
int main(){
 Derive d;
 Base1* p1 = &d;
 Base2* p2 = &d;
 Derive* p3 = &d;
 return 0;
}

结果是p3 == p1 != p2 。多继承中指针偏移问题,先继承的在前,指针位置与p3重合。

2.虚函数指针存在对象的前四个字节还是最后四个字节?

在C++中,虚函数指针通常存储在对象内存布局的开始部分,也就是对象的前四个字节(假设地址大小是32位)。这是因为在C++对象模型中,为了保证通过指向基类的指针能够正确调用派生类中的虚函数,虚函数表指针(vptr)需要位于对象内存的固定位置。

对于大多数编译器和平台,vptr 通常位于对象内存的最开始处。这样做的原因是,当通过基类指针或引用调用虚函数时,编译器生成的代码会查找这个固定位置的vptr,然后通过vptr找到虚函数表(vtable),进而调用正确的函数。

然而,需要注意的是,C++标准并没有规定vptr必须位于对象内存的最开始处,这依赖于具体的编译器实现和平台。在某些情况下,编译器可能会将vptr放置在对象内存的其他位置,但是这种情况非常罕见。

综上所述,在大多数情况下,虚函数指针存在于对象的前四个字节,但这并不是C++标准所强制的,因此不能保证在所有编译器和平台上都是这样

问答题
1. 什么是多态?答:参考本文第一节内容
2. 什么是重载、重写(覆盖)、重定义(隐藏)
3. 多态的实现原理?(条件 + 重写)
4. inline函数可以是虚函数吗?答:可以,不过编译器就忽略inline属性,这个函数就不再是
inline,因为虚函数要放到虚表中去。
5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有this指针(不存在指针类型的调用),使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表
阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析
构函数定义成虚函数
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针
对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函
数表中去查找
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况
下存在代码段(常量区)的。
详解:

虚函数表是在编译阶段生成的。

在C++等支持多态的编程语言中,虚函数表(vtable)是一个用于实现动态绑定的数据结构。每个带有虚函数的类都会有一个虚函数表,表中包含了该类所有虚函数的地址。当一个对象调用一个虚函数时,程序会通过对象的虚函数指针(通常位于对象的起始位置)来查找虚函数表,并调用相应的函数。

以下是关于虚函数表生成的一些详细说明:

  1. 编译阶段:在编译阶段,编译器会分析类的定义,确定需要为哪些类创建虚函数表。对于每个包含虚函数的类,编译器会生成一个虚函数表

  2. 链接阶段:在链接阶段,虚函数表中的函数地址会被填入。如果类有继承关系,子类的虚函数表中会包含从基类继承来的虚函数,以及子类中重写的虚函数

  3. 运行时:当创建一个包含虚函数的对象时,对象的内存布局中会包含一个指向虚函数表的指针。通过这个指针,多态的实现成为可能,即通过基类的指针或引用调用子类的函数。

10. C++菱形继承的问题?虚继承的原理?(参考本文)
11. 什么是抽象类?抽象类的作用?答:参考(3.抽象类)。抽象类强制重写了虚函数,另外抽
象类体现出了接口继承关系
12.所有虚函数的地址一定会被放进虚表吗?        -----是的。
 
13.函数指针的格式以及怎么样定义变量,怎么样typedef

函数指针的格式:返回类型 (*指针变量名)(参数类型1, 参数类型2, ...);

int add(int a, int b) {
    return a + b;
}
定义一个指向该类型函数的指针:int (*ptr_add)(int, int);        //ptr_add就是一个函数指针
赋给这个指针:ptr_add = &add;        或者           ptr_add = add;
数指针调用函数  int result = ptr_add(5, 3); //函数指针(参数列表)

typedef简化函数指针的定义:typedef int (*AddFuncPtr)(int, int);

AddFuncPtrint (*)(int, int)类型的一个别名。现在,你可以用AddFuncPtr声明函数指针变量

AddFuncPtr ptr_add = add;        //AddFuncPtr这就是函数指针的别名

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值