C++——多态

本文详细探讨了多态的实现条件、虚函数的概念与使用、重写规则,以及虚函数表的构成。重点讲解了final与override关键字的作用,以及动态绑定与静态绑定的区别。实例演示了派生类未重写虚函数的情况和虚表的观察方法。
摘要由CSDN通过智能技术生成

概念

  • 现实中:不同的人去干同一件事产生了不同的结果,例如:成人买票全价,学生买票半价等;
  • 代码中:不同的对象去执行同一函数从而产生了不同的结果,这就是多态;

实现

构成多态的前提条件
  • 条件:继承关系体系中,在基类中拥有虚函数,在派生类中重写基类中的虚函数,当使用基类的指针、引用去调用被重写的虚函数,则就会引发多态;
  • 行为:系统会根据赋值给基类指针或者基类引用的对象来调用该对象中重写的虚函数;
    • 注意:在类的构造函数或者析构函数中调用虚函数会执行当前类中的虚函数,与其他无关;
  • 虚函数:在继承章节中,使用virtual可以形成虚继承,而虚函数也是使用这个关键字来构成;
virtual 函数返回类型 函数名(参数){//函数体}
  • 重写:在派生类中对基类的虚函数只进行函数体部分的修改,其他部分与基类中的虚函数一模一样,通俗来说就是派生类中重写的虚函数的函数名、参数列表、返回值类型和基类中对应的虚函数完全一致即就是重写;
    • 注意:重写之后的函数与原函数接口一模一样,所以重写只关注函数体怎么实现的,并不关注参数什么的,所以虚函数参数的缺省值是由基类虚函数中的缺省值来确定的,与重写的参数缺省值无关;
  • 基类的指针或引用:必须使用基类的指针或者引用来调用被重写的虚函数才会触发多态,会根据赋值给基类指针 / 引用的对象来调用该对象自身的虚函数;
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;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}
例外情况
  • 省略virtual关键字:我们在派生类中重写基类的虚函数时,我们可以不用在虚函数前加virtual,这样也可以构成重写,因为继承后基类的虚函数被继承下来了,在派生类依旧保持虚函数属性,但是我们强烈建议不这样写,这种写法不规范,且可读性不高;
  • 协变:基类与派生类虚函数返回值类型可以不同,但是有前提条件:虽然返回值类型不同,但是这两个返回值类型必须是具有继承关系的指针或者引用;
class A{};
class B : public A {};
class Person {
public:
	virtual A* f() {return new A;}
};
class Student : public Person {
public:
	virtual B* f() {return new B;}
};
  • 析构函数重写:基类与派生类虚函数函数名可以不同,但仅限于析构函数,当我们将基类中的析构函数定义为虚函数时,则此时派生类的析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然函数名不相同,看起来违背了重写的规则,但其实不然,这里可以理解为编译器在底层对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor;
    这有什么用处呢?主要是因为我们经常会将派生类指针赋值给基类指针,如果这个派生类指针所指向的派生类对象是动态开辟的,且内部含有资源,那么在没有将基类的析构函数定义为虚函数的前提下就销毁该指针,则不会调用派生类的析构函数去释放资源,造成内存泄露,而当我们将基类的析构函数定义为虚函数时,此时再销毁该指针时,就会触发多态机制,调用派生类的析构函数来释放资源,保证了安全;
class Person {
public:
	virtual ~Person() {cout << "~Person()" << endl;}
};
class Student : public Person {
public:
	virtual ~Student() { cout << "~Student()" << endl; }
protected:
	char* ptr = new char[100];
};
// 只有派生类Student的析构函数重写了Person的析构函数时,下面的delete对象调用析构函数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main(){
	Person* p1 = new Person;
	Person* p2 = new Student;
	delete p1;
	delete p2;
	return 0;
}
finaloverride
  • final:最终形态,不可被继承、重写了;
    • 写在类名后面,表明此类不可再被继承;
    • 写在基类的虚函数后面,表明该虚函数不可被派生类重写;
//不可继承该类
class a{};
class b final : public a{};
//这样是不被允许的,不能进行继承
//class c : public b{};

//不可重写虚函数
class Car{
public:
	//表明该虚函数不想被派生类重写
	virtual void Drive() final {}
};
class Benz :public Car{
public:
	//该虚函数不能被重写
	//virtual void Drive() {cout << "Benz-舒适" << endl;}
};
  • override:虚函数重写检查,将该关键字写在派生类的虚函数后面,如果在派生类中没有重写该虚函数,那么编译器则会报错;
class Car{
public:
	virtual void Drive(){}
};
class Benz :public Car {
public:
	//该函数必须被重写
	virtual void Drive() override {cout << "Benz-舒适" << endl;}
};
重载、重写、重定义
  • 重载:两个函数在同一作用域下,它们的函数名相同、参数列表不同;
  • 重写:两个函数都为虚函数,且分别在基类和派生类两个作用域下,它们的函数名、返回值类型、参数列表都相同;
  • 重定义(隐藏):两个函数分别在基类和派生类两个作用域下,且只需要函数名相同就算(在不构成重写的情况下);

