C++ 多态万字详解|吃透虚函数、虚表、重写、虚析构、汇编与底层实验

目录

引言:

1.多态的概念

2.多态的定义及实现

2.1.多态的构成条件

2.2.虚函数

2.3.虚函数的重写/覆盖

2.4.多态的使用

2.5.多态场景的一个选择题

2.6.虚函数重写的一些其他问题

2.6.1.协变(了解)

2.6.2.析构函数的重写

2.7.override和final关键字(C++11新增)

2.7.1.override关键字

2.7.2.final关键字

2.8.重载/重写/隐藏的对比

3.纯虚函数和抽象类

4.多态的原理

4.1.虚函数表指针(简称虚表指针)

4.2.多态是如何实现的

4.3.动态绑定与静态绑定

4.4.虚函数表


引言:

        在 C++ 面向对象三大特性中,封装、继承都是为了代码复用,而多态是为了行为扩展,是 OOP 最重要、底层最难、面试考察最多的核心机制。

如果只学语法不学原理,很容易踩坑:虚函数为什么能实现动态调用?派生类不写虚函数为什么也算重写?缺省参数为什么不走多态?虚表到底存在哪里?虚函数为什么会增加对象大小?

本文将从零完整梳理多态全套体系:从多态概念、构成条件、虚函数重写、经典易错选择题、协变、虚析构、override/final 关键字、纯虚函数抽象类,再深入虚表底层原理、动静绑定汇编对比、内存分区虚表实验,全程配合可运行代码 + 底层调试验证,带你彻底吃透 C++ 多态,做到会用、会做题、懂底层、懂面试

        那么话不多说,直接进入正文————>

                           


1.多态的概念

        多态(polymorphism)的概念:通俗来说,就是多种形态。多态分为编译时多态(静态多态)和运行时多态(动态多态)这里我们重点讲运行时多态,。编译时多态(静态多态)主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,之所以叫编译时多态,是因为他们实参传给形参的参数匹配是在编译时完成的,我们把编译时一般归为静态,运行时归为动态。

运行时多态,具体点就是去完成同一个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。再比如,同样是动物叫的一个行为(函数),传猫对象过去,就是” (>^ω^<)喵 “,传狗对象过去,就是"汪汪"。


2.多态的定义及实现

2.1.多态的构成条件

动态多态是一个继承关系下的类对象,去调用同一函数(参数什么都相同),产生了不同的行为。比如Student继承了Person。Person对象买票全价,Student对象优惠买票。

派生类外部(最常见)动态多态实现有三个必要条件:

  1. 派生类必须是public继承(内部动态多态不需要,protected/private 继承仅派生类内部语法可触发,工程几乎不用)——外部需要赋值兼容转换
  2. 必须是基类的指针或者引用调用虚函数
  3. 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖

说明:

        第一:必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象

        第二:派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到

2.2.虚函数

类成员函数前面加virtual关键字修饰,那么这个成员函数被称为虚函数(这个virtual我们在处理菱形继承的时候也用到过,但这里实现的效果和那里是不同的)。非成员函数不能加virtual修饰!!!

class Person
{
public:
	virtual void Buy()
	{
		cout << "买票——全价" << endl;
	}
};

2.3.虚函数的重写/覆盖

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

注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意埋这个坑,让你判断是否构成多态(是构成的)

!!重写的本质是重写虚函数的实现!!

(在多态的前提下)如果派生类和基类的虚函数的参数列表中给的缺省值不同,如果调用派生类的虚函数且不传参,此时用的是基类的虚函数中参数列表的缺省值!!!!!!

这部分会在后面有道选择题体现出来

class Person
{
public:
	virtual void Buy()
	{
		cout << "买票——全价" << endl;
	}
};

class Student : public Person
{
public:
	virtual void Buy()
	{
		cout << "买票——半价" << endl;
	}
};

2.4.多态的使用

虚函数的重写/覆盖知道了后,我们就可以尝试触发一下多态了

