【C++】一文带你吃透C++多态
🍎 博客主页:🌙@披星戴月的贾维斯
🍎 欢迎关注:👍点赞🍃收藏🔥留言
🍇系列专栏:🌙 C/C++专栏
🌙那些看似波澜不惊的日复一日,一定会在某一天让你看见坚持的意义!-- 算法导论🌙
🍉一起加油,去追寻、去成为更好的自己!

@TOC
提示:以下是本篇文章正文内容,下面案例可供参考
前言
C++的核心特性包括继承、封装和多态。在上期文章中我们深入探讨了C++中的继承机制。本期我们将一起深入理解C++中的多态特性。那么问题来了:'多态'到底是什么意思呢?它是如何具体实现的呢?---- 详情请关注这篇博客
🍎1、多态的概念
1.1 多态的概念
举个例子,在购买动车票的过程中会提供学生票、成人票以及军人优先通道等不同类型的优惠或便捷选项。这说明,在买动车票这件事上来说,不同的人会采取不同的方式来购票,并且这些方式都会带来各自的效果差异。多态就是在不同对象(如学生、成人或军人)完成相同任务(如购买动车票)时展现出的不同效果。
1.2 多态的定义与构成要素
多态性是指在不同层次(即不同继承关系)的对象中去调用同一个函数时会引发的行为差异 。例如:当一个Student类继承自Person类时,在购票过程中Person对象会支付全额票价而Student对象只需支付半价票价 。
在继承机制中需要满足以下两个基本条件:
一是实现接口时必须遵循其声明的行为规范;
二是必须保证接口之间的兼容性以支持功能的一致性 。
- 该虚函数必须通过其基类的对象指针或引用进行调用。
- 所有被调用的虚拟函数都应由相应派生类实现。

🍎2、 详解虚函数
2.1 虚函数的概念以及为什么要引入虚函数
class X{
……
virtual 返回类型 函数名(函数参数表); //虚函数(成员)声明
……
}
虚函数的引入: 赋值相容性
基于赋值相容性原则的情况下,在使用基于基类的指针或引用时,默认情况下对象只能通过调用派生方法或属性来间接操作其基础类型相关联的对象(即继承自基类的对象实例),而无法直接或显式地操作被派生类型所独有的独立定义的对象属性或方法。
如何使用基类指针/引用来访问派生类中新增定义的成员?
本节讨论** virtual function 的实现方式 **
代码示例:
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;
}
虚函数重写的两个例外:
在基类与派生类之间存在协变现象(当它们的虚函数返回值类型不同时)。当派生类重写了基类的虚函数时,则与基类虚函数的返回值类型不一致。此时称这种情况为协变。代码示例:
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; }
};
// 只有派生类Student的析构函数重写了Person的析构函数,下面的delete对象调用析构函数,才能构成
多态,才能保证p1和p2指向的对象正确的调用析构函数。
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
return 0;
}
🍇2.3虚函数的特性
有 3种类型的函数成员不能定义为虚函数。
- 该类的构造与静态成员不可以作为虚函数实现,并且仅允许非静态成员使用。
- 内联结构同样不可以作为虚函数实现。
C++实现函数重写的规则较为繁琐, 但在某些情况下, 如果不小心将函数字母顺序颠倒, 可能会导致无法实现预期效果的现象. 这种错误在编译阶段并不会发出提示信息, 只有在程序运行后发现结果与预期不符时才会开始排查问题. 因此,C++11引入了override和final这两个关键字, 以便于开发者检测并纠正可能的错误.
- final:修饰虚函数,表示该虚函数不能再被重写
class D1 :public D {
public:
void g1(int x) final { cout << x; } //正确,不允许D1的派生类覆盖g1
void f(int y) final {cout<<y;} //错误,f不是虚函数
};
class D2 :public D1 {
void g1(int a)override { cout << a; } //错误,D1已声明g1为final
};
- 继承: 检查子类中的纯虚函数是否实现了父类对应纯虚函数的功能, 如果没有实现则会导致编译错误
class Car{
public:
virtual void Drive(){}
};
class Benz :public Car {
public:
virtual void Drive() override {cout << "Benz-舒适" << endl;}
}
🍇2.5 重载、覆盖(重写)、隐藏(重定义)的对比

🍎3、抽象类
🍇3.1 概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
定义纯虚函数的语法格式:
class X {
virtual ret_type func_name (params) = 0;
}
抽象类的主要用途——作为“接口”

