【C++】多态

目录

文章目录

前言

一、多态的概念

二、多态的定义及实现

三、重载/重写/隐藏的对比

四、纯虚函数和抽象类

五、多态的原理

总结



前言

本文主要讲述C++中的多态,涉及的概念有虚函数、协变、纯虚函数、抽象类、虚表指针和虚函数表等。


一、多态的概念

多态分为:

  1. 编译时多态(静态多态):主要就是我们前面讲的函数重载和函数模板,他们传不同类型的参数就可以调用不同的函数,通过参数不同达到多种形态,
  2. **运行时多态(动态多态):**运行时多态,具体点就是去完成某个行为(函数),可以传不同的对象就会完成不同的行为,就达到多种形态。比如买票这个行为,当普通人买票时,是全价买票;学生买票时,是优惠买票(5折或75折);军人买票时是优先买票。

我们把编译时一般归为静态,运行时归为动态。

本文主要讲解的就是动态多态


二、多态的定义及实现

1.定义

多态的构成条件:

  • 首先是继承
  • 多态是一个继承关系的下的类对象,去调用同一函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象优惠买票。

实现多态还有两个必须条件:

  1. 必须是基类的指针或者引用调用虚函数
  2. 被调用的函数必须是虚函数,并且完成了虚函数重写/覆盖。

说明:要实现多态效果,第⼀必须是基类的指针或引用,因为只有基类的指针或引用才能既指向基类对象又指向派生类对象;第二派生类必须对基类的虚函数完成重写/覆盖,重写或者覆盖了,基类和派生类之间才能有不同的函数,多态的不同形态效果才能达到。

虚函数:

  • 关键字:virtual
  • 类成员函数前面加 virtual 修饰,那么这个成员函数被称为虚函数。注意非成员函数不能加 virtual 修饰。(包括静态成员函数也不能加virtual)

虚函数演示:

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket()//虚函数
	{
		cout << "买票-全价" << endl;
	}
};

虚函数的重写(覆盖):

  • 派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称派生类的虚函数重写了基类的虚函数。(只有函数体不相同)
  • 注意:在重写基类虚函数时,派生类的虚函数在不加 virtual 关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用,不过在考试选择题中,经常会故意买这个坑,让你判断是否构成多态。(简单点说,派生类会直接把虚函数声明继承下来,重写时只改变函数体)

虚函数重写演示:

cpp 复制代码
class Person
{
public:
	virtual void BuyTicket()//虚函数
	{
		cout << "买票-全价" << endl;
	}
};

class Student : public Person//继承
{
public:
	virtual void BuyTicket()//虚函数重写
	{
		cout << "买票-打折" << endl;
	}
};

2.实现

定义多态演示1:买票

cpp 复制代码
#include <iostream>
using namespace std;

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

class Student : public Person//继承
{
public:
	virtual void BuyTicket()//虚函数重写
	{
		cout << "买票-打折" << endl;
	}
};

int main()
{
	Person p1;
	Student s1;

	Person* ptr1 = &p1;//必须是基类的指针或者引用
	ptr1->BuyTicket();//被调用的函数必须是虚函数

	Person& ptr2 = s1;//必须是基类的指针或者引用
	ptr2.BuyTicket();//被调用的函数必须是虚函数

	return 0;
}

运行结果:


演示2:动物叫

cpp 复制代码
#include <iostream>
using namespace std;

class Animal
{
public:
	virtual void talk() const//虚函数
	{}
};

class Dog : public Animal//继承
{
public:
	virtual void talk() const//虚函数重写
	{
		cout << "汪汪" << endl;
	}
};

class Cat : public Animal//继承
{
public:
	virtual void talk() const//虚函数重写
	{
		cout << "(>^ω^<)喵" << endl;
			
	}
};

void LetsHear(const Animal& a)//基类指针或者引用调用
{
	a.talk();
}

int main()
{
	Dog g;
	Cat c;

	LetsHear(g);
	LetsHear(c);

	return 0;
}

运行结果:


