首页 > 编程语言 >C++ 多态

C++ 多态

时间:2024-07-12 14:55:21浏览次数:18  
标签:虚表 函数 派生类 多态 C++ 基类 重写 指针

1. 多态的概念

多态的概念:通俗来说,就是多种形态, 具体点就是去完成某个行为,当不同的对象去完成时会 产生出不同的状态。 比如 买票这个行为 ,当 普通人 买票时,是全价买票; 学生 买票时,是半价买票; 军人 买票时是优先买票。

2. 多态的定义及实现

2.1 虚函数

虚函数:即被 virtual 修饰的类成员函数称为虚函数。
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};

2.2虚函数的重写

虚函数的重写 ( 覆盖 ) : 派生类中有一个跟基类完全相同的虚函数 ( 即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同 ) ,称子类的虚函数重写了基类的虚函数。
class Person {
public:
	virtual void BuyTicket() { cout << "买票-全价" << endl; }
};
class Student : public Person {
public:
	virtual void BuyTicket() { cout << "买票-半价" << endl; }
	/*注意:在重写基类虚函数时,派生类的虚函数在不加virtual关键字时,也可以构成重写*/
	/*void BuyTicket() { cout << "买票-半价" << endl; }*/
};
void Func(Person& p)
{
	p.BuyTicket();
}
int main()
{
	Person ps;
	Student st;
	Func(ps);//买票-全价
	Func(st);//买票-半价
	return 0;
}

虚函数重写后,此时调用func函数,就形成多态了。

接口继承

在C++中,接口继承是指一个类继承另一个类的接口部分,即只继承虚函数而不继承函数体部分。这样做的目的是为了在派生类中重写虚函数,以实现特定的功能。

class person
{
public:
	virtual void func(int a = 5)
	{
		cout << "被person调用 a = " << a << endl;
	}
};

class student : public person
{
public:
	virtual void func(int a = 10)
	{
		cout << "被student调用 a = " << a << endl;
	}
};
student s;

person& rp = s;
student& rs = s;

rp.func();
rs.func();

输出结果:

被student调用 a = 5
被student调用 a = 10


以上代码中,两个虚函数func构成重写,但是person中的func,参数a的默认值为5;student中的func,参数a的默认值为10。奇怪的事情发生了:我们确实使用student对象调用了函数func,所以两次调用都显示了被student调用,说明调用了student中的函数。但是为什么通过person&调用的函数,a的值是5?

这就涉及到了接口继承。当两个函数构成虚函数时,并且通过 基类的引用/指针 调用函数,此时根据多态,会调用到派生类对应的函数,同时会发生接口继承。

上面的virtual void func(int a = 5)会被继承给派生类,把下面的virtual void func(int a = 10)替换掉,所以最后虽然我们最后通过多态调用到了正确的函数,但是由于接口继承,我们的接口依然是基类的,所以a = 5。但是如果我们直接通过,student&来调用student的函数,此时就是自己调用自己的函数,没有发生多态,所以没有发生接口继承,最后a = 10。

虚函数重写的两个例外
1. 协变 ( 基类与派生类虚函数返回值类型不同 ) 派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指 针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
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; }
};
2. 析构函数的重写 ( 基类与派生类析构函数的名字不同 ) 如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处 理,编译后析构函数的名称统一处理成destructor 。
class Person {
public:
	virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
	virtual ~Student() { cout << "~Student()" << endl; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函
//数,才能构成多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
	Person* p1 = new Person;
	Person* p2 = new Student;
	delete p1;
	delete p2;
	//~Person()
	//~Student()
	//~Person()
	return 0;
}

2.3多态的构成条件

多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如 Student 继承了 Person。 Person 对象买票全价, Student 对象买票半价。 那么在继承中要 构成多态还有两个条件
1. 必须通过基类的指针或者引用调用虚函数 2. 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写

多态的结果:

多态会根据 指针/引用 指向的对象的类型来调用对应的函数,而不是根据 指针/引用 本身的类型

2.4 C++11 override final

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

2.5 重载、重写(覆盖)、重定义(隐藏)的对比

函数重载:

功能:当函数传入不同类型的参数时,执行不同的效果
要求:

1.重载的函数要在同一个作用域
2.函数名相同
3.参数列表不同


函数重写:

功能:派生类的虚函数将基类的虚函数重写,以达成多态
要求:

1. 两个函数分别处于基类与派生类
2. 函数名相同
3. 参数列表相同
4. 返回值相同
5. 两个函数都是虚函数


函数重定义:

功能:派生类的同名函数屏蔽了基类的同名函数的直接访问
要求:

1. 两个函数分别处于基类与派生类
2. 函数名相同(当函数名相同,只要不构成重写,那就是重定义)

3. 抽象类

3.1 概念 在虚函数的后面写上 =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();
}

