您现在的位置是:首页 >技术交流 >[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)网站首页技术交流

[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)

朵猫猫. 2023-05-26 16:00:02
简介[C++]:万字超详细讲解多态以及多态的实现原理(面试的必考的c++考点)

文章目录

  • 前言
  • 一、多态的定义及实现
  • 1.多态的构成条件
  • 2.c++11的override和final
  • 3.重载,重写,重定义的比较
  • 4.抽象类
  • 5.多态的原理
  • 6.多继承中的虚函数表
  • 7.动态绑定和静态绑定
  • 总结


前言

多态的概念:

多态的概念:通俗来说,就是多种形态, 具体点就是去完成某个行为,当不同的对象去完成时会
产生出不同的状态
举个栗子:比如 买票这个行为 ,当 普通人 买票时,是全价买票; 学生 买票时,是半价买票; 军人
买票时是优先买票。
学习多态前我们必须知道虚函数,虚函数的关键字为virtual,虚函数与之前学的虚继承没有任何关系,只是共用了同一个关键字。虚继承是为了解决多继承中数据冗余和二义性的问题,而虚函数是为了实现多态。

一、多态的定义及实现

1.多态的构成条件

多态是在不同继承关系的类对象,去调用同一个函数,产生了不同的行为。

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

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

2.被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。重写的三同(函数名,参数,返回值)

下面我们用代码演示一下:

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
};
void Func(Person& p)
{
    //1.不满足多态---看调用者的类型,调用这个类型的成员函数
    //2.满足多态  -- 看指向的对象的类型,调用这个类型的成员函数
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}

 以上是我们所举的一个买票的例子,基类中的函数与子类中的函数名相同前面加了virtual关键字修饰并且参数也相同都是无参的,但是实现不同,而我们的func函数是用基类的对象的引用去调用函数,满足了形成多态的条件,当子类对象调用这个函数时我们发现打印的是子类的函数实现,父类对象打印的是父类的函数实现,如下图:

 我们前面学了继承都知道如果用基类对象调用函数只能调用基类的函数,现在基类对象不仅能调用自己的还能调用子类的,下面我们看看如果不是基类的指针或引用是什么现象:

 我们可以看到当不是基类的指针或引用的时候调用同名函数只会调用自己的,下面我们在看看如果不写virtual是什么情况。

首先是基类函数不加virtual:(在修改前一定要恢复为基类的指针或引用)

 我们可以看到基类中无virtual修饰是形成不了多态的

派生类函数不加virtual:

 我们发现派生类加不加virtual关键字不重要,只要基类同名函数加了virtual就可以形成多态。

我们刚刚所演示的情况用其他方式也能实现,下面我们用一个更详细的例子来展现多态的重要性:

class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
	~Person()
	{
		cout << "~Person()" << endl;
	}
};
class Student : public Person {
public:
	void BuyTicket() { cout << "买票-半价" << endl; }
	~Student()
	{
		cout << "~Student()" << endl;
	}
};
void Func(Person* p)
{
	p->BuyTicket();
	delete p;
}
int main()
{
	Func(new Person);
	Func(new Student);
	return 0;
}

 以上代码我们加了析构函数,在调用func函数的时候开了基类空间和派生类空间,这个时候析构函数并没有重写加virtual关键字,所以一定是父类指针调用父类的析构函数,如下图:

 这个结果是我们想要的吗?肯定不是!因为子类很有可能有自己的东西,如果我们用父类的析构析构子类对象必定会造成内存泄漏,这里我们想的是父类指针中存放父类对象就调用父类的析构函数,父类指针中存放子类对象就调用子类的析构函数,这个时候我们给基类中的析构函数加上virtual关键字:

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

 这个时候我们发现正确的释放了子类的资源,为什么会析构完子类再析构父类这个问题我们在继承中已经提到过,因为派生类的构造函数结束的时候自动调用父类的构造函数实现先析构子再析构父。这里不知道会不会有人问为什么析构函数名字不同却实现了多态呢?因为我们之前说过,编译器对于任何一个析构函数的处理都是处理为destructor,可以理解为每个类中的析构函数都是同名函数。 

上面我们说了虚函数的重写有三同,函数名相同,返回值相同,参数相同。但是有两个例外,第一个例外是协变(基类与派生类虚函数返回值类型不同):协变中的返回值不同必须是父子关系的指针或者引用,不可以是其他的:

派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指
针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
class A {};
class B : public A {};
class Person {
public:
	virtual A* f() 
	{ 
		cout << "Person" << endl;
		return new A; 
	}
};
class Student : public Person {
public:
	virtual B* f() 
	{ 
		cout << "Student" << endl;
		return new B;
	}
};
void Func(Person* p)
{
	p->f();
	delete p;
}
int main()
{
	Func(new Person);
	Func(new Student);
	return 0;
}