3.多态场景的一个选择题:

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

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

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

  • 首先,初学时这道题很难选对。主函数中,派生类指针 p 调用 test 函数。
  • test 函数是在父类中,这里需要注意,此时 test 的 this 指针是 A类 而不是 B类,我们虽然形象上说继承是把父类的成员拿到子类中,但其实不是真正的拿,而是当编译器调用父类成员时,如果在子类找不到就会去父类中找。
  • 我们知道 test 函数中 this 指针是 A类 的类型时,test 中就使用父类指针调用 func 函数,此时构成多态,因为 func 是虚函数,并且在A类中完成了重写,这里需要注意的是A类中 func 函数虽然没有加 virtual,但还是重写,只要父类加了 virtual 就行。
  • 多态构成条件:1.父类指针或引用调用虚函数(满足),2.虚函数完成重写(满足)。所以 test 中调用的 func 是B类中的 func。此时你可能以为正确答案是 D。
  • 正确答案是:B,因为还有一个点需要注意,上文已经说过,虚函数重写只需要函数体不同即可,这是因为在子类中,是直接将父类的虚函数声明拿过来,所以在子类中缺省值其实是 1 而不是 0。

运行结果验证:

  • 这题其实是一个警示,告诫我们重写虚函数时要保持缺省参数一致,并且子类虚函数最好也要带上 virtual 关键字。

4.协变

  • 派生类重写基类虚函数时,与基类虚函数返回值类型可以不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。协变的实际意义并不大,所以我们了解一下即可。
  • 简单点说:只要父类虚函数的返回值类型与子类虚函数返回值类型构成父子关系(继承关系),也构成虚函数重写。
  • 也就是说,虚函数重写不一定非要返回值相同,因为有协变这种特殊情况。

协变演示:

cpp 复制代码
#include <iostream>
using namespace std;

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

class Person
{
public:
	virtual A* BuyTicket()//虚函数
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual B* BuyTicket()//协变
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person p1;
	Student s1;

	Func(&p1);
	Func(&s1);

	return 0;
}

运行结果:

  • 父类虚函数与子类虚函数返回值 A* 和 B* ,A类和B类构成父子关系(继承关系),这就是协变,构成虚函数重写,也就支持多态。
  • 当然,返回值是当前父子类也是一样的。

演示:

cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public:
	virtual Person* BuyTicket()//虚函数
	{
		cout << "买票-全价" << endl;
		return nullptr;
	}
};

class Student : public Person
{
public:
	virtual Student* BuyTicket()//协变
	{
		cout << "买票-打折" << endl;
		return nullptr;
	}
};

void Func(Person* ptr)
{
	ptr->BuyTicket();
}

int main()
{
	Person p1;
	Student s1;

	Func(&p1);
	Func(&s1);

	return 0;
}

运行结果:

总结,协变只要虚函数返回值构成父子关系即可。


5.析构函数的重写

  • 基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。
  • 虽然基类与派生类析构函数名字不同看起来不符合重写的规则,实际上编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor,所以基类的析构函数加了 vialtual修饰,派生类的析构函数就构成重写。

为什么基类的析构函数建议设计成虚函数?

我们可以通过下面一个例子来说明:

cpp 复制代码
#include <iostream>
using namespace std;

class A
{
public:
	virtual ~A()//写成虚函数
	{
		cout << "~A()" << endl;
	}
};

class B : public A
{
public:
	~B()//无论加不加virtual都构成虚函数重写
	{
		delete[] _p;
		cout << "~B()" << endl;
	}

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

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

	delete p1;
	//假如不构成多态,p2就调用不到B类的析构导致内存泄漏
	delete p2;

	return 0;
}

运行结果:

  • 第一个析构是 p1 调用A类的析构,第二、三个析构是p2调用B类的析构,因为继承所以最后还会析构父类。
  • 这里析构形成多态就不怕因为调用不到 B 类的析构而导致内存泄漏等问题了。

从反汇编处就能看到A、B类的析构函数名字都被处理成一样的了,所以满足虚函数重写条件之一的函数名相同:


6.override和final关键字

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

override示例:

cpp 复制代码
#include <iostream>
using namespace std;

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

class Student : public Person
{
public:
	//加上override可以判断是否完成重写,比如这里故意写成函数名
	virtual void BuyTicke() override
	{
		cout << "买票-打折" << endl;
	}
};

