C++ 多态 (详解)

多态的概念

通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。举个栗子:比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是半价买票;军人买票时是优先买票。

总的来说:多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象买票半价。

多态的定义及实现

多态的构成条件

在继承中要构成多态还有两个条件:

  1. 必须通过基类的指针或者引用调用虚函数

  2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

cpp 复制代码
class person
{
public:
	virtual void BT()
	{
		cout << "买票,全价" << endl;
	}
};

class student : public person
{
public:
	virtual void BT()
	{
		cout << "买票,半价" << endl;
	}
};

void Fan(person& p)
{
	p.BT();
}

int main()
{
	person ps;
	student st;
	Fan(ps);
	Fan(st);

	return 0;
}

注意:接收对象的指针或引用,传递是父类就调用父类的函数,传递是子类就调用子类的函数。

在重写父类虚函数的时候,子类的虚函数在不加 virtual 关键字时虽然也能构成重写(因为继承后父类的虚函数被继承下来依旧保持虚函数的属性),这种方法不规范,不建议使用

虚函数的重写和其两个例外

虚函数的重写:

虚函数的重写(覆盖):子类中有一个跟父类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。

刚才多态的构成条件已经实现了虚函数的重写,这里不做过多赘述

虚函数重写的两个例外:

1.协变(父类与子类虚函数返回值类型不同)

子类重写基类虚函数时,与父类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。

这里不仅仅时返回当前父类和子类的类型,还可以返回其他继承关系的类和类类型

2.析构函数的重写(父类与子类析构函数的名字不同)

如果父类的析构函数为虚函数,此时子类析构函数只要定义,无论是否加virtual关键字,都与父类的析构函数构成重写,虽然父类与子类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor。

首先,看看不加 virtual 的情况

本义是想看让p1调用person的析构,p2先调用person的析构再调用student的析构,但是这里并没有调用student的析构,只有父类的析构,这样可能发生内存泄漏。

这是为什么呢?

因为这里构成了隐藏,~person()变为 this->destructor() ~student()为this->destructor()编译器将他们两个的函数名都统一处理destructor,因此调用的时候只看自身的类型,是person就调用person,student就调用student的函数,根本构不成多态,与我们期望的并不一样。

现在加上virtual关键字

student 能正常析构了

虽然说子类可以不加 virtual 关键字,建议不要这样,最好父类和子类都加上

同时析构函数加virtual是在new场景下才需要,其他环境可以不用

C++11 final和override

C++11提供了override和final两个关键字,可以帮助用户检测是否重写。

final:修饰虚函数,表示该虚函数不能再被重写

final修饰类不能被继承

override: 检查子类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。

重载、覆盖(重写)、隐藏(重定义)的对比

(只有重写要求原型相同,原型相同就是指 函数名/参数/返回值都相同)

函数重载:在同一个作用域中,两个函数的函数名相同,参数个数,参数类型,参数顺序至少有一个不同,函数返回值的类型可以相同,也可以不相同。

重定义(也叫做隐藏)是指在继承体系中,子类重新定义父类中有相同名称的非虚函数(参数列表可以不同),此时子类的函数会屏蔽掉父类的那个同名函数。

重写(也叫做覆盖)是指在继承体系中子类定义了和父类函数名,函数参数,函数返回值完全相同的虚函数。此时构成多态,根据对象去调用对应的函数。

抽象类

概念:在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成纯虚函数。

多态的原理

虚函数表

cpp 复制代码
// 这里常考一道笔试题:sizeof(Base)是多少?
class Base
{
public:
 virtual void Func1()
 {
 cout << "Func1()" << endl;
 }
private:
 int _b = 1;
};

通过观察测试我们发现b对象是8bytes,除了_b成员,还多一个__vfptr放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表,那么派生类中这个表放了些什么呢?我们接着往下分析

我们多添加几个虚函数看看

可以发现虚函数会放到虚函数表里,普通函数不会,并且表的内容是一个数组,是函数指针数组

多态的原理

测试代码

cpp 复制代码
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	virtual void fun(){}
private:
	int a;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:
	int b;
};
void Func(Person* p)
{
	p->BuyTicket();
}
int main()
{
	Person p;
	Student s;
	Func(&p);
	Func(&s);
	return 0;
}

虚表指针里的内容

从图中我们可以看到,在内存2中输入 &p 可以找到p的地址,因为p的第一个内容是 _vfptr,因此p的地址也就是_vfptr的地址,通过_vfptr地址里就可以找到虚函数表里的内容,因此内存1中输入_vfptr的地址,便找到两个函数的地址。

同理拿到 s 的

为什么我要费劲心思去看内存呢?监视窗口不是可以看嘛,因为vs2022的监视窗口不一定准确,但内存一定准确。

第二个虚函数的地址一样,因为fun()没有被重写而被继承下来了,而BuyTicket()被子类重写覆盖掉了,这就是为什么重写也被称为覆盖。

引用和指针如何实现多态

可以分析为什么指针指向父类调用父类的函数,指向子类调用子类函数?