上面代码中我们可以看到派生类对基类中的f()函数进行了重写,但是他们的返回值类型一个是A*一个是B*,我们看看结果:

 没错 这里即使返回值不同也是多态,那么我们看看如果返回值不是父子关系的指针或引用是什么结果:

 我们可以看到直接编译报错了,并且报错提示不是协变。

2.c++11的override 和 final

从上面可以看出, C++ 对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数
名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有
得到预期结果才来 debug 会得不偿失,因此: C++11 提供了 override fifinal 两个关键字,可以帮
助用户检测是否重写。
1. final :修饰虚函数,表示该虚函数不能再被重写
class Car
{
public:
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};

 在这里我们只需要记住final关键字必须写到函数的参数列表后面。

2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car {
public:
	virtual void Drive() {}
};
class Benz :public Car {
public:
	virtual void Drive() override { cout << "Benz-舒适" << endl; }
};

 下面我们不重写再看看结果:

 3.重载,覆盖(重写),隐藏(重定义)的对比

重载:重载必须是在同一个作用域,也就是说在一个类中可以构成重载,继承体系中不可以构成重载。重载的条件是函数名相同

覆盖(重写):重写就是我们所说的虚函数,重写是出现在继承体系中的派生类中的,只有子类对父类的函数进行重写。条件:函数名,参数,返回值必须相同(除了协变和构造函数)  virtual关键字在基类中的同名函数中必须出现  必须用父类的指针或引用

重定义(隐藏):两个函数必须分别在基类和派生类的作用域

条件:函数名相同       在继承体系中两个同名函数不构成诚谢就构成重定义  隐藏在子类中是默认隐藏父类的方法使用子类的方法。

4.抽象类

在虚函数的后面写上 =0 ,则这个函数为纯虚函数。 包含纯虚函数的类叫做抽象类(也叫接口
类),抽象类不能实例化出对象 。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生
类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
class Car {
public:
	virtual void Drive() = 0
	{
		cout << "Car" << endl;
	}
};
class Benz :public Car {
public:
	virtual void Drive() {}
};
int main()
{
	Car s;
	return 0;
}

我们可以看到将car类的Drive函数声明为纯虚函数,这个时候Car类是无法实例化对象的:

一个类中有纯虚函数这个类被称为抽象类,抽象类也会有子类去继承,但是如果子类没有重写纯虚函数的话这个子类也是抽象类,如果重写了纯虚函数那么这个子类就可以正常使用了:

 红括号括起来的就是子类重写了父类的纯虚函数,下面我们看看不重写的例子:

 确实如上面所说,子类不重写父类的纯虚函数那么子类也是抽象类。

5.多态的原理

再讲原理之前有一道常考的题,代码如下:

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

上面这个类的大小是多少呢?我们先给出结果:

结果为什么是8呢?因为Base类中有虚函数,而编译器看到虚函数会给Base类生成一个虚表指针,我们调试看一下:

 接下来我们看看派生类当中这个表都放了什么:

首先将刚刚的代码改造一下:

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

 我们发现派生类当中也有一个虚表指针,下面我们再用多态的代码来看一下多态的实现原理:

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 ps;
	Func(ps);
	Student st;
	Func(st);
	return 0;
}

 还是之前买票的例子我们调试看一下汇编:

 上面多态形成的汇编指令,我们再看看不是多态的:

 我们可以看到是多态时候的汇编指令和不是多态时候的汇编指令相差很多,不是多态的汇编就简单的两行,而多态有很多指令。

 上图中方框圈出来的就是父类中虚函数的地址和子类中虚函数的地址。

通过上面代码的演示,可以看出满足多态以后的系统调用,不是在编译时确定的,是运行起来以后到对象中找的,不满足多态的函数调用是编译时确定好的。 

下面我们再看看同一个类不同对象是否共用同一张虚表:

我们可以看到同一个类不同对象用的确实是同一张虚表。

通过以上验证我们得到结论:

1.派生类会拷贝基类的虚表

2.同一个类的不同对象用的同一张虚表

1. 派生类对象 d 中也有一个虚表指针, d 对象由两部分构成,一部分是父类继承下来的成员,虚
表指针也就是存在部分的另一部分是自己的成员。
2. 基类 b 对象和派生类 d 对象虚表是不一样的,这里我们发现 Func1 完成了重写,所以 d 的虚表
中存的是重写的 Derive::Func1 ,所以虚函数的重写也叫作覆盖 ,覆盖就是指虚表中虚函数
的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3. 另外 Func2 继承下来后是虚函数,所以放进了虚表, Func3 也继承下来了,但是不是虚函
数,所以不会放进虚表。
4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 nullptr
5. 总结一下派生类的虚表生成: a. 先将基类中的虚表内容拷贝一份到派生类虚表中 b. 如果派生
类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c. 派生类自己
新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后

