当前位置: 首页 > article >正文

C++:多态的原理

目录

一、多态的原理

1.虚函数表 

2.多态的原理 

 二、单继承和多继承的虚函数表

1、单继承中的虚函数表

2、多继承中的虚函数表 


 

一、多态的原理

1.虚函数表 

首先我们创建一个使用了多态的类,创建一个对象来看其内部的内容:

#include<iostream>
using namespace std;

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


int main()
{
	Base b;
	cout << sizeof(Base) << endl;
	return 0;
}

通过运行在x64下,Base的大小是16btyes,在x86下,Base的大小是8btyes。在通过监视窗口,出了有_b成员,还多了一个_vfptr数组,这个指针数组实际上叫做虚函数表指针数组,严格意义来说,一个含有虚函数的类中至少有一个虚函数表指针数组,这个数组中存放的是虚函数的函数地址,虚函数表也叫做虚表。为什么要这么设计呢?

针对上面的代码我们在进行改造:

1.增加一个继承了基类的派生类

2.派生类中去重写虚函数

3.基类中增加一个虚函数和一个普通函数(派生类不进行重写和不存在这两个函数)

#include<iostream>
using namespace std;

class Base
{
public:
	virtual void Func1()
	{
		cout << "Base::Func2()" << 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;
}

 

 【总结】

1、虚函数表指针_vfptr创建的:当对象实例化出来后,会调用构造函数,在构造函数的初始化列表中有_vfptr赋值的语句,并且把虚函数表的首地址赋给虚表指针。

2、派生类对象d中也有一个虚表指针,其中是由两个部分构成的,一部分是继承父类成员,另一部分是虚表指针,也就是说是虚函数。

3、基类 b 对象和派生类 d 对象虚表地址是不一样的,在虚表中我们发现,有一个函数指针地址是一样的,有一个是不一样的。虚表地址不一样说明派生类中重写的函数地址发生了改变。基类虚函数 Func1 在派生类中完成了重写,d 的虚表中存的是重写的 Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。所以,派生类对象 d 的虚表中本来该放的是基类虚函数的地址,但是因为派生类重写了基类的虚函数,所以基类虚函数的地址就被覆盖变成了派生类虚函数的地址,本意是调用基类的虚函数,结果却调到了派生类的虚函数,这就实现了多态。

4、另外 Func2 继承下来后是虚函数,所以放进了虚表;Func3 也继承下来了,但它不是虚函数,所以不会放进虚表。

5、基类和派生类,无论是否完成了虚函数的重写,都有各自独立的虚表。

6、一个类的所有对象共享同一张虚表。(就像一个类的所有对象共享成员函数一样)

【虚函数表的生成过程】

1、先将基类中的虚表内容拷贝一份到派生类虚表中。

2、如果派生类重写了基类中某个虚函数,用派生类自己重写的虚函数覆盖虚表中基类的虚函数。

3、派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。

 虚函数存在哪里?虚表存在哪里?

错误回答:虚函数存在虚表,虚表存在对象中。

正确回答:虚表中存的是虚函数的指针,并不是虚函数,虚函数和普通的函数是一样的,都是存放在代码段中,只是将它的指针放到了虚表中,而在对象中存放的是虚表的指针,也不是虚表,虚表在vs下也是存放在代码段的位置中。

VS下进行验证 

class Base
{
public:
	virtual void func1()
    {
        cout << "Base::func1" << endl;
    }
private:
	int a;
};
int Test()
{
    return 0;
}

int main()
{
	Base b;
 
	int a1 = 0; // 栈帧
	int* p1 = new int; // 堆区
	const char* p2 = "hello"; // 常量区
	auto pf = Test(); // 函数地址
	static int a2 = 1; // 静态区
 
	printf("栈帧        :0x%p\n", &a1);
	printf("堆区        :0x%p\n", p1);
	printf("常量区      :0x%p\n", p2);
	printf("函数地址    :0x%p\n", pf);
	printf("静态区      :0x%p\n", &a2);
	printf("虚函数表地址:0x%p\n", *((int*)&b));
 
	return 0;
}

 

2.多态的原理 

 多态的原理到底是什么?还记得这里 Func 函数传 Person 调用的 Person::BuyTicket,传 Student 调用的是 Student::BuyTicket吗?

 

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、这样就实现出了不同对象去完成同一行为时,展现出不同的形态。

要达到多态,有两个条件,一个是虚函数覆盖,一个是对象的指针或引用调用虚函数

为什么? 

(1)基类对象的指针 / 引用调用虚函数的原理是什么? 

不管基类指针 / 引用指向的是基类还是派生类,执行这段代码 p.BuyTicket() 的指令是一模一样的,先找到虚表指针,通过虚表指针找到虚表,取对应虚函数的地址并调用该虚函数。

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

p中存的是Mike对象的指针,将p移动到eax中
001940DE  mov         eax,dword ptr [p]
[eax]就是取eax值指向的内容,这里相当于把Mike对象头4个字节(虚表指针)移动到了edx
001940E1  mov         edx,dword ptr [eax]
[edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE  mov         eax,dword ptr [edx]
call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的。
001940EA  call           eax   22  |  001940EC  cmp         esi,esp   

可以看出,足多态以后的函数调用,不是在编译时确定的,而是运行起来以后到对象的中去找的。不满足多态的函数调用是编译时确认好的。 

(2)为什么多态必须要用基类的指针 / 引用来调用虚函数,而用基类对象调用却不行? 

派生类对象赋值给基类对象,不会拷贝派生类的虚表指针,只会拷贝对象中的数据成员过去。不妨这样来理解:一个类的所有对象共享同一张虚表,就像一个类的所有对象共享成员函数一样,只能供这个类自己的对象使用,所以派生类对象是不可能把虚表拷贝过去的,不然就违背同一个类共享的规则了。那么既然不会把派生类的虚表指针拷贝过去,那基类对象自然就不能调用到派生类的虚函数了。

 由上图,我们可以看到,Johnson赋值给Amy,但是Amy的虚表并没有变成派生类Johnson的虚表。

下面则是上面继承关系中的 Person 类对象 Mike 和 Student 类对象 Johnson 模型:解释了用基类引用 / 指针引用不同对象去完成同一行为时,如何展现出不同的形态。

(3)动态绑定与静态绑定

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

 二、单继承和多继承的虚函数表

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;
};

观察上图中的监视窗口中我们发现看不见 func3 和 func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug。那么我们如何查看 d 的虚表呢?下面我们使用代码打印出虚表中的函数

// 函数指针VFPTR
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]);
        // 把虚表各元素由void*强转为函数指针类型后,赋值给函数指针f
        VFPTR f = vTable[i];
        // 调用函数
        f();
    }
    cout << endl;
}
 