抽象类

概念
  • 纯虚函数:在虚函数的后面不写花括号,而是直接让其= 0,则这个函数为纯虚函数;
  • 抽象类:包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象,当派生类继承抽象类后也算是一个抽象类,不能实例化对象,只有当派生类重写完全部的纯虚函数(只要有一个纯虚函数没重写,则仍算抽象类),派生类才不算是抽象类,才能实例化出对象,纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承的思想;
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;
	}
};
void Test(){
	//多态情形,不同的人执行相同的事,产生不同结果
	Car* pBenz = new Benz;
	pBenz->Drive();
	Car* pBMW = new BMW;
	pBMW->Drive();
}
接口继承和实现继承
  • 实现继承:普通函数的继承是一种实现继承,派生类继承了基类所实现的函数,可以直接使用该函数,继承的是函数的实现;
  • 接口继承:虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口,所以如果不是为了实现多态,就不要把函数定义成虚函数;

多态原理

虚函数表
  • 我们之前说过类中如果没有成员变量只有成员函数时,它的大小就是 1 字节,如果有成员变量那就是成员变量的大小,现在我们使用sizeof()来计算下面代码中 Base 类的大小,但是得到的结果并不是一个整型变量的大小——4 字节,而是 8 字节,这是为什么呢?
    这是因为除了 _b 成员外,还多了一个 __vfptr 指针放在对象的前面(有些平台可能会放到对象的最后面,这个跟平台有关),对象中的这个指针我们叫做虚函数表指针(v 代表 virtual,f 代表 function),一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表简称虚表;
class Base{
public:
	virtual void Func1(){
		cout << "Func1()" << endl;
	}
	virtual void Func2(){
		cout << "Func2()" << endl;
	}
private:
	int _b = 1;
};
虚表剖析
  • 针对上面的代码进行如下的改造:
    1. 我们增加一个派生类 Derive 去继承基类 Base;
    2. Derive 类中重写 Func1 函数,并且增加普通函数 Func4;
    3. Base 类中再增加一个虚函数 Func2 和一个普通函数 Func3;
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;
	}
	void Func4(){
		cout << "Derive::Func4()" << endl;
	}
private:
	int _d = 2;
};
int main(){
	Base b;
	Derive d;
	return 0;
}

在这里插入图片描述

  1. 派生类对象 d 由两部分构成,一部分是从基类继承下来的,这其中包含了基类的虚表指针、基类的成员变量,另一部分是自己的,这其中只包含了自己的成员变量,至于自己的虚表指针则是没有的(自身没有虚表),自身的虚函数是存在于第一个继承的父类的虚函数表中(后面会细讲);
  2. 虽然派生类可以继承基类的虚表,但是基类对象和派生类对象的虚表是不一样的,这是因为在派生类中完成了对 Func1 函数的重写,所以派生类的虚表中存的是重写后的 Func1 函数指针,因此虚函数的重写也叫作覆盖,用重写之后的虚函数覆盖继承下来的虚表中对应的虚函数;
  3. 另外 Func2 是虚函数,所以继承下来后仍然放在虚表中,Func3 不是虚函数,所以继承下来了但不会放进虚表;
  4. 虚函数表本质是一个存储虚函数指针的指针数组,这个数组最后面放了一个 nullptr,用来代表该表的结束符;
  5. 总结一下派生类的虚表生成:
    1. 先将基类中的虚表内容拷贝一份到派生类虚表中;
    2. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数;
    3. 派生类自己新增加的虚函数按其在派生类中的声明次序添加到第一个继承的基类的虚表的最后;
  6. 这里还有一个小伙伴们很容易混淆的问题:虚函数存在哪的?虚表存在哪的?
    • 虚表中存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是将它们的指针存到了虚表中,方便进行多态而已;
    • 虚表不是存在于对象中,而是虚表指针存在于对象中,对象中存的是指向虚表空间首地址的指针,那么虚表存在哪的呢?在 vs 下虚表是存在于代码段的;
多态原理
  • 说明:在 vs 下,不管是单继承还是多继承,基类的成员在子类中都是按基类的继承顺序进行模块排列的,一般是虚函数指针(有虚函数则存在)、虚基表指针(有菱形继承则存在)、成员变量这样的顺序:
    在这里插入图片描述
  • 还记得一开始的买票代码吗?我们用它来进行一个详细说明:
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;
	Student st;
	Func(ps);
	Func(st);
	return 0;
}
  • 图文解释:在用基类指针或引用 p 来接收传入的参数后,再用 p 来调用派生类中重写的基类虚函数,此时就会根据传入的对象来决定到底调用哪个对象的虚函数;
    在这里插入图片描述
  • 汇编解释:将上面代码中重要部分的汇编源码拿出来如下:
//这是存在多态行为汇编源码:这里使用的是基类得引用
void Func(Person& p){
	p.BuyTicket();
}
// p中存的是基类对象的指针,将p移动到eax中
001940DE mov eax,dword ptr [p]
// [eax]就是取eax值指向的内容,这里相当于把基类对象头4个字节(虚表指针)移动到了edx
001940E1 mov edx,dword ptr [eax]
// [edx]就是取edx值指向的内容,这里相当于把虚表中的头4字节存的虚函数指针移动到了eax
00B823EE mov eax,dword ptr [edx]
// call eax中存虚函数的指针。这里可以看出满足多态的调用,不是在编译时确定的,是运行起来以后到对象的中取找的
001940EA call eax

***************************************************************************

//这是不存在多态行为的汇编源码:这里使用的是基类的对象
void Func(Person p){
	p.BuyTicket();
}
// 首先BuyTicket虽然是虚函数,但是p是对象而非指针或引用,不满足多态的条件,所以这里是普通函数的调用转换成地址时,是在编译时已经从符号表确认了函数的地址,直接call地址
00195182 lea ecx,[mike]
00195185 call Person::BuyTicket (01914F6h)
动态绑定与静态绑定
  1. 静态绑定又称前期绑定(早绑定):在程序编译期间确定了程序的行为,也称为静态多态,比如:函数重载、模板等;
  2. 动态绑定又称后期绑定(晚绑定):是在程序运行期间,根据具体拿到的类型确定程序的具体行为,调用具体的函数,也称为动态多态;

派生类未重写的虚函数

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() {
	Derive d;
	return 0;
}
  • 对于上面的代码我们创建一个 Derive 对象 d,当我们在观察窗口看 d 对象时,就会发现在继承的虚表中只能看到从基类继承的虚函数 func1、func2,而看不到自身的虚函数 func3、func4,这是因为编译器的监视窗口故意隐藏了这两个函数,也可以认为是他的一个小 bug,那么该如何查看派生类自身的虚函数呢?
    在这里插入图片描述
  • 我们可以通过取出虚基表的内容从而查看到那些隐藏的虚函数,不过在此之前我们需要知道以下四点:
    1. 派生类自身的虚函数会存放在第一个继承的基类的虚表中;
    2. 在 vs 下,第一个继承的基类的虚表指针在整个对象的最开始四个字节;
    3. 虚表以 nullptr 作为结束标记;
    4. 虚表中存放的是函数指针,虚表指针是存放函数指针那片空间的首地址,所以可以通过下面的方式拿到虚表指针;
//定义函数指针,这个根据虚表中存放的函数指针类型来确定,我上面存放的都是无返回值、无参的函数
typedef void(*vfptr)();
//取派生类对象的前四个字节内容
Derive d;
vfptr* p = (vfptr*)(*(int*)&d);
  • 代码实现取出虚表中的所有虚函数:
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* p = (vfptr*)(*(int*)&d);
	PrintVTable(p);
	return 0;
}

在这里插入图片描述

  • 我们一直提到未重写的虚函数存储在第一个继承的基类的虚表中,下面就来验证一下吧,上面的代码是单继承,所以看不出来什么,下面我们进行多继承就能明白了:
    • 前提:我们前面也介绍到了,多继承是按照继承顺序进行模块排列的,所以派生类的第一个虚表指针就是对象的前四个字节,而第二个虚表指针的位置就是从对象起始位置偏移第一个继承的基类的大小,后续以此类推;
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 func3() { cout << "Base2::func3" << endl; }
	virtual void func4() { cout << "Base2::func4" << 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; }
	virtual void func5() { cout << "Derive::func5" << endl; }
private:
	int d;
};
typedef void(*vfptr) ();
void PrintVTable(vfptr* vTable) {
	// 依次取虚表中的虚函数指针打印并调用。调用就可以看出存的是哪个函数
	cout << "虚表地址>" << vTable << endl;
	while (*vTable != nullptr) {
		(*vTable)();
		vTable++;
	}
	cout << endl;
}
int main() {
	Derive d;
	vfptr* p1 = (vfptr*)(*(int*)&d);
	PrintVTable(p1);
	vfptr* p2 = (vfptr*)(*(int*)((char*)&d + sizeof(Base1)));
	PrintVTable(p2);
	return 0;
}

在这里插入图片描述

零碎

  1. 对象访问普通函数快还是虚函数更快?答:如果是普通对象进行访问,则是一样快的,如果是指针对象或者是引用对象进行访问,则是调用普通函数更快,因为此时调用虚函数会构成多态,运行时需要到虚函数表中去查找;
  2. inline函数可以是虚函数吗?答:不能,因为inline函数没有地址,无法把地址放到虚函数表中;
  3. 静态成员函数可以是虚函数吗?答:不能,因为静态成员函数没有this指针,因此使用类名/对象::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表;
  4. 构造函数可以是虚函数吗?答:不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的;
  5. 前面继承章节中提到的一个问题:放虚基表指针的位置为什么不直接放会被重复继承的公共变量?答:因为每一个继承的基类都是按照模块排列在派生类内部,而每一个虚基表指针并不是一定在当前模块的起始位置,有时候也会放置的靠后一些,所以如下图解释:
    在这里插入图片描述
  6. 菱形继承与菱形继承中虚函数都是强烈不建议写出来的,因为一方面太复杂容易出问题,另一方面这样的模型,访问基类成员有一定的性能损耗;
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值