C++——多态与虚表

news/2024/7/16 7:47:50

目录

1.多态的实现

2.虚表

2.1虚函数重写是怎么实现的

2.2多态的原理

2.3静态绑定与动态绑定

3.单继承体系中的虚函数表

​编辑4.多继承体系中的虚函数表

5.菱形继承的虚函数表

6.菱形虚拟继承的虚函数表

1.多态的实现

在C++中,要想实现多态,必须满足以下几个条件:

  1. 有继承关系
  2. 有虚函数
  3. 虚函数要重写

根据继承关系中,派生类向基类赋值没有发生类型转换的特性,可以得出一个结论:基类的指针或引用指向了一个"基类对象",这个"基类对象"有可能是基类本身的对象,也有可能是派生类当中的基类部分,由于编译时确定不了(因为这两种基类对象没有差别),所以多态又称运行时绑定

这幅图用来解释上面那段话:

 以一段代码来体会运行时绑定(动态绑定):

class Person
{
public:
	virtual void slefMessage()
	{
		cout << "Person" << endl;
	}
};

class Student : public Person
{
public:
	virtual void slefMessage()
	{
		cout << "Student" << endl;
	}
};

class Teacher : public Person
{
public:
	virtual void slefMessage()
	{
		cout << "Teacher" << endl;
	}
};

void testPolimorphic(Person &rp)
{
	rp.slefMessage();//调用虚函数
}
int main()
{
	Student s;
	Teacher t;
	testPolimorphic(s);
	testPolimorphic(t);
	return 0;
}

2.虚表

虚表全称虚函数表,所以它是一个函数指针数组。 虚表指针将会被存放在对象中,所以以下代码的输出结果可能会令人诧异:

class Person
{
public:
	virtual void slefMessage()
	{
		cout << "Person" << endl;
	}
};
int main()
{
	Person p;
	cout << sizeof(p) << endl;
	return 0;
}

实际上类的对象模型当中确实不存储任何成员函数,包括虚函数在内。虚函数被存放在了虚函数表当中,但是编译为了能够找到虚函数表,所以有必要维护一个虚函数表指针,并将它存放在对象当中。所以最后的输出结果为4(64位平台下的输出结果为8,本篇文章的所有测试用例都在Visual Studio 2013下编译运行)。也就是说对象的前4/8个字节为虚表指针

对于上面的程序,以调试-监视窗口查看是这样的:

以调试-内存窗口查看是这样的:

所以对于Person类对象来说,它的对象模型应该是这样的:

 虚表实际上不一定是以空结尾,只是对于我所使用的编译器来说它就是以空结尾的。其他的编译器可能不一样。

2.1虚函数重写是怎么实现的

在语法层面上,派生类继承了基类的虚函数,再定义实现一遍基类的虚函数就是"重写"。但是在实现原理上远比这复杂的多。以下面的代码为例:

class Person
{
public:
	virtual void func1()
	{}
	virtual void func2()
	{}
};

class Student : public Person
{
public:
	virtual void func1()
	{}
};
int main()
{
	Person t;
	Student s;
	return 0;
}

对于这段代码,以调试-监视窗口观察是这样的:

由此可以得出两个结论

  1. 因为数组的首元素地址可以代表数组的地址,对比上图可以发现派生类的虚表是基类虚表的一份拷贝
  2. 如果派生类发生了重写基类虚函数,原本存放在虚表的基类虚函数地址会被替换为派生类虚函数地址;反之,派生类没有重写虚函数,虚表放的还是基类的虚函数地址。 

所以重写的实质不是程序员再定义实现一遍虚函数就行,而是虚表当中的虚函数地址被替换,这种替换行为称为重写(覆盖)

补充一个结论:如果派生类实现了一个全新的虚函数,这个虚函数的地址会追加进虚表当中

虚表的存放位置在代码段(常量区),以下面这段代码证明:

class Test
{
public:
	virtual void func(){}
};
int main()
{
	Test t;
	
	int a = 0;
	cout << "栈: " << (void*)&a << endl;

	static int b = 0;
	cout << "数据段: " << (void*)&b << endl;

	const char * str = "nice";
	cout << "代码段: " << (void*)str << endl;

	cout << "虚表指针位置? " << *(void**)&t << endl;
	return 0;
}

 "*(void**)&t"是什么写法?解引用之后得到一个void*类型的指针,void*类型在32位平台下有4个字节,64位平台下有8个字节。所以这种写法能够自适应不同的平台。