接下来我们用程序打印虚表:

下面我们用这个代码进行演示:

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

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

 这里由于编译器的监视窗口故意隐藏了func3和func4两个函数,我们看不到虚表中这两个虚函数的地址,所以我们将地址打印出来。

打印虚表前先typedef一个函数指针:

typedef void(*VFPTR)();

 这里我们打印地址的思路是:以前我们测试大小端的时候将int的1类型强转为char类型拿到int的第一个字节,然后判断这个字节是否是1如果是就是小端,现在也一样。

void PrintVFTable(VFPTR table[])
{
	cout << " 虚表地址>" << table << endl;
	for (int i = 0; table[i] != nullptr; ++i)
	{
		printf(" 第%d个虚函数地址 :0X%x,->", i, table[i]);
		VFPTR f = table[i];
		f();
	}
	cout << endl;

}

 上面这个函数就是打印虚表的,为什么判断条件是table[i]!=nullptr呢?因为在VS系列的编译器中会在函数指针数组的最后放上一个nullptr。

 那么我们该如何传参呢?根据我们刚刚的思路,因为一个对象的起始4个字节或8个字节是其存放虚表指针的位置,所以在32位下将对象的地址强转为int*也就是4字节就拿到虚表指针(在32位下指针4字节,在64位下指针是8字节)然后解引用就是一个int类型,由于int类型传参传不过去,实际类型是函数指针类型,所以我们在强制转化为函数指针类型。如下图所示:

int main()
{
	Base b;
	Derive d;
	PrintVFTable((VFPTR*)(*(int*)&b));
	PrintVFTable((VFPTR*)(*(int*)&d));
	return 0;
}

 当然既然函数指针数组的首元素类型为VFPTR**,所以我们可以直接强转为VFPTR**再解引用

 通过以上对虚函数地址的打印我相信大家能更深刻的了解多态。

下面有几个面试容易考的知识点:

1.虚表是在什么阶段生成的?

答:虚表是在编译阶段生成的。

2.对象中虚表指针是在什么时候初始化的?

答:虚表指针是在基类的构造函数的初始化列表中初始化的。

3.虚表是存在哪里的?要回答这个问题我们先验证一下:

int main()
{
	Base b;
	Derive d;
	int x = 0;
	static int y = 0;
	int* z = new int;
	const char* p = "xxxxxxxxxxx";
	printf("栈对象:%p
", &x);
	printf("静态对象:%p
", &y);
	printf("堆对象:%p
", z);
	printf("常量区对象:%p
", p);
	printf("b对象虚表:%p
", *((int*)&b));
	printf("d对象虚表:%p
", *((int*)&d));
	return 0;
}

 从上图中我们可以看到b对象虚表和d对象虚表与常量区对象的地址挨的非常近,所以虚表是存在常量区(代码段)当中,而从之前查看地址的时候我们也能看到虚表的地址与函数的地址很接近,而函数的地址就存在代码段中。

6.多继承中的虚函数表

首先我们先写一个多继承的代码:

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

Derive继承base1和base2,并且只重写了func1,还多加了一个虚函数func3,这个时候我们有个问题,新增加的func3要放在哪里呢?首先我们要理解,derive继承了base1和base2所以应该有两种虚表,如下图:

 我们发现在这两种虚表中好像并没有看见func3,这是因为编译器将func3隐藏了,我们需要打印虚表来找func3到底存在哪里了。

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

打印第一张虚表的时候还好说直接在对象的最前面4个字节,那么第二张虚表到底在哪呢?因为我们先继承的base1,再继承的base2所以base1过来就是base2的虚表,所以我们直接跳一个base1的虚表就能找到base2,因为我们要从第一个位置开始偏移所以不能像之前那样强转为int*,要强转为char*指针的偏移量才是一个字节,否则如果偏移量是int*的4个字节就走多了就不是base2虚表的起始地址了。如下图:

运行程序后打印以下的结果:

 我们发现新增加的func3虚函数放在第一个虚表里面了,但是有一个问题为什么虚表中重写的func1的地址不一样呢?

 我们将主函数修改一下去调试验证为什么不一样:

int main()
{
	Derive d;
	Base1* ptr1 = &d;
	Base2* ptr2 = &d;
	ptr1->func1();
	ptr2->func1();
	return 0;
}

 首先这里满足多态,父类的指针或引用调用func1函数,func1函数是虚函数,下面我们调试看一下地址为什么不一样。

这是func1调用的汇编代码, 从上图中我们可以看到第一个函数的调用是正常的,找到func1的地址然后进去,下面我们再看看ptr2访问func1函数的结果:

 

 

 以上是ptr2调用func函数的整体结果,接下来我们把ptr1调用和ptr2调用func1函数做一个对比:

 我们发现ptr2调用func函数的时候对地址进行了封装,这就好像我们我们要从宁夏到北京,可以坐飞机直接到北京,但是我们先经过河南再从河南转到河北,再从河北到北京,来来回回绕了很多路,为什么要这样呢?我们在刚刚走调用过程的时候看到这样一条指令:sub ecx,ecx存的是谁的地址呢?其实ecx存的是this指针,我们再配合下面这张图理解一下:

因为这里是多态ptr1和ptr2都要调用子类的函数,而ptr1本来就指向这个Derive对象的开始所以调用子类的函数就恰好指向子类对象的开始所以ecx里面存的是this指针就是子类对象的开始,可以直接调用,而到ptr2的时候ecx就是ptr2的值,这个时候this指针指向对象中间的位置根本无法直接调用子类对象的函数,所以刚刚ptr2在跳的时候就是找到子类对象的开始。而这里谁指向子类对象的开始是谁先继承决定的,base1先继承所以base1的指针刚好指针子类对象的开始。

由于菱形继承,菱形虚拟继承太复杂容易出问题,所以菱形继承和菱形虚拟继承的虚表我们就不看了,我们只需要知道:虚表存储虚函数地址        虚基表存储偏移量

7.动态绑定和静态绑定

1. 静态绑定又称为前期绑定 ( 早绑定 ) 在程序编译期间确定了程序的行为 也称为静态多态
比如:函数重载
2. 动态绑定又称后期绑定 ( 晚绑定 ) ,是在程序运行期间,根据具体拿到的类型确定程序的具体
行为,调用具体的函数, 也称为动态多态

总结

1. 什么是多态?答:多态分为静态多态和动态多态,静态多态是在编译时期就确定的,比如函数重载。动态多态是在程序运行期间确定的称为动态多态,虚函数可以实现动态多态
2. 什么是重载、重写 ( 覆盖 ) 、重定义 ( 隐藏 ) ?答:重载是在编译时期产生的,函数名相同参数不同则构成函数重载,并且重载是静态时多态。重写(覆盖)是在运行时产生的,由虚函数引起的运行时多态,子类重写父类的函数通过父类指针或引用的方式调用不同形态的同名函数。重定义(隐藏)在继承体系中通常是子类的函数名与父类的函数名相同则在子类中隐藏了父类的方法默认使用子类的方法,如果想要使用父类的则需要域名限定符。
3. 多态的实现原理?答:静态的多态的实现原理是函数名的修饰规则,动态的多态是因为虚表中存放了虚函数的地址在运行的时候会去按条件找对应的方法。
4. inline 函数可以是虚函数吗?答:可以,因为inline对编译器来说只是一个建议,当没有成功变成内联函数时, 这个函数就不再是inline,因为虚函数要放到虚表中去。(真正的内联函数是将函数展开没有地址的,所以不能实现虚函数)
5. 静态成员可以是虚函数吗?答:不能,因为静态成员函数没有 this 指针,使用类型 :: 成员函数
的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
6. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表
阶段才初始化的。
7. 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?答:可以,并且最好把基类的析
构函数定义成虚函数。在子类开了空间的情况下让析构函数变成虚函数就可以成功释放子类的资源不造成内存泄漏
8. 对象访问普通函数快还是虚函数更快?答:首先如果是普通对象,是一样快的。如果是指针
对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函
数表中去查找。
9. 虚函数表是在什么阶段生成的,存在哪的?答:虚函数表是在编译阶段就生成的,一般情况
下存在代码段 ( 常量区 ) 的。
10. C++ 菱形继承的问题?虚继承的原理?答:菱形继承会引发数据冗余和二义性的问题,通过虚继承可以解决这个问题,但还是不建议用菱形继承。注意这里不要把虚函数表和虚基表搞混了,虚函数表是存放虚函数的地址,而虚基表是存放偏移量的
11. 什么是抽象类?抽象类的作用?答:类中有纯虚函数不能实例化出对象的类叫抽象类 。抽象类强制重写了虚函数,另外抽象类体现出了接口继承关系。
12.拷贝构造和赋值可以是虚函数吗?不可以!拷贝构造不能的原因和构造函数一样,而虚函数的作用是完成重写,赋值在定义中没有要求不能是虚函数,但是不建议将赋值定义为虚函数,因为赋值本来就是子类用父类的方法,如果赋值定义为虚函数就是子类调用子类的赋值就不是子类对父类的方法重写了。
风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。