【C++修行之路】面向对象三大特性之多态

前言

大家好久不见,今天我们来一起学习一下c++中的多态。

认识多态

通过之前继承的学习,我们知道以下场景下,父类中的BuyTicket()会被子类中的隐藏,如果想要调用父类的,可以通过显示调用实现。

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

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

那如果要让其实现这样的一个场景:大人是全价,小孩就半价,应该怎么实现呢?

void show2(Person* p)
{
    
    
	p->BuyTicket();
}
int main()
{
    
    
	show2(new Person);
	show2(new Student);
	return 0;
}

我们想要让这个函数实现,如果是父类对象就打印全价,如果是子类对象就打印半价,运行程序,得到如下结果:
在这里插入图片描述

显然这不是我们想要的效果,这是我们就要引入一个叫虚函数的概念了,在父类和子类函数成员前面都加上virtual关键字,这样使两个类的同名函数构成重写的关系

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

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

void show2(Person* p)
{
    
    
	p->BuyTicket();
}
int main()
{
    
    
	show2(new Person);
	show2(new Student);
	return 0;
}

运行程序,得到了我们想要的效果:
在这里插入图片描述
这样的实现被称为多态。

构成多态的必要条件

构成多态有两个必要条件:
一、必须通过基类的指针或者引用来调用虚函数
二、被调用的必须是虚函数,并且派生类要对基类的虚函数进行重写

注意以上两个条件缺一不可,少哪一个都无法构成多态。

虚函数的重写

上面提到要对虚函数进行重写(覆盖):派生类有一个跟基类完全相同的虚函数(返回值类型、函数名字、参数列表),称子类虚函数重写了基类的虚函数。

扫描二维码关注公众号,回复: 14851675 查看本文章

虚函数继承相当于继承了基类函数的声明,在派生类中重写的是基类的内容,请看以下场景:
在这里插入图片描述
上述例子很好的证明了这个结论。

虚函数重写的两个例外

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

二、析构函数重写
基类的析构函数为虚函数,派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,编译器会对析构函数的名字做特殊处理,编译后的析构函数名称统一处理为destructor。

这样我们就理解为什么析构函数要定义为虚函数了,假如有以下场景:

class Person {
    
    
public:
	~Person() {
    
     cout << "~Person()" << endl; }
};
class Student : public Person {
    
    
public:
	virtual ~Student() {
    
     cout << "~Student()" << endl; }
};
int main()
{
    
    
	Person* p1 = new Person;
	Person* p2 = new Student;
	delete p1;
	delete p2;
}

在调用delete函数释放p2指向的对象资源的时候,就构不成多态,这样才能保证p1、p2正确调用析构函数。

final和override

c++对函数重写的要求比较严格,有些情况下由于疏忽会导致类似字母次序写反而无法重载,并且编译阶段不会报错,只有在程序运行时未能得到预期结果才会报错,这样会大大降低程序开发效率,因此c++提供了override和final两个关键字。
一、
final修饰虚函数,被final修饰的虚函数不可以再被重写!
二、
override检测派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错!
演示如下:
在这里插入图片描述
在这里插入图片描述

重载、覆盖、隐藏

重载

  1. 两个函数在同一个作用域
  2. 函数名相同、参数不同

重写(覆盖)

  1. 两个函数分别在基类和派生类的作用域
  2. 函数名、参数、返回值必须相同(协变例外)
  3. 两个函数必须是虚函数

重定义(隐藏)

  1. 两个函数分别在基类和派生类的作用域
  2. 函数名相同
  3. 两个基类和派生类的同名函数若不重写就是隐藏

抽象类

在虚函数的后面写上 = 0 ,则这个函数为纯虚函数,包含纯虚函数的类叫做抽象类(接口类),抽象类不能实例出对象,必须重写纯虚函数,派生类才可以实例出对象,纯虚函数规范了派生类必须重写,体现出了接口继承,演示如下:
在这里插入图片描述
注意:纯虚函数可以有函数体!!!

多态的原理

单继承

先来看一个问题:在32位操作系统中运行以下代码:

//运行这段代码,会得到什么结果?
#include <iostream>
using namespace std;
class Person {
    
    
public:
	void func();
protected:
	int _a1;
};

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

结果是4,因为整形大小为4个字节,将其中的函数变成虚函数后,再计算大小

//
#include <iostream>
using namespace std;
class Person {
    
    
public:
	virtual void func();
protected:
	int _a1;
};

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

这次得到的结果是8,这是为什么呢?为什么多出来了4个字节呢?

打开监视窗口,我们会发现:
在这里插入图片描述

Person实例画的对象里多出来一个指针,这个指针指向了一个指针数组。
在这里插入图片描述
事实上,这个指针也叫虚函数表指针

多态的原理可以用这张图片来简单表示:在这里插入图片描述
通过反汇编,我们可以得知满足多态以后的函数调用,不是在编译时确定的,而是在运行后在对象中寻找的。不满足多态的函数调用在编译时就确认好了。
在这里插入图片描述

多继承

假如这个类继承了多个类的时候,是怎么完成多态的呢?我们一起来分析一下:

重写了基类的虚函数

下面程序是如何实现多态的呢?

class A
{
    
    
public:
	virtual void func1()
	{
    
    
		cout << " hello  A" << endl;
	}
};
class B
{
    
    
public:
	virtual void func1()
	{
    
    
		cout << " hello  B" << endl;
	}
};

class C : public A, public B
{
    
    
public:
	virtual void func1()
	{
    
    
		cout << " hello C " << endl;
	}
protected:
	int _a1 =1;
};

int main()
{
    
    
	C c;
	A* a = &c;
	B* b = &c;

	a->func1();
	b->func1();
	return 0;
}

我们打开监视窗口,C类中包含A、B两张虚表,但是明明在C类中重写了继承下来的方法,但两个方法的地址却不一样?
在这里插入图片描述
使用反汇编来看一下到底是为什么?
在这里插入图片描述

我们发现在上述实现多态的过程中,调用的函数应该一致,但jmp的两个地址却不同,通过反汇编我们发现b在调用的时候使用了sub命令,然后调用了func1函数,所以,这里为什么要调用sub命令呢?其实,sub ecx 本质上是修正this指针的过程,因为A先被继承,因此a指针天然就是指向对象的起始位置,但b指针指向对象的中间位置,因此要修正this指针。
在这里插入图片描述

没有重写基类的虚函数

我们知道,虚函数的函数指针要保存到一张叫虚函数表的地方,那么 派生类中没有重写父类虚函数的虚函数(新定义的虚函数)存在哪张表里呢?

我们可以通过打印虚表的方式来看一下,调试窗口有时不会显示(我也不知道为什么)

先来说一下如何打印虚表,当我们实例化对象后,在这个对象里有一个虚表指针,它指向一个函数指针数组,我们要做的就是把数组内容打印出来

将函数指针类型重定义为VFPTR,那么这个虚表指针就是一个VFPTR* 类型的指针了,我们只要在这个对象里拿出这个指针即可,这里提供一种扩展性较强的方案:

在这里插入图片描述

运行下面的测试程序

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

class A
{
    
    
public:
	virtual void func1()
	{
    
    
		cout << "hello A" << endl;
	}
};
class B
{
    
    
public:
	virtual void func1()
	{
    
    
		cout << "hello B" << endl;
	}
};

class C : public A, public B
{
    
    
public:
	virtual void func1()
	{
    
    
		cout << "hello C" << endl;
	}
	virtual void Cfunc()
	{
    
    
		cout << "C func" << endl;
	}
protected:
	int _a1 =1;
};

int main()
{
    
    

	C c;
	B* b = &c;
	printVFTable(*(VFPTR**)&c);
	printVFTable(*(VFPTR**)b);
	return 0;
}

在这里插入图片描述
可见,在派生类中新定义的虚函数其实是存在第一个继承对象的虚函数表里的。

菱形继承和菱形虚拟继承的虚表

太复杂了,占个位,以后有空再来补充,实际上非常不推荐设计出菱形继承。