#include <iostream>
using namespace std;

class Person
{
public:
	virtual void Buy()
	{
		cout << "买票——全价" << endl;
	}
};

class Student : public Person
{
public:
	virtual void Buy()
	{
		cout << "买票——半价" << endl;
	}
};

void func(Person& people)
{
	people.Buy();
}

int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

在没虚函数这些概念前,我们如果用这种方式,因为func接收的是基类的引用,调用肯定是调用的基类的Buy成员函数,但是有了虚函数后,我们就可以通过基类的引用调用对应基类或派生类的成员函数,具体虚函数是怎么做到的我会在讲多态原理的时候讲

2.5.多态场景的一个选择题

以下程序输出结果是什么()

A:A->0        B:B-->1        C:A->1        D:B->0        E:编译出错        F:以上都不正确

#include <iostream>
using namespace std;

class A
{
public:
	virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
	virtual void test() { func(); }
};

class B : public A
{
public:
	void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};

int main(int argc, char* argv[])
{
	B* p = new B;
	p->test();
	return 0;
}

答案是B

解析:

        首先,B中的func重写了A中的func,虽然前面没有virtual,但因为是继承下来的,所以B中的func继承了A中func的virtual属性,虽然A和B中func函数的缺省值不一样,但这无关紧要,因为虚函数重写在参数列表方面的要求没有缺省值的要求

        接下来我们分析调用逻辑,先看下图

        首先p是B对象类型指针,然后调用test函数,因为B是A继承下来的,B中先找没找到test,随后去A中找test函数,找到了,此时test函数中的隐式this指针是A*类型,这里其实就涉及了赋值兼容转换,那么此时因为B已经完成了虚函数的重写 ,所以触发多态,这个时候调用的就是B对象下的func函数,但是我们并没有传参,所以此时我们的val是赋缺省值的,在多态的情况下,缺省值是赋的基类的函数的缺省值,所以我们输出是B->1。缺省参数是编译期解析,不是运行时;虚函数是运行时绑定,二者不冲突


2.6.虚函数重写的一些其他问题

2.6.1.协变(了解)

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用(基类虚函数返回的指针或引用可以不是自身,可以是其他基类,派生类也是如此,但需要满足的是派生类返回的和基类返回的需要是派生类和基类的关系),称为协变(这也构成多态)。协变的实际意义并不大,所以我们了解一下即可。

我这里举俩个例子

返回自身的协变

#include <iostream>
using namespace std;

class Person
{
public:
	virtual Person* Buy()
	{
		cout << "买票——全价" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual Student* Buy()
	{
		cout << "买票——半价" << endl;
		return nullptr;
	}
};

void func(Person& people)
{
	people.Buy();
}

int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

返回非自身的协变

#include <iostream>
using namespace std;

class A {};
class B : public A {};

class Person
{
public:
	virtual A* Buy()
	{
		cout << "买票——全价" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual B* Buy()
	{
		cout << "买票——半价" << endl;
		return nullptr;
	}
};

void func(Person& people)
{
	people.Buy();
}

int main()
{
	Person p;
	Student s;
	func(p);
	func(s);
	return 0;
}

2.6.2.析构函数的重写

基类的析构函数为虚函数,此时只要是派生类的析构函数都与基类的析构函数构成重写(无论派生类析构函数是否显式定义,是否加virtual),虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor(这也是为什么在继承的时候我讲基类的析构和派生类的析构发生了隐藏,因为函数名相同了),所以基类的析构函数加了 virtual 修饰,派生类的析构函数就构成重写

下面的代码我们可以看到,如果~A (),不加 virtual,那么 delete p2 时只调用的 A 的析构函数,没有调用 B 的析构函数,就会导致内存泄漏问题,因为~B () 中在释放资源。

#include <iostream>
using namespace std;

class A
{
public:
	virtual ~A()
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};

int main()
{
	A* p1 = new A;
	A* p2 = new B;
	delete p1;
	delete p2;

	return 0;
}

这边~B析构完后又析构了一次~A,这个我在继承的时候讲过,派生类的析构函数结束后会调用基类的析构函数,因为基类的那些成员不会在派生类中析构,会调用基类析构来进行析构

注意:这个问题面试中经常考察,大家一定要理解上面的样例,为什么基类中的析构函数建议设计为虚函数。

2.7.override和final关键字(C++11新增)

从上面可以看出,C++ 对虚函数重写的要求比较严格,但是有些情况下由于疏忽,比如函数名写错参数写错等导致无法构成重写,而这种错误在编译期间是不会报出的,只有在程序运行时没有得到预期结果才来 debug 会得不偿失,因此 C++11 提供了 override,可以帮助用户检测是否重写(这个是编译时检查,assert是运行时检查)。如果我们不想让派生类重写这个虚函数,那么可以用 final 去修饰

2.7.1.override关键字

class A
{
public:
	virtual void func() {};
};
class B : public A {
public:
	virtual void fucn() override {};
};

这个代码在加个override后就能在编译期间检查出问题(未构成虚函数重写,函数名不同)

2.7.2.final关键字

