• 【C++】多态,从使用到底层。



    前言


    一、多态的概念

    多态的概念:通俗来说,就是多种形态,具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。

    举例:对于买票的这个行为来说,成年人买票全价,儿童买票半价,学生买票打折,军人优先买票……不同的人虽然都是进行买票的行为,但买票过程的细节不完全相同。而为了让不同的对象,进行同一行为,产生不同的状态。我们则需要采用面向对象的三大特性之一:多态。

    下面看一段简单的多态代码,后文进行解释:

    class Person
    {
    public:
    	virtual void BuyTicket() { cout << "Person::BuyTicket()" << endl; }
    };
    class Student : public Person
    {
    public:
    	virtual void BuyTicket() { cout << "Student::BuyTicket()" << endl; }
    };
    int main()
    {
    	Person pn;
    	Student st;
    	Person* ppn = &pn;
    	ppn->BuyTicket(); //普通人买票
    	ppn = &st;
    	ppn->BuyTicket(); //学生买票
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    打印结果:
    在这里插入图片描述

    二、多太的定义和实现

    2.1 多太的构造条件

    多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。就像上面的代码:Student继承了Person。Person对象买票全价,Student对象买票半价。

    而在继承中构成多态有两个条件(牢记):

    1. 必须通过基类的指针或者引用调用虚函数
    2. 此时基类的指针或引用已经被赋值为了派生类的对象的地址,且被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行了重写

    这中间出现了两个陌生的名词:虚函数和重写,因此我们首先要了解这两个词的意思是什么?

    2.2 虚函数

    虚函数:即被virtual修饰的类成员函数称为虚函数

    比如BuyTicket()函数

    class Person 
    {
    public:
     virtual void BuyTicket() { cout << "买票-全价" << endl;}
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5

    2.3 重写(覆盖)

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

    class Person 
    {
    public:
     virtual void BuyTicket() { cout << "买票-全价" << endl; }
    };
    class Student : public Person 
    {
    public:
     virtual void BuyTicket() { cout << "买票-半价" << endl; }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    注意:

    1. 派生类进行重写时可以不加virtual,只要派生类里的函数和基类的虚函数的返回值类型、函数名字、参数列表完全相同,编译器会自动识别为基类虚函数的重写。但一般为了可读性还是加上
    2. 返回值类型、函数名字、参数列表完全相同。其中参数列表完全相同指的是参数类型+形参的名字完全相同,对于缺省值不作要求.
      virtual void func(int val = 3) {}
      virtual void func(int val = 4) {} 它们依然构造重写
    3. 虚函数的重写是对函数体进行重写
      基类里的虚函数:virtual void func(int val = 3) {cout << "基类" << val;}
      派生类的虚函数:virtual void func(int val = 4) {cout << "派生类" << val;}
      当你调用派生类的虚函数func()时,你会发现打印的结果是派生类3,即它会使用基类的虚函数头 + 派生类的虚函数体。后文有个面试题考察了这个知识。

    虚函数重写的两个特例:

    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;}
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    1. 析构函数的重写(基类与派生类析构函数的名字不同)
      如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor,因此所有的析构函数都满足函数名相同。
    class Person {
    public:
     virtual ~Person() {cout << "~Person()" << endl;}
    };
    class Student : public Person {
    public:
     virtual ~Student() { cout << "~Student()" << endl; }
    };
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    问题:为什么要对析构函数的名称进行处理?博客析构函数的名称为什么统一处理为destructor


    知晓了这两个条件,我们来看上面的那段代码。

    class Person
    {
    public:
    	virtual void BuyTicket() { cout << "Person::BuyTicket()" << endl; }
    };
    class Student : public Person
    {
    public:
    	virtual void BuyTicket() { cout << "Student::BuyTicket()" << endl; }
    };
    int main()
    {
    	Person pn;
    	Student st;
    	Person* ppn = &pn;
    	ppn->BuyTicket(); //普通人买票
    	ppn = &st;
    	ppn->BuyTicket(); //学生买票
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    解释:为什么ppn能调用到派生类的BuyTicket()?

    1. ppn的类型是基类person的指针满足多态的第一个条件
    2. ppn已经被赋值为派生类对象st的地址,且BuyTicket()对基类的BuyTicket进行了重写。满足第二个条件
      因此:ppn->BuyTicket会调用子类的虚函数。

    2.4 C++11 override 和 final

    final:修饰虚函数,表示该虚函数不能再被重写
    使用场景:当你不想某个虚函数被重写时,可以加上final

    在这里插入图片描述

    override:帮助派生类检查是否完成重写

    在这里插入图片描述

    2.5 重载,隐藏,重写

    在这里插入图片描述


    三、多态的原理

    看完上面的内容,相信你会有以下的困惑:

    1. 为什么基类的对象或指针能调用到派生类的函数?
    2. 为什么限定为基类的指针或引用,基类的对象不行吗?

    要解答这些问题,我们必须要了解定义虚函数时产生的虚函数表。

    3. 1虚函数表

    class A
    {
    public:
    	virtual void func1() {};
    	virtual void func2() {};
    	char a;
    };
    int main()
    {	
    	A A1;
    	cout << sizeof(A1);
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    在32位的机器下,请问上面的打印结果是什么?
    如果func的前面没有加virtual,结果很明显是1,但加上virtual后,结果变成了8.
    那多出的内存放了些什么东西呢?
    我们此时打开监视窗口:
    在这里插入图片描述

    发现A1中出现了一个指针_vfptr,我们猜测它代表的什么意思:v即virtual,f即function,ptr即指针,猜测它是虚函数指针。但它下面有【0】【1】,这又表明它可能是个数组。结合以下,即_vfptr是虚函数指针数组。
    事实上,它还真是一个虚函数指针数组,只不过我们将这个数组叫做虚函数表,简称虚表
    这时我们可以确定2个事实

    1. 多出来的内存存储了一个指针,32位下的指针是4字节,加上char a的1字节,最后在进行内存对齐,结果就是8字节。
    2. 这个指针指向的空间并不存储在对象里面,如果存储在对象里,那么对象的大小应该大于8字节。

    此时我们可以画一个简图:
    在这里插入图片描述


    知晓了虚函数表的存在,随之而来的就有2个问题:

    1. 虚函数表是如何完成多态的功能?
    2. 虚函数表并没有存储在对象里,那它存储在什么地方?

    同时加上前文提到的问题:为什么多态的构成条件要求是基类的指针或引用?

    3.2 虚函数表如何完成多态的功能

    在这里插入图片描述
    通过监视窗口,我们可以看到:
    a1的_vfptr[0]与b1_vfptr[0]不相同,但_vfptr[1]是相同的。那就有问题了:a1和b1的_vfptr[0]和_vfptr[1]指向的是哪个函数的地址?
    验证如下,直接通过虚表里的地址来调用虚函数。
    在这里插入图片描述

    据此,我们基本确定调用虚函数的过程如下:

    1. 基类对象和派生类对象都会创建虚函数表,基类对象的虚函数表存储基类的虚函数地址,派生类对象的虚函数表会拷贝基类的虚函数表,同时将重写的虚函数地址改为自己的
    2. 当我们使用基类的对象的指针或引用去调用时,分别取指向对象的虚表去寻找。这就解释了为什么不能使用基类的对象,因为基类的对象里的虚表存储的是基类虚函数的地址,无法找到派生类的虚函数。

    3.3 虚函数表存储在内存空间的那个区域?

    A:栈
    B:堆
    C:代码段(常量区)
    D:数据段(静态区)
    先说答案 : 代码段(常量区)

    验证如下:
    在这里插入图片描述
    思路:比较虚函数表内存储的地址与其他存储区域的地址进行对比,看谁更接近。
    通过上面的结果可以看出:虚表的地址 与 常量区的地址最为接近。

    如何提取虚表的地址:首先对象第一个存储的便是虚函数表指针,因此前4个字节(32位)存储便是虚函数表的地址(int*)&ps 即是 _vfptr的地址, 再解引用便是 _vfptr存储的地址,即虚函数表的地址
    在这里插入图片描述


  • 相关阅读:
    如何将微软 Office 宏转换为 ONLYOFFICE 宏
    机器学习+CFD的后续
    后缀表达式的转换(栈的运用)
    Secrets of RLHF in Large Language Models Part I: PPO
    电影售票系统遇到的问题
    mac上安装brew(最简易)
    Flutter/Dart 中的 extension 方法
    使用 QLoRA 进行微调Llama 2 和 Mistral的初学者指南
    Django-(7)
    vue-pdf结合alloyfinger手势缩放旋转上下翻页pdf文件
  • 原文地址:https://blog.csdn.net/ProcedureStone/article/details/133216393