4.多态的原理

虚函数重写,是基于虚函数表的。虚函数表是一个用于存储虚函数指针的数组,其用于存储一个类中所有的虚函数指针,简称虚表。

对于一般的类,如果没有虚函数,那么它的函数是不会存储在对象中的。但是虚函数不一样,为了保证可以在对象中确定这个对象对应的函数,我们要想办法在对象中标识出这个对象的虚函数。于是含有虚函数的类,会多出一个指针,这个指针指向虚函数表,而虚函数表内部存储了这个类所有虚函数的地址。而这个指针叫虚函数表指针,简称虚表指针。

通过观察测试我们发现 b对象 , 除了 _b 成员,还多一个 __vfptr 放在对象的前面 ,对象中的这个指针我们叫做虚函数表指针 (v virtual f 代表 function) 。一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。 针对上面的代码我们做出以下改造 1. 我们增加一个派生类 Derive 去继承 Base 2.Derive 中重写 Func1 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;
	}
private:
	int _d = 2;
};
int main()
{
	Base b;
	Derive d;
	return 0;
}

通过观察和测试,我们发现了以下几点问题: 1. 每个类都有自己独立的虚表,所以派生类和基类的虚表是独立的。 派生类对象d 中也有一个虚表指针, d 对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。 2. 基类 b 对象和派生类 d 对象虚表是不一样的,这里我们发现 Func1 完成了重写,所以 d 的虚表 中存的是重写的 Derive::Func1 ,所以虚函数的重写也叫作覆盖 ,覆盖就是指虚表中虚函数的覆盖。 3. 另外 Func2 继承下来后是虚函数,所以放进了虚表, Func3 也继承下来了,但是不是虚函数,所以不会放进虚表。 4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个 nullptr 。 5. 总结一下派生类的虚表生成: a. 先将基类中的虚表内容拷贝一份到派生类虚表中 b. 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c. 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。 6. 这里还有很容易混淆的问题: 虚函数存在哪的?虚表存在哪的? 答:虚函数存在 虚表,虚表存在对象中。注意上面的回答的错的 。注意 虚表存的是虚函数指针,不是虚函数 ,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。那么虚表存在哪的 呢?实际我们去验证一下会发现 vs 下是存在代码段的。  

当我们调用虚函数时,其会通过对象虚函数表指针找到虚函数表,再通过虚函数表定位函数。

将派生类的对象交给基类的 指针/引用 维护时,不会发生拷贝,而是进行一次切片,此时指针依然指向原先的对象,访问虚函数时,通过派生类对象的虚表来访问

当指针/引用指向基类对象:访问基类的虚表,调用重写前的虚函数
当指针/引用指向派生类对象:访问派生类的虚表,调用重写后的虚函数

此时不论是通过基类还是派生类的 指针/引用,都会通过对象本身对应的虚表来调用函数,这样就不会被 指针/引用 影响调用错误了。

那么为什么将派生类的对象切片为基类对象,不能调用到派生类的函数呢?

当我们将一个派生类的对象切片为基类对象,此时不是直接进行拷贝,基类在拷贝派生类中的基类成员时,不会拷贝派生类的虚表,而是用基类自己的虚表。因此当我们将一个派生类对象切片为基类对象,由于虚表不是派生类的虚表,所以访问到的虚函数是基类的虚函数,无法构成多态。

虚表的特性:

1. 虚表在编译阶段生成
2. 虚表存储在代码段(常量区)中
3. 只有虚函数才进虚表,普通函数不会进入虚表
4. 虚表指针在构造函数的初始化列表中完成的初始化

 动态绑定与静态绑定

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