int main()
{
	Person p1;
	Student s1;

	Person* ptr1 = &p1;
	ptr1->BuyTicket();

	Person& ptr2 = s1;
	ptr2.BuyTicket();

	return 0;
}

报错信息:

final示例:

cpp 复制代码
#include <iostream>
using namespace std;

class Person
{
public:
	virtual void BuyTicket() final//不能重写
	{
		cout << "买票-全价" << endl;
	}
};

class Student : public Person
{
public:
	virtual void BuyTicket() override
	{
		cout << "买票-打折" << endl;
	}
};

int main()
{
	Person p1;
	Student s1;

	Person* ptr1 = &p1;
	ptr1->BuyTicket();

	Person& ptr2 = s1;
	ptr2.BuyTicket();

	return 0;
}

报错信息:


三、重载/重写/隐藏的对比


四、纯虚函数和抽象类

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

**演示:**纯虚函数和抽象类

cpp 复制代码
#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 << "Bwm-操控" << endl;
	}
};

int main()
{
	Car* p1 = new Benz;
	p1->Drive();

	Car* p2 = new Bmw;
	p2->Drive();

	return 0;
}

运行结果:

  • 简单说,抽象类就是专门用来继承的类,它可以表示某一个抽象概念,抽象的东西是不能实例化的。
  • 比如上面抽象类 Car(车),因为不是什么具体的车所以不实例化,当其子类重写纯虚函数后就可以表示 Benz(奔驰) 或者 Bmw(宝马),这样就可以实例化具体的车了。

五、多态的原理

1.虚函数表指针

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

A.编译报错 B.运行报错 C.8 D.12

cpp 复制代码
#include <iostream>
using namespace std;

class Base
{
public:
	virtual void func1()
	{
		cout << "func1()" << endl;
	}
protected:
	int _a = 1;
	char _ch = 'x';
};

int main()
{
	Base b;
	cout << sizeof(b) << endl;//32位

	return 0;
}
  • 首先,我们知道关于一个类对象的大小,只计算它的成员变量,函数是储存在静态区的
  • 所以表面上我们只计算 _a 和 _ch 的大小,一个占4字节,一个占1字节,按照内存对齐结果应该是占 8 字节。但是...
  • 实际结果是选 D,12字节,因为这里存在一个虚函数表指针,指针在32位下占4字节,按照内存对齐结果就是12字节。

运行结果:

我们可以通过监视窗口看到虚函数表指针:

  • 这里多了一个变量 _vfptr 就是虚函数表指针,也可以叫虚表指针,它是一个指针数组,专门存储虚函数地址的。
  • 那么C++实现多态的原理就是全靠这个 虚函数表 了。
  • 注意:只有定义了虚函数或者继承了父类的虚函数,才有这个虚函数表指针变量。

2.多态原理

  • 父类定义了虚函数,那么虚函数表是父类子类都有的,对于子类的虚函数表,如果重写了父类的虚函数,那么子类对应虚函数的地址就不一样,这样当父类指针调用子类或者父类时,就会根据虚函数表中不同的地址调用不同的函数,以此就形成了多态。
  • 所谓动态多态:就是程序在运行时根据虚函数表中的函数地址调用不同函数来实现的。

3.动态绑定与静态绑定

  • 对不满足多态条件(指针或者引用+调用虚函数)的函数调用是在编译时绑定,也就是编译时确定调用函数的地址,叫做静态绑定。
  • 满足多态条件的函数调用是在运行时绑定,也就是在运行时到指向对象的虚函数表中找到调用函数的地址,也就做动态绑定。

反汇编可以看出区别:

动态绑定:先存在寄存器中,然后去寄存器中call对应函数地址

静态绑定:函数地址在编译时就直接确认了的


4.虚函数表