        我们通过这个关键字其实也可以验证我们上面说的析构函数重写问题,我上面说只要基类的析构函数用virtual修饰成了虚函数,那么不管派生类显不显式实现析构函数,都实现了虚函数的重写,我们通过final验证一下,如果我们在基类的虚函数后面加上final关键字,如下

class A
{
public:
	virtual ~A() final
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:
	~B()
	{
		cout << "~B()->delete:" << _p << endl;
		delete _p;
	}
protected:
	int* _p = new int[10];
};

我们显式实现派生类析构的时候就报错了,说明派生类我们显示实现的时候这个析构是虚函数重写

当我们不显示实现的时候,我们发现还是报错了

class A
{
public:
	virtual ~A() final
	{
		cout << "~A()" << endl;
	}
};
class B : public A {
public:

protected:
	int* _p = new int[10];
};

由此,我们就可以验证得出,只要基类的析构函数为虚函数,那么派生类的析构函数不管是否显式实现都实现了虚函数重写,那我们用基类指针或引用调用的时候自然就形成了多态

2.8.重载/重写/隐藏的对比

这里需要注意的是,如果基类的某个成员函数用了virtual关键字修饰成了虚函数,继承下来后,如果你在派生类中用的函数名相同的话,如果加了override关键字,他会检测为你这个是不是重写,如果是的话,那就没事,构成重写,如果不是的话,就会编译不通过,如果不加这个关键字的话就没事,此时如果满足虚函数重写条件就是重写,不满足就是隐藏,如下


3.纯虚函数和抽象类

在虚函数的后面写上 =0,则这个函数为纯虚函数纯虚函数不需要定义实现(实现没啥意义因为要被派生类重写,但是语法上可以实现),只要声明即可包含纯虚函数的类叫做抽象类,抽象类不能实例化出对象,如果派生类继承后不重写纯虚函数,那么派生类也是抽象类。纯虚函数某种程度上强制了派生类重写虚函数,因为不重写实例化不出对象。

就比如下面这种情况,我基类的Car其实是没有实例化的用处的,这个时候我们就可以让Drive变为纯虚函数,使基类变为抽象类

#include <iostream>
using namespace std;

class Car
{
public:
	virtual void Drive() = 0;
};
class Benz :public Car
{
public:
	virtual void Drive()
	{
		cout << "Benz-舒适" << endl;
	}
};
class BMW :public Car
{
public:
	virtual void Drive()
	{
		cout << "BMW-操控" << endl;
	}
};

int main()
{
	// 编译报错:error C2259: “Car”: ⽆法实例化抽象类 
	Car car;
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
	return 0;
}


4.多态的原理

4.1.虚函数表指针(简称虚表指针)

我们先看一个题

下面编译为64位程序的运行结果是什么()

A.编译报错        B.运行报错        C.16        D.24

#include <iostream>
using namespace std;

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
protected:
	long long _b = 1;
	char _ch = 'x';
};
int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

答案是D

我们先前所讲的计算类的大小就是算类中成员的大小再内存对齐即可,但这里多了8字节,这8字节是虚函数产生的,如下

这个多出来的__vfptr是个指针类型,对象中的这个指针我们称之为虚函数表指针(v代表virtual,f代表function)。一个含有虚函数的类中都至少有一个虚函数表指针,因为一个类所有虚函数的地址都要被存放到这个类的虚函数表中严格一点说这个其实就是函数指针数组虚函数表指针指向的就是这个数组的首元素位置,虚函数表也简称为虚表

我们可以多放几个虚函数感受一下

#include <iostream>
using namespace std;

class Base
{
public:
	virtual void Func1()
	{
		cout << "Func1()" << endl;
	}
	virtual void Func2() {}
	virtual void Func3() {}
	virtual void Func4() {}

protected:
	long long _b = 1;
	char _ch = 'x';
};
int main()
{
	Base b;
	cout << sizeof(b) << endl;
	return 0;
}

我们可以用图来理解

4.2.多态是如何实现的

