【C++进阶】第十六篇——C++中的多态(多态的概念+多态的实现+抽象类+多态的原理)

本文详细解析了C++中的多态概念,涉及虚函数的定义、多态构成条件、C++11的override和final、抽象类的使用、虚函数表的工作原理,以及单继承和多继承下的虚函数表差异。深入探讨了动态绑定和静态绑定的区别,以及多态在实际编程中的应用和注意事项。
摘要由CSDN通过智能技术生成

⭐️上一篇博客我和大家聊了聊关于继承的内容,继承是C++的三大特性之一,今天要和大家聊一聊有关C++的三大特性中的最后一个——多态。
⭐️博客代码已上传至gitee:https://gitee.com/byte-binxin/cpp-class-code


🌏多态的

🍯概念

多态: 从字面意思来看,就是事物的多种形态。用C++的语言说就是不同的对象去完成同一个行为会产生不同的效果

🍯虚函数

虚函数:virtual关键字修饰的类成员函数叫做虚函数。

实例演示: 看一下代码,其中BuyTicket成员函数被virtual关键字修饰

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

🍯多态构成的条件

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

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

  1. 必须有基类的指针或引用调用
  2. 被调用的函数必须是虚函数,其派生类必须对基类的虚函数进行重写

虚函数的重写是什么?

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

实例演示:

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

class Student : public Person
{
public:
	virtual void BuyTicket() // 这里也可以不写virtual,因为基类的虚函数属性已经被保留下来了,这里只是完成虚函数的重写
	{
		cout << "买票半价" << endl;
	}
};

虚函数重写的两个例外:

  1. 协变:基类和派生类的虚函数的返回类型不同
    派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。(也就是基类虚函数的返回类型和派生类的虚函数的返回类型是父子类型的指针或引用)
// 协变  返回值类型不同,但它们之间是父子或父父关系  返回类型是指针或者引用
// 基类虚函数   返回类型  是  基类的指针或者引用  
// 派生类虚函数 返回类型  是  基类或派生类的返回类型是基类的指针或引用

class A {};
class B : public A {};
class Person {
public:
	virtual A* f() { return new A; }
};
class Student : public Person {
public:
	virtual A* f() { return new B; }
};
  1. 析构函数的重写 基类与派生类的析构函数的函数名不同
    我在上一篇博客中说到过,基类和派生类的析构函数的函数名会被编译器统一处理成destructor,所以只要基类的析构函数加了关键字virtual,就会和派生类的析构函数构成重写。

我们再回到多态构成的两个条件中,完成基类虚函数的重写我已经介绍了,还有一个必须由基类的指针或引用调用的条件,这个应该很好理解吧。下面举个例子:
实例演示:

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

class Student : public Person
{
public:
	virtual void BuyTicket() // 这里也可以不写virtual,因为基类的虚函数属性已经被保留下来了,这里只是完成虚函数的重写
	{
		cout << "买票半价" << endl;
	}
};

void Func1(Person& p) { p.BuyTicket(); }
void Func2(Person* p) { p->BuyTicket(); }
void Func3(Person p) { p.BuyTicket(); }

int main()
{
	Person p;
	Student s;

	// 满足多态的条件:与类型无关,父类指针指向的是谁就调用谁的成员函数
	// 不满足多态的条件:与类型有关,类型是谁就调用谁的成员函数
	cout << "基类的引用调用:" << endl;
	Func1(p);
	Func1(s);

	cout << "基类的指针调用:" << endl;
	Func2(&p);
	Func2(&s);

	cout << "基类的对象调用:" << endl;
	Func3(p);
	Func3(s);

	return 0;
}

代码运行结果:
在这里插入图片描述
总结:

  • 满足多态的条件:成员函数调用与对象类型无关,指向那个对象就调用哪个的虚函数
  • 不满足多态的条件:成员函数的调用与对象类型有关,是哪个对象类型就调用哪个对象的虚函数。

思考: 析构函数是否要加virtual?
答案是需要的。先给大家看一个例子:

class Person
{
public:
	/*virtual*/ ~Person()
	{
		cout << "~Person()" << endl;
	}
};
class Student: public Person
{
public:
	~Student()
	{
		cout << "~Student()" << endl;
	}
};
int main()
{
	Person* p = new Person;
	Person* ps = new Student;// 不加virtual,不构成多态,父类指针只会根据类型去调用对于的析构函数
	// 加了virtual,构成多态,父类指针会根据指向的对象去调用他的析构函数

	delete p;
	delete ps;

	return 0;
}