5. 查看单继承和多继承中的虚函数表

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;
};
观察图中的监视窗口中我们发现看不见 func3 和 func4 。这里是编译器的监视窗口故意隐藏了这 两个函数,也可以认为是他的一个小bug 。那么我们如何查看 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()
{
	Base b;
	Derive d;
	// 思路:取出b、d对象的头4bytes,就是虚表的指针,前面我们说了虚函数表本质是一
    //个存虚函数指针的指针数组,这个数组最后面放了一个nullptr
		// 1.先取b的地址,强转成一个int*的指针
		// 2.再解引用取值,就取到了b对象头4bytes的值,这个值就是指向虚表的指针
		// 3.再强转成VFPTR*,因为虚表就是一个存VFPTR类型(虚函数指针类型)的数组。
		// 4.虚表指针传递给PrintVTable进行打印虚表
		// 5.需要说明的是这个打印虚表的代码经常会崩溃,因为编译器有时对虚表的处理
        //不干净,虚表最后面没有放nullptr,导致越界,这是编译器的问题。我们只需要
        //点目录栏的 - 生成 - 清理解决方案,再编译就好了。
	VFPTR * vTableb = (VFPTR*)(*(int*)&b);
	PrintVTable(vTableb);
	VFPTR* vTabled = (VFPTR*)(*(int*)&d);
	PrintVTable(vTabled);
	return 0;
}

标签:虚表,函数,派生类,多态,C++,基类,重写,指针
From: https://blog.csdn.net/2301_79881188/article/details/140372090

相关文章

  • python--实验10 封装,继承,多态
    目录知识点 封装(PART1)继承(PART2)多态(PART3)动态性(PART4)小结知识拓展实验 知识点 封装(PART1)定义:封装是将数据(属性)和行为(方法)组合在一起的过程,通常封装在类中。目的:保护数据不被外部直接访问和修改,提高程序的安全性和可维护性。类和对象:......
  • How to ues Dev C++
    对于Dev-C++这个"老古董",合理利用设置去提升它的能力,是非常important重要的。1.打开全警告方式:工具[T]->编译选项[C]->代码生成/优化->代码警告->第二个和第三个选Yes代码里,总是会出现奇奇怪怪的错误(scanf、printf占位符和参数列表不匹配,if里把==打成=,运算符优先级.........
  • 一些 C++ 的卡常技巧
    是的,这篇文章的主要内容非常好懂,相信各位同学也十分感兴趣毕竟哪位OIer不想自己的代码跑得飞快呢?那么我们就进入正题吧!First众所周知,一份代码里面必然会有很多循环打表的话当我没说,而循环自然是十分占时间的。所以我们要做的就十分清楚了:加速循环!1.把int改成registerin......
  • <c++>斗破苍穹游戏(转载·博客园)喜欢的一键三连~
    #include<stdio.h>#include<ctime>#include<time.h>//suiji#include<windows.h>//SLEEP函数structPlayer//玩家结构体,并初始化player{charname[21];intattack;intdefense;inthealth;longintmax_health;intlevel;intexp;intrange_exp;......
  • C++编程基础
     一:C++程序语言的基本组成。其中包括:1.一些基础数据类型:布尔值(Boolean)、字符(character)、整数(integer),   浮点数(foating  point)。2.算术运算符、关联运算符以及逻辑运算符,用以操作上述的基础数据型别。这些运算符不仅包括一般常见的加法运算符、等......
  • c/c++设计模式---访问者模式
    访问者(Visitor)模式:访问器模式,行为型模式。  //(1)一个具体范例的逐渐演化  //阿司匹林肠溶片:改善血液流通,预防血栓形成,血栓形成就产生阻塞,人就会直接面临危险;  //氟伐他汀钠缓释片:降血脂。因为血脂高意味着血流慢,营养无法运输到身体各部位,还很可能引发心脑血管疾病;......
  • 【C++】AVL树(旋转、平衡因子)
    ......
  • [C++]封装
    一、封装的定义封装是面向对象编程(OOP)的三大基本特性之一(封装、继承、多态)。它指的是将数据(属性)和操作这些数据的方法(函数)结合成一个独立的单元(类),并尽可能隐藏对象的内部细节,仅对外公开接口。这样做的目的是保护对象的数据,防止外部代码直接访问对象内部的数据结构,减少错误并简......
  • 【C++】通讯录管理系统+少量数据结构
    #include<iostream>#include<string>usingnamespacestd;#definemax1000structnewp{ stringname; intsex; intage; stringnumber; stringadd;};structbooks{ structnewpa[max]; intsize;};staticvoidshowMenu(){ cou......
  • 【C++ 】C++ 停车场收费系统(源码)【独一无二】
    ......