int main()
{
    Base b;
    Derive d;
 
    /*思路:取出b、d对象的头4字节,就是虚表的指针,
      前面我们说到虚函数表本质是一个存虚函数指针的指针数组,
      这个数组最后面放了一个nullptr
      1、先取b的地址,强转成一个int*的指针
      2、再解引用取值,就取到了b对象头4字节的值,这个值就是指向虚表的指针
      3、再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
      4、虚表指针传递给PrintVTable进行打印虚表
      5、需要说明的是这个打印虚表的代码经常会崩溃,
         因为编译器有时对虚表的处理不干净,
         虚表最后面没有放nullptr,导致越界,
         这是编译器的问题。我们只需要点目录栏的-生成-清理解决方案,
         再编译就好了。*/
 
    VFPTR* vTableb = (VFPTR*)(*(int*)&b);
    PrintVTable(vTableb); // 打印对象b的虚表
 
    VFPTR* vTabled = (VFPTR*)(*(int*)&d);
    PrintVTable(vTabled); // 打印对象d的虚表
 
    return 0;
}

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;
};
 
// 函数指针VFPTR
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]);
        // 把虚表各元素赋值给函数指针f
        VFPTR f = vTable[i];
        // 调用函数
        f();
    }
    cout << endl;
}
 
int main()
{
    Derive d;
 
    VFPTR* vTableb1 = (VFPTR*)(*(int*)&d);
    PrintVTable(vTableb1); // 打印第一张虚表
 
    // 必须先强转成char*,然后加Base1大小个字节,再强转成int*,解引用,强转成VFPTR*
    VFPTR* vTableb2 = (VFPTR*)(*(int*)((char*)&d+sizeof(Base1)));
    PrintVTable(vTableb2); // 打印第二张虚表
 
    return 0;
}

1、Base1 和 Base2 中都有虚函数 func1,那么 Derive 类中的 func1 到底是重写的哪一个基类的呢? 

答:两个基类 Base1 和 Base2 中的虚函数 func1 都会被重写,因为要满足多态条件。

2、多继承体系,Derive 继承了两个基类,那么 Derive 对象中有几张虚表呢?
答:Derive 对象中有两张虚表。

观察下图可以看出:多继承派生类的未重写的虚函数放在第一个继承基类部分的虚函数表中。 

这里 Derive 对象的两张虚表中的重写的 Derive::func1 函数,虽然函数地址不一样,但是当 Base1 或 Base2 指针指向 Derive对象时,调的都是 Derive 中的 func1,是同一个函数。这其中的具体原因和编译器的设计有关。  


http://www.kler.cn/a/411844.html

相关文章:

  • Springboot启动报错’javax.management.MBeanServer’ that could not be found.
  • 学习Java的日子 Day56 数据库连接池,Druid连接池
  • Elasticsearch:Retrievers 介绍
  • JSON 性能测试 - WastJson 性能也很快
  • 利用开源图床的技巧与实践
  • 机器学习与图像处理中上采样与下采样
  • VMware ubuntu创建共享文件夹与Windows互传文件
  • Unity中的简易TCP服务器/客户端
  • macos 14.0 Monoma 修改顶部菜单栏颜色
  • Leetcode53. 最大子数组和(HOT100)
  • numpy.digitize函数介绍
  • 缺失的第一个正数(java)
  • 挂载本地目录到k8s的pod实现持久化存储
  • [java] 什么是 Apache Felix
  • wp the_posts_pagination 与分类页面搭配使用
  • git-显示顺序与提交顺序不一致的问题
  • unity3d——基础篇2刷(Mathf练习题)
  • RabbitMQ的预取值详解
  • 泷羽sec-linux进阶
  • postman的简单使用
  • 【mac】终端左边太长处理,自定义显示名称(terminal路径显示特别长)
  • 前端小练习——星辰宇宙(JS没有上限!!!)
  • 51单片机从入门到精通:理论与实践指南(一)
  • Hadoop的MapReduce详解
  • 详细描述一下Elasticsearch更新和删除文档的过程?
  • 【Linux】Ubuntu:轻量级Xfce桌面及远程连接