下面分别是基类析构函数不加virtual和加virtual的代码运行结果:
在这里插入图片描述
在这里插入图片描述
可以看出,不加virtual关键字时,第二个对象delete时没有调用子类的析构函数清理释放空间。为什么呢?因为不加virtual关键字时,两个析构函数不构成多态,所以调用析构函数时是与类型有关的,因为都是都是父类类型,所以只会调用父类的析构函数。加了virtual关键字时,因为两个析构函数被编译器处理成同名函数了,所以完成了虚函数的重写,且是父类指针调用,所以此时两个析构函数构成多态,所以调用析构函数时是与类型无关的,因为父类指针指向的是子类对象,所以会调用子类的析构函数,子类调用完自己的析构函数又会自动调用父类的析构函数来完成对父类资源的清理。
所以总的来看,基类的析构函数是要加virtual的。

🍯C++11override和final

  1. final: 修饰虚函数,表示该虚函数不可以被重写(还可以修饰类,表示该类不可以被继承)
    实例演示:
class Car
{
public:
	// final  表示该虚函数不能被重写  也可以修饰类,表示该类不可以被继承
	virtual void Drive() final {}
};
class Benz :public Car
{
public:
	virtual void Drive() { cout << "Benz-舒适" << endl; }
};

编译器检查结果: 由于dirve字母编写错误,所以编译器检查出没有重写基类的虚函数
在这里插入图片描述

  1. overide: 检查派生类虚函数是否重写了基类的某个虚函数
    实例演示:
class Car
{
public:
	// override 检测派生类是否对虚函数进行了重写
	virtual void Drive() {}
};
class Benz :public Car
{
public:
	virtual void Dirve() override { cout << "Benz-舒适" << endl; }// override 检测派生类是否对虚函数进行了重写
};

编译器检查结果:
在这里插入图片描述

🍯重载、重写和重定义(隐藏)

名称作用域函数名其他
重载两个函数在同一作用域相同参数类型不同
重写两个函数分别再基类和派生类的作用域相同函数返回类型和参数类型一样
重定义(隐藏)两个函数分别再基类和派生类的作用域相同两个基类和派生类的同名函数不是构成重写就是重定义

🌏抽象类

概念: 在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化象纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承

总结出几个特点:

  1. 虚函数后面加上=0
  2. 不能实例化出对象
  3. 派生类如果不重写基类的纯虚函数那么它也是抽象类,不能实例化出对象
  4. 抽象类严格限制派生类必须重写基类的纯虚函数
  5. 体现了接口继承

实例演示:

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 () override
	{
		cout << "BMW" << endl;
	}
};

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

	Car* pBMW = new BMW;
	pBMW->Drive();

	delete pBenZ;
	delete pBMW;
	return 0;
}

代码运行结果:
在这里插入图片描述

抽象类的意义?

  1. 强制子类完成父类虚函数的重写
  2. 表示该类是抽象类,没有实体(例如:花、车和人等)

接口继承和实现继承

普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。所以如果不实现多态,不要把函数定义成虚函数。

🌏多态的原理

🌲虚函数表

概念: 一个含有虚函数的类中至少有一个虚函数指针,这个指针指向了一张表——虚函数表(简称虚表),这张表中存放了这个类中所有的虚函数的地址。

计算一下下面这个类的大小:

class Base
{
public:
	virtual void func1() {}
	virtual void func2() {}
public:
	int _a;
};
int main()
{
	cout << sizeof(Base) << endl;
	return 0;
}

代码运行结果如下:
在这里插入图片描述
这个类中存放了一个虚表指针和一个成员变量,所以总大小就是8。给大家看一下它的类对象模型:
在这里插入图片描述
实例演示:

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

class Student : public Person
{
public:
	virtual void BuyTicket() // 这里也可以不写virtual,因为基类的虚函数属性已经被保留下来了,这里只是完成虚函数的重写
	{
		cout << "买票半价" << endl;
	}
	int _s = 1;
};

int main()
{
	Person p;
	Student s;

	return 0;
}

类对象模型如下:
在这里插入图片描述

可以看出,两个虚函数地址是不一样的,其实子类会先把父类的虚表拷贝一份下来,如果子类重写了虚函数,那么子类的虚函数的地址将会覆盖虚表中的地址,如果没有重写,那么将不覆盖。