        多态的实现,说到底就是靠继承的赋值兼容转换,因为调用函数调用哪个函数是通过__vfptr 决定的,这个是基类的一个指向虚函数表的指针,但是经过继承下来后,每个派生类的__vfptr 都是指向自己类的虚函数表,那么我们通过传指针或引用然后由基类型接收,这不就是赋值兼容转换,那对应的__vfptr 依旧是自己的那部分,自然就能指哪打哪。

        以下面这个代码为例

#include <iostream>
using namespace std;

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
private:
	string _name;
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-打折" << endl; }
private:
	string _id;
};
class Soldier : public Person {
public:
	virtual void BuyTicket() { cout << "买票-优先" << endl; }
private:
	string _codename;
};
void Func(Person* ptr)
{
	// 这⾥可以看到虽然都是Person指针Ptr在调⽤BuyTicket 
	// 但是跟ptr没关系,⽽是由ptr指向的对象决定的。 
	ptr->BuyTicket();
}
int main()
{
	// 其次多态不仅仅发⽣在派⽣类对象之间,多个派⽣类继承基类,重写虚函数后 
	// 多态也会发⽣在多个派⽣类之间。 
	Person ps;
	Student st;
	Soldier sr;
	Func(&ps);
	Func(&st);
	Func(&sr);

	return 0;
}

我们通过监视可以观察到,不管是基类还是派生类都有对应的虚函数表和虚函数表指针

其实他们的结构用图表示就是这个样子

4.3.动态绑定与静态绑定

  • 对不满足多态条件 (指针或者引用 + 调用虚函数) 的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定

  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也叫做动态绑定

我们从汇编到角度来看看静态绑定和动态绑定的区别

#if 0
    ptr是指针+BuyTicket是虚函数 满足多态条件
    这里就是动态绑定,编译在运行时到ptr指向对象的虚函数表中确定调用函数地址
#endif
	ptr->BuyTicket();
00007FF79CE23920  mov         rax,qword ptr [ptr]  
00007FF79CE23927  mov         rax,qword ptr [rax]  
00007FF79CE2392A  mov         rcx,qword ptr [ptr]  
00007FF79CE23931  call        qword ptr [rax] 



#if 0
    BuyTicket不是虚函数,不满足多态条件
    这里就是静态绑定,编译器直接确定调用函数地址 
#endif
	ptr->BuyTicket();
00007FF6AFE33920  mov         rcx,qword ptr [ptr]  
00007FF6AFE33927  call        Person::BuyTicket (07FF6AFE3160Eh)