传递父类,通过vptr找到找到虚函数表的位置,再去找到虚函数的地址,有了虚函数的地址,便可以去call这个虚函数。

传递子类会进行切割

将子类的内容切割掉父类再去接受这个数据,一样会有vptr(子类的vptr),再去找到虚函数的地址,有了虚函数的地址,便可call这个虚函数,这样实现了多态。

为什么普通类实现不了多态

我们用普通类

给代码做点小改造,给Person加上构造

同时给p对象传10,他会调用构造函数将10赋给成员a。执行Func(p)时注意此时虚函数表的地址为0x0000007ff7dfd3ac18

当执行Func(s)此时a的直为0,此时虚函数表的地址为0x0000007ff7dfd3ac18,没错相同!

不难看出Func(s)传递时,切割出子类中父类的那一份成员会拷贝给父类,但是没有拷贝虚函数表指针。

为什么只拷贝成员,不拷贝虚函数表呢?C++祖师爷为何这么设计?

我们可以反向思考,假设 拷贝构造 和 赋值重载 会拷贝虚函数表指针那么如下,运行后输出的结果就为 买票,半价 了(因为不管指向的什么,只管你所存储的数据)这样就不能保证多态调用时,指向父类,父类调用的是父类的虚函数。因为还有可能经过一些操作,变成子类的虚函数

或许这问题不致命,那么析构呢,要清楚虚函数表还可能有析构函数,遇到如下情况该如何

cpp 复制代码
Person* p = new Person;
Student s;
*p = s;
deletep;

这个时候Person父类的对象delete回去调用子类Student类的析构函数,这样会引发很多不可控的事情。

可能有些绕,只需要知道只有 引用和指针才能触发多态即可!

最后同类对象的虚表一样,如果子类没有重写父类的粗函数,那么他们的虚函数表指针不同,但里面的内容相同

虚函数表存放的位置

各个区地的地址可以通过代码获得,以此来判断虚函数表的存放位置

cpp 复制代码
class Base
{
public:
	virtual void fun1()
	{
		cout << "base::fun1" << endl;
	}

	virtual void fun2()
	{
		cout << "base::fun2" << endl;
	}
private:
	int a;
};

void fun()
{}

int main()
{
	Base b1;
	Base b2;
	static int a = 0;
	int b = 0;
	int* p1 = new int;
	const char* p2 = "hello word";
	printf("静态区:%p\n", &a);
	printf("栈:%p\n", &b);
	printf("堆:%p\n", p1);
	printf("代码段:%p\n", p2);
	printf("虚表:%p\n", *((int*)&b1));
	printf("虚函数地址:%p\n", & Base::fun1);
	printf("普通函数:%p\n", fun);

}

注意打印虚表这里,vs X86环境下的虚表的地址时存放在类对象的头四个字节上。因此通过强转来获取这是个头字节。

b1是类对象,取地址取出类对象的地址,强转为(int*)代表我们只取四个字节,再解引用,即可获取第一个元素的地址,也就是虚函数表指针的地址

从结果来看代码段和虚表的地址非常接近,存放在代码段的常量区

虚函数和普通函数地址最为接近,存放在代码段

动态绑定与静态绑定(了解)

  1. 静态绑定又称为前期绑定(早绑定),在程序编译期间确定了程序的行为,也称为静态多态,

比如:函数重载

  1. 动态绑定又称后期绑定(晚绑定),是在程序运行期间,根据具体拿到的类型确定程序的具体

行为,调用具体的函数,也称为动态多态。

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

单继承中的虚函数表

测试代码

cpp 复制代码
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;
};

class X : Derive
{
public:
	virtual void func3()
	{
		cout << "X::func3" << endl;
	}
};

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

监视窗口中我们发现看不见func3和func4。这里是编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小bug。

打开内存窗口输入_vfptr的地址,找到d中的四个虚函数地址

在vs环境下,虚函数表里的虚函数都是以0结尾符合我们之前观察到的

我们可以通过这一点来打印虚表。

下面我们typedef了虚函数表指针 typedef void(*VFTPTR)(); 可以通过这个函数指针数组来打印里面的虚函数,这个打印函数终止条件就是 !=0 ,传递的参数内容跟前面我们分析的差不多,只是多了一个强转,PrintVFPtr((VFTPTR*)*(int*)&b) ; 因为后面的 *(int*)&b 虽然内容是地址,但是表现形式是一个整形,需要强为 (VFTPTR*) 。

再*((int*)&d) 就会取到vTableAddress指向的地址,即可得到虚函数的地址

cpp 复制代码
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;
};
class X : Derive
{
public:
	virtual void func3() { cout << "X::func3" << endl; }
};

typedef void(*VFTPTR)();

void PrintVFPtr(VFTPTR* a)
{
	for (size_t i = 0; a[i] != nullptr; ++i)
	{
		printf("a[%d]:%p\n", i, a[i]);
		VFTPTR f = a[i];
		f();
	}
	cout << endl;
}