总结几点:

  1. 子类对象由两部分构成,一部分是父类继承下来的成员,虚表指针指向的虚表有父类的虚函数,也有子类新增的虚函数
  2. 子类完成父类虚函数的重写其实是对继承下来的虚表的中重写了的虚函数进行覆盖,把地址更换了,语法层是称为覆盖
  3. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr
  4. 虚表生成的过程:先将基类中的虚表内容拷贝一份到派生类虚表中 b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数 c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后

下面我们来讨论一下虚表存放的位置和虚表指针存放的位置

虚表指针肯定是存在类中的,从上面的类对象模型中可以看出。其次虚表存放的是虚函数的地址,这些虚函数和普通函数一样,都会被编译器编译成指令,然后放进代码段。虚表也是存在代码段的,因为同类型的对象共用一张虚表。下面带大家验证一下(环境:vs2019)

验证代码:

class Base
{
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	virtual void func3() { cout << "Base::func3" << endl; }
	void func() {}

	int b = 0;
};

class Derive :public Base
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func2() { cout << "Derive::func2" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
	virtual void func5() { cout << "Derive::func5" << endl; }

	int d = 0;
};

void func() {}
int globalVar = 10;
int main()
{
	Base b;
	Derive d;
	const char* pChar = "hello";
	int c = 1;
	static int s = 20;
	int* p = new int;
	const int i = 10;

	printf("栈变量:%p\n", &c);
	printf("虚表指针:%p\n", (int*)&b);
	printf("对象成员:%p\n", ((int*)&b + 1));
	printf("堆变量:%p\n", p);
	printf("代码段常量:%p\n", pChar);
	printf("普通函数地址:%p\n", func);
	printf("成员函数地址:%p\n", &Base::func);
	printf("虚函数:%p\n", &Base::func1);
	printf("虚函数表:%p\n", *(int*)&b);
	printf("数据段:%p\n", &s);
	printf("数据段:%p\n", &globalVar);

	delete p;
	return 0;
}

代码运行结果如下:
在这里插入图片描述
容易看出,代码段常量存放的地址和虚表存放的地址很接近,和数据段的地址也很接近,所以可以猜测虚表存放在数据段或代码段,更可能是在代码段。

🌲原理

多态是在运行时到指向的对象中的虚表中查找要调用的虚函数的地址,然后进行调用。

为什么要实现多态必须是父类的指针或引用,不可以是父类对象?

子类对象给父类对象赋值时,会调用父类的拷贝构造对父类的成员变量进行拷贝构造,但是虚表指针不会参与切片,这样父类对象无法找到子类的虚表,所以父类对象不能够调用子类的虚函数。但是子类对象给父类的指针或引用赋值时,是让父类的指针指向父类的那一部分或引用父类的那一部分,这样父类还是可以拿到子类的虚表指针,通过虚表指针找到子类的虚表,从而可以调用虚表中的虚函数。

总结:

  1. 多态满足的两个条件:一个是虚函数的覆盖,一个是对象的指针和引用调用
  2. 满足多态后,函数的调用不是编译时确认的,而是在运行时确认的。

在这里插入图片描述
动态绑定和静态绑定

  1. 静态绑定: 发生在编译时,也就是早期绑定,就是我们之前说过的函数重载就是属于静态绑定,也称静态多态。
  2. 动图绑定: 发生在运行时,也就是后期绑定,多态就是发生在运行时,也称动态多态。

🌏单继承和多继承的虚表

🍍单继承的虚表

先看下面的代码(单继承)

class Base
{
public:
	virtual void func1() { cout << "Base::func1" << endl; }
	virtual void func2() { cout << "Base::func2" << endl; }
	virtual void func3() { cout << "Base::func3" << endl; }
	void func() {}

	int b = 0;
};

class Derive :public Base
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func2() { cout << "Derive::func2" << endl; }
	virtual void func4() { cout << "Derive::func4" << endl; }
	virtual void func5() { cout << "Derive::func5" << endl; }

	int d = 0;
};

观察它的类对象模型:
在这里插入图片描述
在上面的类对象模型中,派生类中只可以看见func1和func2,后面两个函数看不见,这是因为编译器把这两个新增的虚函数给隐藏了,为了我们能够更好的观察,我们可以通过写代码来看。
先定义一个函数指针:

typedef void(*VF_PTR)(); // 给函数指针typedef

下面是打印虚表的代码:

void PrintVFTable(VF_PTR* pTable)
{
	for (size_t i = 0; pTable[i] != nullptr; ++i)
	{
		printf("vfTable[%d]:%p->", i, pTable[i]);
		VF_PTR f = pTable[i];
		f();// 通过函数地址调用函数
	}

	cout << endl;
}

下面我们只需要通过传虚表地址的方式来调用函数打印虚表,虚表地址如何获取呢?从上面的类对象模型可以知道,类对象的前四个字节存放的是虚表指针,所以我们要获取类对象的前四个字节。下面是获取方法:

(VF_PTR*)*(int*)&b;

先将类对象的地址取出,然后强转为整形指针,解引用就会按照四个字节来获取内容,这四个字节的内容是虚表指针,其实也是虚表的地址,我们可以把这个整形强转为函数指针的类型就可以了。

打印虚表:

int main()
{
	Base b;
	Derive d;

	PrintVFTable((VF_PTR*)*(int*)&b);
	PrintVFTable((VF_PTR*)*(int*)&d);

	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 func1() { cout << "Base2::func1" << endl; }
	virtual void func2() { cout << "Base2::func2" << endl; }
private:
	int b2 = 1;
};
class Derive : public Base1 , public Base2 
{
public:
	virtual void func1() { cout << "Derive::func1" << endl; }
	virtual void func3() { cout << "Derive::func3" << endl; }
private:
	int d1 = 1;
};

类对象模型如下:
在这里插入图片描述
细心的你可能还会发现,虚表1中的func1函数的地址和虚表2中func1函数的地址不同。按道理来说,子类重写func1函数后,这两个表中该函数的地址应该是相同的,但编译器却显示不同。这是为什呢?

这其实也算是编译器的一个bug。这里的两个地址其实不是func1函数的真实地址。这两个地址都分别指向一个jump指令,两个地址中的jump指令最终都会跳转到同一个func1中。

为了更好地观察,我们还是通过打印虚表来观察:

int main()
{
	Derive d;
	cout << sizeof(Derive) << endl;
	cout << "Base1的虚表:" << endl;
	PrintVFTable((VF_PTR*)*(int*)&d);

	cout << "Base2的虚表:" << endl;
	PrintVFTable((VF_PTR*)*(int*)((char*)&d+sizeof(Base1)));

	cout << "Derive的成员变量d:" << endl;
	//PrintVFTable((VF_PTR*)*(int*)((char*)&d + sizeof(Base1) + sizeof(Base2)));
	cout << *(int*)((char*)&d + sizeof(Base1) + sizeof(Base2)) << endl;
	return 0;
}

打印结果如下:
在这里插入图片描述
可以看出,派生类新增的虚函数放在了第一个继承的对象的虚表中最后了。

🌏几个值得思考的问题

  1. 内联函数可以是虚函数吗?
    答:可以,但是编译器会忽略inline属性(inline只是一种建议),因为内联(inline)函数没有地址,且虚函数要把地址放到虚表中去。
  2. 构造函数可以是虚函数吗?
    答:不可以,因为对象中虚函数指针是在构造函数初始化列表阶段才初始化的。
  3. 析构函数可以是虚函数吗?
    答:可以,且建议设计成虚函数,具体原因前面说了。
  4. 对象访问普通函数快还是虚函数更快?
    答:首先如果是普通对象,是一样快的。如果是指针对象或者是引用对象,则调用的普通函数快,因为构成多态,运行时调用虚函数需要到虚函数表中去查找。
  5. 虚函数表是在什么阶段生成的?
    答:在编译阶段生成的,存在于代码段。
  6. 什么是抽象类?有什么意义?
    答:前面介绍过了,可以参考前文。
  7. 静态成员可以是虚函数吗?
    答:不可以。因为静态成员没有this指针,使用类域(::)访问成员函数的调用方式无法访问到虚表,所以静态成员函数无法放进虚表。

🌐总结

多态也是C++的三大特性之一,之前也介绍过两个,就是封装和继承。多态也是十分的重要,我们要理清其中一些改了,更好地理解这些。今天的内容就到这里了,喜欢的话,欢迎点赞支持和关注~

在这里插入图片描述

  • 29
    点赞
  • 21
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 23
    评论
评论 23
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

呆呆兽学编程

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值