4.4.虚函数表

  • 基类或派生类的虚函数表中存放基类或派生类中所有虚函数的地址。一个虚函数表对应一个类,同一个类的多个对象的虚函数表指针指向的是同一块虚函数表。基类和派生类有各自独立的虚表。

  • 派生类由两部分构成,继承下来的基类和自己的成员一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同一个指针变量(如果派生类里的虚函数一个都不重写,指向的还是基类的虚函数表的地址),就像基类对象的成员和派生类对象中的基类对象成员也独立的。

蓝色字体部分我演示一下,代码如下

#include <iostream>
using namespace std;


class A
{
public:
	virtual void tem1() {};
	virtual void tem2() {};
	virtual void tem3() {};
	virtual void tem4() {};

private:
	int i;
};
class B : public A
{
private:
	int i;
};



int main()
{
	A a;
	B b;

	return 0;
}

  • 派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址

举个例子就是我在上面代码的基础上,给tem1重写下,tem1对应的虚函数表就会覆盖上新地址

class B : public A
{
public:
	virtual void tem1() {};
private:
	int i;
};

  • 派生类的虚函数表中包含,(1) 基类的虚函数地址,(2) 派生类重写的虚函数地址完成覆盖,(3)派生类自己的虚函数地址三个部分。

我把这三种情况都包含进来演示一下

#include <iostream>
using namespace std;


class A
{
public:
	virtual void tem1() {};
	virtual void tem2() {};
	virtual void tem3() {};
	virtual void tem4() {};

private:
	int i;
};
class B : public A
{
public:
	virtual void tem1() {};
	virtual void tem5() {};
private:
	int i;
};



int main()
{
	A a;
	B b;

	return 0;
}

我们直接看看不到全部的虚函数表的时候可以用之前看数组的方式来看这个,毕竟虚函数表就是函数指针数组,当然也可以通过内存观察

  • 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 0x00000000 标记。(这个 C++ 并没有进行规定,各个编译器自行定义的,vs 系列编译器会再后面放个 0x00000000 标记(上图就能看到),g++ 系列编译不会放)

  • 虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。

  • 虚函数表存在哪的?这个问题严格说并没有标准答案 C++ 标准并没有规定,我们写下面的代码可以对比验证一下。vs 下是存在代码段 (常量区)

#include <iostream>
using namespace std;


class A
{
public:
	virtual void tem1() {};
	virtual void tem2() {};
	virtual void tem3() {};
	virtual void tem4() {};
	void tem5() {};

private:
	int i;
};
class B : public A
{
public:
	virtual void tem1() {};
	virtual void tem5() {};
private:
	int i;
};



int main()
{
	int i = 0;
	static int j = 1;
	int* p1 = new int;
	const char* p2 = "xxxxxxxx";
	printf("栈:%p\n", &i);
	printf("静态区:%p\n", &j);
	printf("堆:%p\n", p1);
	printf("常量区:%p\n", p2);
	A b;
	B d;
	A* p3 = &b;
	B* p4 = &d;
	printf("A虚表地址:%p\n", *(long long*)p3);
	printf("B虚表地址:%p\n", *(long long*)p4);
	printf("虚函数地址:%p\n", &A::tem1);
	printf("普通函数地址:%p\n", &A::tem5);

	return 0;
}

我们可以发现虚表地址是离常量区最近的

这里我是先得到四个区的地址,随后将俩个类实例化后取地址存在p3和p4里,之后就是关键一步,如果直接解引用得到的就是整个b或是d,但是我们强转成对应指针类型来限制字节个数,因为我是64位环境,所以我用的long long*来强转,随后解引用得到的就是虚表的开始地址(因为我们在前面通过监视可以观察到,虚函数表指针是放在对象的最前面的)


结语:

        那么,C++多态部分的内容就全部讲解完毕啦,希望以上内容对你有所帮助,感谢观看,若觉得写的还可以,可以分享给朋友一起来看哦,毕竟一起进步更有动力嘛,当然能关注一下就更好啦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值