int main()
{
	Base b;
	Derive d;
	X x;
	PrintVFPtr((VFTPTR*)(*((int*)&b)));
	PrintVFPtr((VFTPTR*)(*((int*)&d)));
	PrintVFPtr((VFTPTR*)(*((int*)&x)));
	return 0;
}

我们运行以下代码便可打印出虚表里面的内容

多继承中的虚函数表

cpp 复制代码
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()
{
	printf("%p\n", &Derive::func1);
 
	Derive d;
	//PrintVTable((VFPTR*)(*(int*)&d));
	PrintVTable((VFPTR*)(*(int*)&d));    
	PrintVTable((VFPTR*)(*(int*)((char*)&d+sizeof(Base1))));
}

结论就是:func1是重写的函数,再子类的两个父类的虚表中储存的func1地址不同,但是通过一系列的call这个地址,这个地址的内容又是jump到另一个指令,最终都会跳到子类重写的func1上

PrintVTable((VFPTR*)(*(int*)&d));

因为对象中存虚表指针,虚表指针中存的是虚表(一个指针数组),则需要先解引用访问到这个对象的前四个字节内容(存的就是虚表指针),此时的虚表指针 *((int*)&d)是一个int类型,再把虚表指针类型强转成指针数组类型才能传参

PrintVTable((VFPTR*)(*(int*)((char*)&d+sizeof(Base1)))); 是找到Base2的虚表地址后再解引用找到虚表(直接加2个int字节也能找到base2,考虑Base1可能不单单是2个int大小,这里建议用sizeof(Base1) )

结论: Derive对象Base2虚表中func1是Base2指针ptr2去调用。但是这时ptr2发生切片指针偏移,需要修正。中途就需要修正存储this指针ecx的值

虚函数使用规则

虚函数使用规则:

(1)虚函数在类中声明和类外定义的时候,virtual关键字只在声明时加上,而不能加在在类外实现上

(2)静态成员不可以是虚函数。因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

(3)友元函数不属于成员函数,不能成为虚函数

(4)静态成员函数就不能设置为虚函数(原因:静态成员函数与具体对象无关,属于整个类,核心关键是没有隐藏的this指针,可以通过类名::成员函数名 直接调用,此时没有this无法拿到虚表,就无法实现多态,因此不能设置为虚函数)

(5)析构函数建议设置成虚函数,因为有时可能利用多态方式通过基类指针调用子类析构函数(尤其是父类的析构函数强力建议设置为虚函数,这样动态释放父类指针所指的子类对象时,能够达到析构的多态)

inline函数可以是虚函数吗?

答:可以,不过多态调用的时候编译器就忽略inline属性,这个函数就不再是 inline,因为虚函数要放到虚表中去。

静态成员可以是虚函数吗?

答:不能,因为静态成员函数没有this指针,使用类型::成员函数 的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。

构造函数可以是虚函数吗?

答:不能,因为对象中的 虚函数表指针 是在 构造函数初始化列表阶段才初始化的 。虚函数的意义是多态,多态调用时到虚函数表中去找,构造函数之前还没初始化,如何去找?

析构函数可以是虚函数吗?什么场景下析构函数是虚函数?

答:可以,并且最好把基类的析构函数定义成虚函数。析构函数名统一会被处理成destructor()

对象访问普通函数快还是虚函数更快?

答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。

虚函数表是在什么阶段生成的,存在哪的?

答: 虚函数表是在编译阶段就生成的 ,一般情况下存在代码段(常量区)的。( 虚函数表指针初始化是指把虚函数表的指针放到对象中去,但生成仍是在编译阶段 )

相关推荐
Monly211 分钟前
Java(若依):修改Tomcat的版本
java·开发语言·tomcat
boligongzhu2 分钟前
DALSA工业相机SDK二次开发(图像采集及保存)C#版
开发语言·c#·dalsa
Eric.Lee20212 分钟前
moviepy将图片序列制作成视频并加载字幕 - python 实现
开发语言·python·音视频·moviepy·字幕视频合成·图像制作为视频
小俊俊的博客3 分钟前
海康RGBD相机使用C++和Opencv采集图像记录
c++·opencv·海康·rgbd相机
7yewh5 分钟前
嵌入式Linux QT+OpenCV基于人脸识别的考勤系统 项目
linux·开发语言·arm开发·驱动开发·qt·opencv·嵌入式linux
waicsdn_haha16 分钟前
Java/JDK下载、安装及环境配置超详细教程【Windows10、macOS和Linux图文详解】
java·运维·服务器·开发语言·windows·后端·jdk
_WndProc18 分钟前
C++ 日志输出
开发语言·c++·算法
薄荷故人_20 分钟前
从零开始的C++之旅——红黑树及其实现
数据结构·c++
m0_7482400220 分钟前
Chromium 中chrome.webRequest扩展接口定义c++
网络·c++·chrome
qq_4335545427 分钟前
C++ 面向对象编程:+号运算符重载,左移运算符重载
开发语言·c++