关于虚函数表还有一些需要补充的:

  • 1.基类对象的虚函数表中存放基类所有虚函数的地址。同类型的对象共用同一张虚表,不同类型的对象各自有独立的虚表,所以基类和派生类有各自独立的虚表。
  • 2.派生类由两部分构成,继承下来的基类和自己的成员,一般情况下,继承下来的基类中有虚函数表指针,自己就不会再生成虚函数表指针。但是要注意的这里继承下来的基类部分虚函数表指针和基类对象的虚函数表指针不是同⼀个,就像基类对象的成员和派生类对象中的基类对象成员也独立的。
  • 3.派生类中重写的基类的虚函数,派生类的虚函数表中对应的虚函数就会被覆盖成派生类重写的虚函数地址。
  • 4.派生类的虚函数表中包含,(1)基类的虚函数地址,(2)派生类重写的虚函数地址完成覆盖,派生类自己的虚函数地址三个部分。
  • 5.虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个0x00000000标记。(这个C++并没有进行规定,各个编译器自行定义的,vs系列编译器会在后面放个0x00000000 标记,g++系列编译不会放)

演示:

cpp 复制代码
#include <iostream>
#include <string>
using namespace std;

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

class Student : public Person
{
public:
	virtual void BuyTicket()
	{
		cout << "买票-打折" << endl;
	}
	virtual void Func2()
	{
		cout << "Func2()" << endl;
	}
	void Func3()
	{
		cout << "Func3()" << endl;
	}
protected:
	int _id;
};

int main()
{
	Person p1;
	Student s1;

	return 0;
}

代码说明:

  • 这里定义了两个类,基类是Person,Person中定义了两个虚函数:BuyTicket和Func1。
  • 派生类 Student 中有三个函数,虚函数 BuyTicket 是重写的,虚函数 Func2 是自己新增的,另外还有一个普通的函数 Func3。

监视窗口:

  • 首先,红色的线地址不一样,这与上面第一、二条对应,父类与子类的虚表地址是不一样的,如果有多个子类,子类的虚表地址是一样的(这里没有演示可以自行验证)
  • 绿色的线地址不一样,这与上面第三条对应,子类重写了父类虚函数,那么子类中该虚函数地址是不一样的。
  • 蓝色的线地址相同,因为这个虚函数是直接从父类继承的,没有重写,所以地址一致。
  • 这里还有一个问题,就是子类对象 s1 中还有一个虚函数 Func2 和一个普通函数 Func3,Func3 肯定不在虚表中,可是为什么 Func2 也不在呢?其实这是VS出于某种原因故意不显示的,但是我们根据上面第5条,可以在内存窗口看到 Func2 的地址

内存窗口:

  • 我们在内存窗口输入s1的虚表地址就能看到:
  • 红色的线对应的就是 BuyTicket 虚函数,绿色对应的就是 Func1 虚函数,(注意:因为是小端存储,所以地址是倒序),根据上面第5条虚函数表末尾会以 0x00000000 标记,我们可以大胆猜测画蓝色线的地址就是 Func2。

最后补充两条:

  • 6.虚函数存在哪的?虚函数和普通函数一样的,编译好后是一段指令,都是存在代码段的,只是虚函数的地址又存到了虚表中。
  • 7.虚函数表存在哪的?这个问题严格说并没有标准答案C++标准并没有规定,vs下是存在代码段(常量区)。

总结

以上就是本文的全部内容了,感谢你的支持!

相关推荐
my_realmy14 分钟前
Java 之「单调栈」:从入门到实战
java·大数据·开发语言·ide·python
重生之成了二本看我逆天改命走向巅峰15 分钟前
Spring IOC 详解:基于 XML 配置与注解的依赖注入
xml·java·开发语言·笔记·后端·spring
小爬虫程序猿18 分钟前
Java爬虫需要设置哪些请求头?
java·开发语言·爬虫
weixin_3077791326 分钟前
优化Apache Spark性能之JVM参数配置指南
大数据·开发语言·jvm·性能优化·spark
悄悄敲敲敲30 分钟前
C++:背包问题习题
开发语言·c++·算法·dp
“抚琴”的人1 小时前
【C#高级编程】—表达式树详解
开发语言·c#·表达式·表达式树
卷卷的小趴菜学编程1 小时前
c++进阶之------红黑树
运维·c语言·开发语言·c++·vscode·红黑树·avl树
xcyxiner1 小时前
snmp v1 get请求优化---调试net-snmp
c++
Suckerbin1 小时前
第八章-PHP数组
开发语言·php
Chandler241 小时前
从零开始实现 C++ TinyWebServer 构建响应 HttpResponse类详解
linux·开发语言·c++·后端