抽象图形类代码示例:
#include <iostream>
using namespace std;
class Figure{
protected:
double x,y;
public:
void set(double i,double j){ x=i; y=j; }
virtual void area()=0; //纯虚函数
};
class Triangle:public Figure{
public:
void area(){cout<<"三角形 面积:"<<x*y*0.5<<endl;}//重写基类纯虚函数
};
class Rectangle:public Figure{
public:
void area(int i){cout<<"矩形 面积:"<<x*y<<endl;} //‘不是’基类纯虚函数的重写
};
int main(){
Figure *pF; //基类(抽象类)指针
// Figure f1; //L1,错误
// Rectangle r; //L2,错误
Triangle t; //L3: 派生类(实体类)对象t
t.set(10,20);
pF=&t;
pF->area(); //L4: 调用派生类的虚函数area()
Figure &rF=t;
rF.set(20,20);
rF.area(); //L5
}
普通函數的承嗣属于實現性承嗣,在此情形下, 派生體不仅承接了基本體中的具體函數功能,並實現了這些具體運算的功能. 虛函數則不同, 它们的承载涉及接口性承嗣, 派生體仅会复制基類中virtual函umber的行为特性, 其目的是为了重寫代码以實現實例化, 朝向實現具體應用的目的. 因此, 若無須實現具體應用的情況下, 則應避免將該功能聲明為純virtual函umber.
🍎4.多态的原理
🍇4.1虚函数表
让我们一起分析一下下面这个题目:
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
请问 sizeof(Base) 的大小是多少?经过测试分析可知,b对象占据的空间大小为8字节。值得注意的是,在b对象之前除了原有的 _b 成员之外,还有一个 __vfptr 成员被附加放置。需要注意的是,在不同平台上实现时,默认情况下这个额外成员的位置可能位于对象之后。在C++面向对象编程中,默认我们将此类成员命名为 vfptr 指针(其中v代表虚拟功能的概念)。任何包含有可重载功能或静态功能(即具有可变返回类型的非成员功能)的类中都会自动拥有至少一个 vfptr 指针。由于C++语言对可重叠功能的支持机制决定了所有可重载或静态功能(非成员且返回类型不固定)的功能在其定义处必须明确存储在相应的vfptr 指针所指向的位置上。

增加了派生类Derive以继承Base,并在其基础上实现了功能模块Func1。为提升整体性能,在Base类中新增了抽象方法Func2以及具体系数的实现方法Func3,并对虚拟基类中的抽象方法进行了详细说明。
增加了派生类Derive以继承Base,并在其基础上实现了功能模块Func1。为提升整体性能,在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;
}


通过观察和测试,我们发现了以下几点问题:
- 派生类对象d包含一个指向自身虚拟表的指针,在这种情况下d对象被划分为两个部分:一个是继承自基类的成员属性;另一个则是与自身相关的成员属性。
- 基类b和派生类d的对象都有自己的独立虚拟表结构;我们发现Func1实现了重写功能;因此在派生类d的虚拟表中存储的是实现该功能的具体实现(即Derive::Func1);这反映了面向过程编程中的覆盖机制:从原理层的角度看这就是覆盖行为;而从语法层则被称为重写。
- 另一方面Func2继承自基类后成为了一个静态函数(virtual function),因此它不会被包含在虚拟表中;而Func3虽然也继承了基类属性但并未声明为静态函数(non-virtual),因此也不会被包含在虚拟表中。
- 虚函数表本质上是一个用于存储所有子类声明中的静态函数指针(virtual function pointers)的数组结构;通常情况下这个数组最后会附加一个空指针(nullptr)。
- 总结起来生成派生类虚拟表格遵循以下规则:
a. 首先将基类的所有静态函数及其相关属性复制到派生类的虚拟表中;
b. 如果当前对象对某个静态方法进行了重定义,则使用当前实例的方法覆盖其父本方法;
c. 对于当前实例新增声明的所有静态方法,则按照声明顺序依次添加至派生类的虚拟表格末尾。 - 注意这里特别强调的是:尽管我们讨论的是保存了多个指向不同方法地址的空间位置(即指向内存中的代码段),但实际上保存的是这些方法地址本身而非实际的方法实体;此外要注意不要混淆对象与它们所携带的数据空间之间的关系——具体来说就是:每个对象并不直接保存其自身的数据空间或引用空间;相反它只保存着指向这些数据空间地址的信息——也就是所谓的引用空间或者说是引用层的空间布局。
多态调用和普通调用的区别:

在将父类赋值给子类对象并进行切片时
子类的虚拟函数表用于存储各虚拟函数的实际地址,并以此支持多态性;在构造派生类时会附加一个偏移量到基类指针中以形成虚拟基表(virtual base table),该偏移量用于解决继承过程中出现的数据冗余及二义性问题。
🍎总结
本文向读者介绍了C++多态的主要方面,在深入分析其概念的基础上探讨了虚函数的作用以及抽象类与其实现方式之间的联系,并结合实例详细阐述了这些知识点之间的关联性