2.2多态的原理

上面开头的代码已经证明多态是可以被实现的,那么它的原理一定与虚表有关。

实际上要调用虚函数,就先要搞清楚虚表在哪;为了搞清楚虚表在哪,对象当中就必须有虚表指针。并且由于编译器编译时根本就不知道基类的指针或引用到底指向哪个类的对象,所以编译器就非常智能地采用多态策略。那么多态的原理就是:调用虚函数时不会直接调用,而是在程序运行时根据对象的虚表确定调用的虚函数

以一张图理解多态的原理:

2.3静态绑定与动态绑定

  • 静态绑定:又称静态多态,在编译时就确定了调用的行为。典型的例子就是函数重载,根据调用函数时传入的类型不同就可以确定不同的调用方法。
  • 动态绑定:又称多态,在编译时确定不了具体的行为而将工作留在程序运行时。主要是利用了继承当中,派生类向基类赋值没有类型转换的特性。动态绑定的核心就是运行时找虚表

3.单继承体系中的虚函数表

实际上可以将虚函数分为三类:

  1. 派生类未重写的虚函数
  2. 派生类重写的虚函数
  3. 派生类新增的虚函数

对于1来说,这个虚函数依然是基类的虚函数;对于2来说,该虚函数将之前的基类虚函数替换掉,完成重写;对于3来说,这个虚函数将会追加在虚表的后面。

由此可以推出虚表的生成条件

  1. 基类当中有虚表,派生类继承后会生成一份一模一样的虚表(拷贝)
  2. 基类当中没有虚表,但是派生类有虚函数

需要注意的是,虚表在对象调用构造函数之前已经生成了,构造函数初始化的是虚表指针。这就意味着重写工作由编译器完成。

以一段代码作为样例:

class A
{
public:
	virtual void func1()
	{}
};

class B : public A
{
public:
	virtual void func1()
	{}
};

class C : public B
{
public:
	virtual void func1()
	{}

	virtual void func2()
	{}
};

int main()
{
	B b;
	C c;
	return 0;
}

 以调试-监视窗口观察:

以一张图来理解单继承体系中的虚表:

4.多继承体系中的虚函数表

 以一段代码作为样例:

class A
{
public:
	virtual void func1()
	{}
};

class B
{
public:
	virtual void func1()
	{}
};

class C : public A,public B
{
public:
	virtual void func1()
	{}

	virtual void func2()
	{}
};
int main()
{
	C c;
	return 0;
}

 以调试-监视窗口观察:

从结果上来看,C类对象当中有两份虚表,分别是从A类继承而来的和从B类继承而来的。根据三同原则(返回类型、函数名、参数类型都相同),所以C类当中的func1虚函数与A类、B类的func1虚函数构成重写关系。那么C类对象当中有一新增的虚函数func2,它被追加进了两份虚表当中的其中一份,即A类的虚表当中。由此可以得出一个结论:多继承体系中,派生类的新增虚函数追加在派生类的第一张虚表中

以一张图解释上面的结论:

实际上凡是关于虚表的,只需要保证对象的前4/8个字节是虚表指针即可。 

对于多继承来说,派生类不一定有两张虚表,主要看被继承的基类有没有虚表。

5.菱形继承的虚函数表

对于菱形继承来说,它的本质就是一个多继承体系,所以它的虚表与上面说介绍的多继承体系的虚表没什么差别。

菱形继承就是两个单继承+一个多继承,以一张图来理解:

6.菱形虚拟继承的虚函数表

说实在的菱形继承本身就没有什么价值更何况菱形虚拟继承。但是这里还是简单的谈谈。

首先以一段代码来明确虚拟单继承的虚函数表在哪:

class A
{
public:
	virtual void func1()
	{}
};
class B : virtual public A
{
public:
	virtual void func1()
	{}
};

int main()
{
	B b;
	return 0;
}

 以调试-内存窗口观察:

理解的思路很简单:虚继承将基类的部分单独作为派生类的一个部分。所以派生类中如果新增虚函数,那么派生类将会再生成一个虚表,并且派生类对象的前4/8个字节将会是指向新开虚表的虚表指针。