补充

虚表是什么阶段生成的?
编译阶段

对象中虚表指针什么时候初始化?
构造函数的初始化列表

虚表存在哪里?

int main()
{
    
    
	
	int x = 0;
	static int y1 = 0;
	int* z1 = new int;
	const char* p1 = "xxxxxxxx";
	A a;
	static int y2 = 0;
	int* z2 = new int;
	const char* p2 = "xxxxxxxx";

	printf("栈对象:%d\n", &x);
	printf("静态区对象:%d\n", &y1);
	printf("静态区对象:%d\n", &y2);
	printf("堆对象:%d\n", &z1);
	printf("堆对象:%d\n", &z2);
	printf("常量区对象:%d\n", &p1);
	printf("常量区对象:%d\n", &p2);

	printf("虚表%d\n", *(VFPTR**)&a);

	return 0;
}

在这里插入图片描述
根据上述代码,再加上虚表是所有对象共用一份的数据,因此我们推测虚表应该在静态区。

补充·继承与多态相关问题

inline函数可以是虚函数吗?

可以,但是不要忘了inline只是给编译器一个建议,采不采纳这个建议取决于编译器,由于内联函数要在编译阶段就展开,不会进入符号表等,而虚函数地址要存放到虚表中,这个行为是在运行时进行的,所以编译器就会忽略掉inline属性。

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

虚函数的地址存放在虚表里,而static静态函数不被某一个对象拥有,而被一个类拥有,因此静态成员函数没有this指针,但虚表指针在每一个实例化的对象里,也就是说虚函数调用会使用this指针,所以说静态成员函数不会构成多态,也就不可以是虚函数。

构造函数、赋值重载可以是虚函数吗?

不可以,在对象创建时,编译器对虚表指针初始化,先让虚表指针指向父类的虚函数表,父类构造完后,子类的虚表指针再指向自己的虚函数表。

虚函数表是在构造函数的 初始化列表 初始化的!

构造时就调用虚函数,那么调用构造函数就要去找虚表指针,虚表指针在构造函数初始化列表才初始化,这是一个先有蛋还是先有鸡的问题

同样的道理,赋值重载可不可以设计为虚函数呢?
语法上是可以设置为虚函数的,但是极其不推荐这样做,我们回到虚函数的本质,我们设置虚函数本身就是为了重写,但对于赋值重载来讲,我们是想先赋值父类再赋值子类而不是多态的 只执行子类不执行父类。

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

可以,非常推荐将析构函数设计为虚函数,上面我们也提到过,编译器会把两个类的析构函数统一处理为同名函数destructor() 这样我们在释放子类指针的时候,会隐藏父类的析构函数,我们没有办法完全释放空间,演示如下:

class Base
{
    
    
public:
	~Base()
	{
    
    
		cout << "~Base" << endl;
	}
};

class Derive : public Base
{
    
    
public:
	~Derive()
	{
    
    
		cout << "~Derive" << endl;
	}
};

int main()
{
    
    
	Base* pd = new Derive;
	delete pd;
	return 0;
}

因为名字相同,默认构成了重写,基类的指针就会去调用基类的析构,这样是不符合预期的,因此我推荐将析构函数设计为虚函数,这样二者构成多态,此时就可以正确的释放空间了。

内存问题

运行下述代码报错:

class Base
{
    
    
public:
	void func1()
	{
    
    }
};

class Derive : public Base
{
    
    
public:
	virtual void func2(){
    
    }
};

int main()
{
    
    
	Base* ptr = new Derive;
	delete ptr;
	return 0;
}

其实原因就是在基类指针对子类切片的时候,父类没有虚表指针,但子类有虚表指针,这样会导致父类部分的前4/8个字节被非法访问了,所以在delete的时候会报错

结语

到这里,本篇文章就结束了,希望对你理解多态有所帮助,这篇文章花费了我大量时间,如果对你学习有所帮助,请给我一个三连+关注,我们下次再见。

猜你喜欢

转载自blog.csdn.net/m0_73209194/article/details/129934072
今日推荐