在菱形虚拟继承中,最终派生类的两个基类,被继承之后会将虚基表指针、两个虚表合成一份,这就注定了最终派生类必须完成虚函数的重写。试想一下,基类1重写了虚函数,基类2也重写了虚函数,那么最终类如果不重写如函数,那么继承下来的虚表当中的虚函数是用基类1的还是基类2的?

以一份代码来理解上面的那段话:

class A
{
public:
	virtual void func1()
	{}
};

class B : virtual public A
{
public:
	// B类重写了A类的虚函数
	virtual void func1()
	{}
};

class C : virtual public A
{
public:
	// C类重写了A类的虚函数
	virtual void func1()
	{}
};

class D : public B,public C
{
public:
	// D类也必须完成重写,因为虚继承没有数据冗余和二义性
	// 所以A类部分只有一份,被放在D类对象的末尾
	// 那么不重写的话,虚表当中放哪个函数?
	virtual void func1()
	{}
};


http://www.niftyadmin.cn/n/393678.html

相关文章

mysql数据类型有哪几种

Mysql支持的多种数据类型主要有&#xff1a;数值数据类型、日期/时间类型、字符串类型。 整数 浮点数&定点数 注&#xff1a;定点数以字符串形式存储&#xff0c;对精度要求高时使用decimal较好&#xff1b;尽量避免对浮点数进行减法和比较运算。 时间/日期类型 字符串类型…

在编程中,代理、委托、回调、钩子、句柄、打桩的区别

文章目录 代理委托委托与代理的区别 回调回调函数回调函数与普通函数的区别 钩子广义的钩子钩子与代理的区别钩子与委托的区别钩子与回调函数的区别 句柄句柄与钩子的区别 打桩打桩与代理的区别 代理 代理&#xff08;proxy&#xff09;&#xff1a;被代理类写好一套 API 的实现…

chatgpt赋能python:Python取消合并单元格

Python取消合并单元格 在Excel中&#xff0c;合并单元格是一个非常常见的操作&#xff0c;它可以将多个单元格合并成一个单元格。这样可视化效果会更好&#xff0c;但是实际上会影响数据的计算和操作。如果你想取消这个操作&#xff0c;手工操作可能会非常费时间。不过&am…

Linux账号管理与ACL权限设定(一)

Linux的账号与群组 Linux系统中&#xff0c;关于账号和群组&#xff0c;实际记录的是UID和GID的数字&#xff1b; 关于账号有两个非常重要的文件&#xff1a;/etc/passwd 和 /etc/shadow &#xff1b; /etc/passwd 文件结构&#xff1a; 账号名称&#xff1a;密码&#xff…

Git版本控制工具详解

1 邂逅版本控制工具 2 集中式和分布式区别 3 Git的环境安装搭建 4 Git初始化本地仓库 6 Git远程仓库和验证 目录 content 5 Git记录更新变化过程 7 Git的标签tag用法 8 Git分支的使用过程 9 工作中的Git Flow 10 Git远程分支的管理 11 Git rebase的使用 12 Git常见命…

什么情形下应该使用BFF?带你了解BFF的优势,即服务于前端的后端

BFF简介 BFF是一种Web架构&#xff0c;全名为Backends For Frontends&#xff0c;即为服务于前端的后端。这个词来源于Sam Newman的一篇文章&#xff1a;Pattern: Backends For Frontends。BFF一般指的是在前端与后端之间加增加一个中间层。为什么要在前端和后端之间增加一个B…

以太网交换机自学习和转发帧的流程

以太网交换机自学习和转发帧的流程 笔记来源&#xff1a; 湖科大教书匠&#xff1a;以太网交换机自学习和转发帧的流程 声明&#xff1a;该学习笔记来自湖科大教书匠&#xff0c;笔记仅做学习参考 以太网交换机工作在数据链路层&#xff08;也包括物理层&#xff09; 以太网交…

Eclipse不用删除之前的项目也可以多次导入相同的项目,操作十分简单!!

问题引入 当我们在学习时&#xff0c;常常需要多次导入网上的同一个案例进行查看效果或者导入自己的项目、用于进行代码测试&#xff0c;原来的项目要继续保留&#xff0c;作为备份&#xff0c;防止代码测试对代码修改过火&#xff0c;一去不返。但当我们导入在Eclipse项目管理…