• C++【多态】


    ✨个人主页: 北 海
    🎉所属专栏: C++修行之路
    🎃操作环境: Visual Studio 2019 版本 16.11.17

    成就一亿技术人



    🌇前言

    多态 是面向对象三大基本特征中的最后一个,多态 可以实现 “一个接口,多种方法”,比如父子类中的同名方法,在增加了多态后,调用同名函数时,可以根据不同的对象(父类对象或子类对象)调用属于自己的函数,实现不同的方法,因此 多态 的实现依赖于 继承

    同一个售票地点,为不同的购票方式提供了不同的取票窗口(多种状态 -> 多态

    售票窗口


    🏙️正文

    1、多态基本概念

    在使用多态的代码中,不同对象完成同一件事会产生不同的结果

    比如在购买高铁票时,普通人原价,学生半价,而军人可以优先购票,对于 购票 这一相同的动作,需要 根据不同的对象提供不同的方法

    #include 
    
    using namespace std;
    
    class Person
    {
    public:
    	virtual void identity() { cout << "普通人原价" << endl; }
    };
    
    class Student : public Person
    {
    public:
    	virtual void identity() { cout << "学生半价" << endl; }
    };
    
    class Soldier : public Person
    {
    public:
    	virtual void identity() { cout << "军人优先购票" << endl; }
    };
    
    
    void BuyTickets(Person& people)
    {
    	//根据不同的身份,选择不同的购票方案
    	people.identity();
    }
    
    
    int main()
    {
    	Person ordinary;	//普通人
    	Student student;	//学生
    	Soldier soldier;	//军人
    
    	//调用同一个购票函数
    	BuyTickets(ordinary);
    	BuyTickets(student);
    	BuyTickets(soldier);
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43

    可以看到在调用同一函数、同一方法的情况下,不同对象的执行结果不同

    买票

    注:父类 Peoson 中使用的 virtual 关键字和 BuyTickets 函数中的父类引用 是实现多态的关键


    2、多态的定义及实现

    实现多态需要借助虚表(虚函数表),而构成虚表又需要虚函数,即 virtual 修饰的函数,除此之外还需要使用虚表指针来进行函数定位、调用

    2.1、构成多态的两个必要条件

    必要条件

    1. virtual 修饰后形成的虚函数,与其他类中的虚函数形成 重写(三同:返回值、函数名、参数均相同)
    2. 必须通过【父类指针】或【父类引用】进行虚函数调用

    virtual 修饰后,成为 虚函数

    virtual void identity() { cout << "普通人原价" << endl; }
    
    virtual void identity() { cout << "学生半价" << endl; }
    
    virtual void identity() { cout << "军人优先购票" << endl; }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    通过 【父类指针】或【父类引用】 调用 虚函数

    void BuyTickets(Person& people)
    {
    	//根据不同的身份,选择不同的购票方案
    	people.identity();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    购票

    除了后续两种特殊情况外,上述两个构成多态的必备条件缺一不可!

    缺少条件一:没有虚函数

    缺少虚函数

    缺少条件二:不是【父类指针】或【父类引用】进行虚函数调用

    普通对象调用

    显然,缺少其中任意一个条件,都不构成多态

    当然还存在两个例外:

    1. 除父类外,其他子类中的函数不必使用 virtual 修饰,此时仍然能构成多态(注意三同,需要构成重写)
    2. 父子类中的虚函数返回值可以不相同,但此时需要返回对应的父类指针或子类指针,确保构成多态,这一现象称为 协变(了解)

    例外一:子类虚函数没有使用 virtual 修饰

    例外一

    例外一有点违反 必要条件一 的意思,不过在某些场景中,这个例外很实用,比如:给父类的析构函数加上 virtual 修饰,这样在进行析构函数调用时,得益于 多态,父类指针可以针对不同对象调用不同的析构函数释放资源

    • 无论是谁的析构函数,最终函数名都为 destructor,可能存在析构错误调用的问题,因此可以利用 virtual 修饰父类的析构函数,这样子类在继承时,自动形成多态
    #include 
    
    using namespace std;
    
    class Person
    {
    public:
    	//此时未构成多态
    	~Person() { cout << "~Person" << endl; }
    };
    
    class Student : public Person
    {
    public:
    	~Student() { cout << "~Student" << endl; }
    };
    
    class Soldier : public Person
    {
    public:
    	~Soldier() { cout << "~Soldier" << endl; }
    };
    
    int main()
    {
    	//父类指针 指向子类对象
    	Person* p1 = new Person();	//普通人
    	Person* p2 = new Student();	//学生
    	Person* p3 = new Soldier();	//军人
    
    	//释放空间
    	delete p1;
    	delete p2;
    	delete p3;
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37

    假若不使用 virtual 修饰父类析构函数,直接运行代码,结果如下:

    未构成多态

    显然此时并未释放两个子类的资源,导致内存泄漏,可以给父类析构函数加上 virtual,构成多态

    //此时构成多态(利用例外一)
    virtual ~Person() { cout << "~Person" << endl; }
    
    • 1
    • 2

    多态

    面试题:为什么要在 父类/基类 的析构函数中加上 virtual 修饰?

    • 为了构成多态,确保不同对象的析构函数能被成功调用,避免内存泄漏

    建议:例外一会破坏代码的可阅读性,可能无法让别人一眼看出多态,因此除了析构函数外,不推荐在子类虚函数中省略 virtual

    例外二:协变

    协变

    如何快速判断是否构成多态?

    • 首先观察父类的函数中是否出现了 virtual 关键字
    • 其次观察是否出现虚函数重写现象,三同:返回值、函数名、参数(协变例外)
    • 最后再看调用虚函数时,是否为【父类指针】或【父类引用】

    父类指针或引用调用函数时,如何判断函数调用关系?

    • 若满足多态:看其指向对象的类型,调用这个类型的成员函数
    • 不满足多态:看具体调用者的类型,进行对应的成员函数调用

    2.2、虚函数及重写

    所以什么是虚函数?为什么类中被 virtual 修饰的函数能变成虚函数?

    虚函数的作用是在目标函数(想要构成多态的函数)之间构成 重写(覆盖),一旦构成了 重写(覆盖),那么子类对象在实现此虚函数时,会 继承父类中的虚函数接口(返回值、函数名、参数列表),然后覆盖至子类对应的虚函数处,因此 重写又叫做覆盖

    #include 
    
    using namespace std;
    
    class Person
    {
    public:
    	virtual void func(int a = 10) { cout << "Person a: " << a << endl; }
    };
    
    class Student : public Person
    {
    public:
    	virtual void func(int a = 20) { cout << "Student a: " << a << endl; }
    };
    
    int main()
    {
    	Person* p = new Student();
    	p->func();
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    预想结果:输出 Student a: 20,但实际情况不是如此

    结果

    不难看出,子类 Student 中虚函数 func 实际上是 Personfunc 的返回值、函数名、参数列表 + Studentfunc 的函数体 组合而成

    结果

    所以虚函数就是 虚拟 的函数,可以被覆盖的、实际形态未确定的函数,使用 virtual 修饰后,就是在告诉编译器:标记此函数,调用时要触发 覆盖 行为,同时虚表指针需要找到正确的函数进行调用

    注意:

    1. 除了类中的成员函数外,普通函数不能添加 virtual 关键字进行修饰,因为虚函数、虚函数表、虚表指针是一体的,普通函数没有
    2. 此处的 virtual 修饰函数为虚函数,与 virtual 修饰类继承为虚继承没有关系:一个是实现多态的基础,而另一个是解决菱形继承的问题
    3. 同样的,假设不是父类指针或引用进行调用,不会构成多态,也不会发生重写(覆盖)行为

    2.3、final 与 override

    C++11 中,新增了两个多态相关的关键字:finaloverride

    final修饰父类的虚函数,不让子类的虚函数与其构成重写,即不构成多态
    override修饰子类的虚函数,检查是否构成重写(是否满足重写的必要条件),若不满足,则报错

    显然一个是 避免被重写 --> 不实现多态,而另一个是 检查是否完成重写 --> 后续实现多态

    对父类的虚函数加上 final无法构成重写

    结果

    对子类的虚函数加上 override 进行 重写检查

    结果

    新标准中的小工具,在某些场景下很实用

    final 还可以修饰父类,修饰后,父类不可被继承

    注:final 可以修饰子类的虚函数,因为子类也有可能成为父类;但 override 无法修饰父类的虚函数,因为父类之上没有父类了,自然无法构成重写

    2.4、重载、重写、重定义

    截至目前为止,我们已经学习了三个 “重” 相关函数知识:重载、重写、重定义

    这三兄弟不止名字很像,而是功能也都差不多,很多面试题中也喜欢考这三者的区别

    重载:即函数重载,函数参数 不同而触发,不同的 函数参数 最终修饰结果不同,确保链接时不会出错,构成重载

    重写(覆盖):发生在类中,当出现虚函数且符合重写的三同原则时,则会发生重写(覆盖)行为,具体表现为 父类虚函数接口 + 子类虚函数体,是实现多态的基础

    重定义(隐藏):发生在类中,当子类中的函数名与父类中的函数名起冲突时,会隐藏父类同名函数,默认调用子类的函数,可以通过 :: 指定调用

    重写和重定义比较容易记混,简言之 先看看是否为虚函数,如果是虚函数且三同,则为重写;若不是虚函数且函数名相同,则为重定义

    注:在类中,仅仅是函数名相同(未构成重写的情况下),就能触发 重定义(隐藏)

    图解


    3、抽象类

    什么是抽象?难道是 围棋大师柯洁直播 “云顶之弈” 下电子围棋 吗?

    当然不是,抽象类是一种极其特殊的类:不允许实例化对象

    抽象大师
    什么年代了还下传统围棋~

    3.1、定义与特点

    如何实现一个抽象类:在虚函数之后加上 =0,此时虚函数升级为 纯虚函数

    纯虚函数也可以与普通虚函数构成重写,也能实现多态,不过包含纯虚函数的类不能实例化对象,因此也被称为抽象类

    注意: 只要类中有一个函数被修饰为纯虚函数,那么这个类就会变成抽象类

    纯虚函数:

    virtual void func(int a = 10) = 0 { cout << "Person a: " << a << endl; }
    
    • 1

    抽象的线条画无法直接看出作者的意图,抽象类也是如此,无法实例化出具体对象,你只知道这个类存在
    抽象
    出自著名画家 彼埃·蒙德里安

    尝试使用 纯虚函数 构成的 抽象类 实例化对象

    #include 
    
    using namespace std;
    
    //抽象类
    class Person
    {
    public:
    	//纯虚函数
    	virtual void func(int a = 10) = 0 { cout << "Person a: " << a << endl; }
    };
    
    int main()
    {
    	//纯虚函数无法实例化对象
    	Person p;
    	Person pp = new Person();	//也不能new出对象
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    图解

    3.2、抽象类的用途

    抽象类适合用于描述无法拥有实体的类,比如 人、动物、植物,毕竟这些都是不能直接使用的,需要经过 继承 赋予特殊属性后,才能作为一个独立存在的个体(对象)

    #include 
    #include 
    using namespace std;
    
    class Person
    {
    public:
    	Person(const string& name = string())
    		:_name(name)
    	{}
    
    	virtual void func() = 0 {};
    protected:
    	string _name;
    };
    
    class Student : public Person
    {
    public:
    	Student(const string& name = string())
    		:Person(name)
    	{}
    
    	//子类继承抽象类后,需要重写纯虚函数,否则仍然是抽象类
    	virtual void func() {};
    };
    
    int main()
    {
    	//抽象类无法直接实例化对象
    	//Person p("newnew");
    
    	Student s("newnew");
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35

    图解

    抽象类的继承很好的体现了函数重写时,继承的是父类虚函数接口的事实,这正是实现多态的基础

    普通继承:子类可以直接使用父类中的函数

    接口继承:子类虚函数继承父类虚函数的接口,进行重写,构成多态

    建议:假如不是为了多态,那么最好不要使用 virtual 修饰函数,更不要尝试定义纯虚函数

    注意: 若父类中为抽象类,那么子类在继承后,必须对其中的纯虚函数进行重写,否则无法实例化出对象


    4、多态实现原理

    所以如此神奇的多态究竟是如何实现的?先来看一段简单的代码

    #include 
    using namespace std;
    
    class Test
    {
    	virtual void func() {};
    };
    
    int main()
    {
    	Test t;	//创建一个对象
    	cout << "Test sizeof(): " << sizeof(t) << endl;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    这是一个空类,其中什么成员都没有,但有一个虚函数

    所以一个对象的大小为多少?
    0 吗 ?

    结果

    答案是 4,当前是 32 位平台下,如果是在 64 位平台,大小会变为 8

    大小随平台而变的只能是指针了,因此可以推测当前类中藏着一个 虚表指针

    就是依靠这个 虚表指针+虚表 实现了多态

    4.1、虚表与虚表指针

    虚函数表(虚表)即 virtual function table -> vft,指向虚表的指针称为 虚表指针 virtual function pointer -> vfptr,在 vs 的监视窗口中,可以看到涉及虚函数类的对象中都有属性 __vfptr(虚表指针),可以通过虚表指针所指向的地址,找到对应的虚表

    • 虚函数表中存储的是虚函数指针,可以在调用函数时根据不同的地址调用不同的方法

    在下面这段代码中,父类 Person 有两个虚函数(func3 不是虚函数),子类 Student 重写了 func1 这个虚函数,同时新增了一个 func4 虚函数

    #include 
    
    using namespace std;
    
    class Person
    {
    public:
    	virtual void func1() { cout << "Person::fun1()" << endl; };
    	virtual void func2() { cout << "Person::fun2()" << endl; };
    	void func3() { cout << "Person::fun3()" << endl; };	//fun3 不是虚函数
    };
    
    class Student : public Person
    {
    public:
    	virtual void func1() { cout << "Student::fun1()" << endl; };
    
    	virtual void func4() { cout << "Student::fun4()" << endl; };
    };
    
    int main()
    {
    	Person p;
    	Student s;
        return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    图解

    如何通过程序验证虚表的真实性?

    • 虚表指针指向虚表,虚表中存储的是虚函数地址,而 32 位平台中指针大小为 4 字节
    • 因此可以先将虚表指针强转为 指向首个虚函数 的指针,然后遍历虚表打印各个虚函数地址验证即可
    • vs 中对虚表做了特殊处理:在虚表的结尾处放了一个 nullptr,因此下面这段代码可能在其他平台中跑不了
    //打印虚表
    typedef void(*VF_T)();
    
    void PrintVFTable(VF_T table[])	//也可以将参数类型设为 VF_T*
    {
    	//vs中在虚表的结尾处添加了 nullptr
    	//如果运行失败,可以尝试清理解决方案重新编译
    	int i = 0;
    	while (table[i])
    	{
    		printf("[%d]:%p->", i, table[i]);
    		VF_T f = table[i];
    		f();	//调用函数,相当于 func()
    		i++;
    	}
    	cout << endl;
    }
    
    
    int main()
    {
    	//提取出虚表指针,传递给打印函数
    	Person p;
    	Student s;
    
    	//第一种方式:强转为虚函数地址(4字节)
    	PrintVFTable((VF_T*)(*(int*)&p));
    	PrintVFTable((VF_T*)(*(int*)&s));
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31

    子类重写后的虚函数地址与父类不同

    结果

    因为平台不同指针大小不同,因此上述传递参数的方式(VF_T*)(*(int*)&p 具有一定的局限性
    假设在 64 位平台下,需要更改为 (VF_T*)(*(long long*)&p

    //64 位平台下指针大小为 8字节
    PrintVFTable((VF_T*)(*(long long*)&p));
    PrintVFTable((VF_T*)(*(long long*)&s));
    
    • 1
    • 2
    • 3

    除此之外还可以间接将虚表指针转为 VF_T* 类型进行参数传递

    //同时适用于 32位 和 64位 平台
    PrintVFTable(*(VF_T**)&p);
    PrintVFTable(*(VF_T**)&s);
    
    • 1
    • 2
    • 3

    传递参数时的类型转换路径

    图解

    不能直接写成 PrintVFTable((VF_T*)&p);,因为此时取的是整个虚表区域的首地址地址,无法定位我们所需要虚表的首地址,打印时会出错

    • 类似于 int* arr[]int* 是第一个指针数组的首地址,遍历的是第一个指针数组;而 int** 是整个指针数组的首地址,遍历的是整个指针数组,+1 会直接跳过一个指针数组

    错误写法:

    //错误写法
    PrintVFTable((VF_T*)&p);
    PrintVFTable((VF_T*)&s);
    
    • 1
    • 2
    • 3

    运行崩溃

    综上所述,虚表是真实存在的,只要当前类中涉及了虚函数,那么编译器就会为其构建相应的虚表体系

    虚表相关知识补充:

    • 虚表是在 编译 阶段生成的
    • 虚表指针是在构造函数的 初始化列表 中初始化的
    • 虚表一般存储在 常量区(代码段),有的平台中可能存储在 静态区(数据段)

    通过一段简单的代码验证 虚表的存储位置

    int main()
    {
    	//验证虚表的存储位置
    	Person p;
    	Student s;
    
    	int a = 10;	//栈
    	int* b = new int;	//堆
    	static int c = 0;	//静态区(数据段)
    	const char* d = "xxx";	//常量区(代码段)
    
    	printf("a-栈地址:%p\n", &a);
    	printf("b-堆地址:%p\n", b);
    	printf("c-静态区地址:%p\n", &c);
    	printf("d-常量区地址:%p\n", d);
    
    	printf("p 对象虚表地址:%p\n", *(VF_T**)&p);
    	printf("s 对象虚表地址:%p\n", *(VF_T**)&s);
    
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    结果

    显然,虚表地址与常量区的地址十分接近,因此可以推测 虚表位于常量区中,因为它需要被同一类中的不同对象共享,同时不能被修改(如同代码一样)

    函数代码也是位于 常量区(代码段),可以在监视窗口中观察两者的差异

    虚表

    4.2、虚函数调用过程

    现在来看,虚函数的调用过程就非常简单了

    • 首先确保存在虚函数且构成重写
    • 其次使用【父类指针】或【父类引用】指向对象,其中包含切片行为
    • 切片后,将子类中不属于父类的切掉,只保留父类指针可调用到的部分函数
    • 实际调用时,父类指针的调用逻辑是一致的:比如虚表第一个位置调用第一个函数,虚表第二个位置调用第二个函数,但是因为此时的虚表是切片得到的,所以 同一位置可以调用到不同的函数,这就是多态
    int main()
    {
    	Person* p1 = new Person();
    	Person* p2 = new Student();
    
    	p1->func1();
    	p2->func1();
    
    	delete p1;
    	delete p2;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    通过汇编代码观察:

    p1
    p2

    注:下图中的函数地址仅供参考,与上图中的调用演示并不是同一次运行

    图解

    4.3、动态绑定与静态绑定

    静态绑定(前期绑定/早绑定)

    • 在编译时确定程序的行为,也称为静态多态

    动态绑定(后期绑定/晚绑定)

    • 在程序运行期间调用具体的函数,也称为动态多态
    p1->func1();
    p2->func1();
    
    add(1, 2);
    add(1.1, 2.2);
    
    • 1
    • 2
    • 3
    • 4
    • 5

    简单来说,静态绑定就像函数重载,在编译阶段就确定了不同函数的调用;而动态绑定是虚函数的调用过程,需要 虚表指针+虚表,在程序运行时,根据不同的对象调用不同的函数

    图解


    5、单继承与多继承中的虚表

    5.1、单继承中的虚表

    单继承中的虚表比较简单,无非就是 子类中的虚函数对父类中相应的虚函数进行覆盖

    • 单继承不会出现虚函数冗余的情况,顶多就是子类与父类构成重写

    单继承中的虚函数表

    向父类中新增虚函数:父类的虚表中会新增,同时子类会继承,并纳入自己的虚表之中

    向子类中新增虚函数:只有子类能看到,因此只会纳入子类的虚表中,父类是看不到并且无法调用的

    向父类/子类中添加非虚函数时:不属于虚函数,不进入虚表,仅当作普通的类成员函数处理

    5.2、多继承中的虚表

    C++ 中支持多继承,这也就意味着可能出现 多个虚函数重写 的情况,当父类指针面临 不同虚表中的相同虚函数重写 时,该如何处理呢?

    #include 
    using namespace std;
    
    //父类1
    class Base1
    {
    public:
    	virtual void func1() { cout << "Base1::func1()" << endl; }
    	virtual void func2() { cout << "Base1::func2()" << endl; }
    };
    
    //父类2
    class Base2
    {
    public:
    	virtual void func1() { cout << "Base2::func1()" << endl; }
    	virtual void func2() { cout << "Base2::func2()" << endl; }
    };
    
    //多继承子类
    class Derive : public Base1, public Base2
    {
    public:
    	virtual void func1() { cout << "Derive::func1()" << endl; }
    	virtual void func3() { cout << "Derive::func3()" << endl; }	//子类新增虚函数
    };
    
    int main()
    {
    	Derive d;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32

    此时的子类 Derive 中拥有两张虚表,分别为 Base1 + Derive::func1 构成的虚表Base2 + Derive::func1 构成的虚表

    图解

    此时出现了两个问题:

    1. 子类 Derive 中新增的虚函数 func3 位于哪张虚表中?
    2. 为什么重写的同一个 func1 函数,在两张虚表中的地址不相同?

    这两个问题是多继承多态中的主要问题

    5.2.1、子类新增虚函数的归属问题

    在单继承中,子类中新增的虚函数会放到子类的虚表中,但这里是多继承,子类有两张虚表,所以按照常理来说,应该在两张虚表中都新增虚函数才对

    但实际情况是 子类中新增的虚函数默认添加至第一张虚表中

    通过 PrintVFTable 函数打印虚表进行验证

    因此此时有两张虚表,所以需要分别打印

    • 第一张虚表简单,直接取地址+类型强转,如法炮制即可
    • 第二张虚表就比较麻烦了,需要在第一张虚表的起始地址处,跳过第一张虚表的大小,然后才能获取第二张虚表的起始地址

    图解

    //打印虚表
    typedef void(*VF_T)();
    
    void PrintVFTable(VF_T table[])
    {
    	//vs中在虚表的结尾处添加了 nullptr
    	int i = 0;
    	while (table[i])
    	{
    		printf("[%d]:%p->", i, table[i]);
    		VF_T f = table[i];
    		f();	//调用函数,相当于 func()
    		i++;
    	}
    	cout << endl;
    }
    
    int main()
    {
    	Derive d;
    
    	PrintVFTable(*(VF_T**)&d);	//第一张虚表
    	PrintVFTable(*(VF_T**)((char*)&d + sizeof(Base1)));	//第二张虚表
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    虚表

    可以看出新增的 func3 函数确实在第一张虚表中

    可能有的人觉得取第二张虚表的起始地址很麻烦,那么可以试试利用 切片 机制,天然的取出第二张虚表的地址

    切片行为是天然的,可以完美取到目标地址

    Base2* table2 = &d;	//切片
    PrintVFTable(*(VF_T**)table2);	//第二张虚表
    
    • 1
    • 2

    此时已经解决问题一:子类新增虚函数的归属问题 —> 添加至第一张虚表中

    5.2.2、冗余虚函数的调用问题

    在上面的多继承多态代码中,子类分别重写了两个父类中的 func1 函数,但最终通过监视窗口发现:同一个函数在两张虚表中的地址不相同

    因此可以推测:编译器在调用时,根据不同的地址寻找到同一函数,解决冗余虚函数的调用问题

    至于实际调用链路,还得通过汇编代码展现:

    图解
    图解

    ptr2 在调用时的关键语句 sub ecx 4

    • sub 表示减法,ecx 通常存储 this 指针,4 表示 Base1 的大小
    • 这条语句表示将当前的 this 指针向前偏移 sizeof(Base1),后续再 jmp 时,调用的就是同一个 func1

    这一过程称为 this 指针修正,用于解决冗余虚函数的调用问题

    为什么是 Base2 修正?

    • 因为先继承了 Base1,后继承了 Base2,假设先继承的是 Base2,那么修正的就是 Base1

    这种设计很大胆也很巧妙,完美解决了多继承多态带来的问题

    因此回答问题二:两张虚表中同一个函数的地址不同,是因为调用方式不同,后继承类中的虚表需要通过 this 指针修正的方式调用虚函数

    5.3、菱形继承多态与菱形虚拟继承多态(了解)

    菱形继承问题是 C++ 多继承中的大坑,为了解决菱形继承问题,提出了 虚继承 + 虚基表 的相关概念,那么在多态的加持之下,菱形继承多态变得更加复杂:需要函数调用链路设计的更加复杂

    菱形虚拟继承多态就更不得了:需要同时考虑两张表:虚表、虚基表

    • 虚基表中空余出来的那一行是用来存储偏移量的:表示当前虚基表距离虚表有多远

    图解

    因为这种写法过于复杂,所以在实际中一般不会使用,更不会去考

    如果感兴趣的同学可以看看下面这两篇相关文章:
    C++虚函数表解析
    C++对象的内存布局


    6、多态相关面试题

    一些简单的概念题,主要是为了回顾面向对象特性

    6.1、基本概念(选择)

    1.下面哪种面向对象的方法可以让你变得富有( )

    A: 继承
    B: 封装
    C: 多态
    D: 抽象

    2.以下关于纯虚函数的说法,正确的是( )

    A:声明纯虚函数的类不能实例化对象
    B:声明纯虚函数的类是虚基类
    C:子类必须实现基类的纯虚函数
    D:纯虚函数必须是空函数

    3.关于虚表说法正确的是( )

    A:一个类只能有一张虚表
    B:基类中有虚函数,如果子类中没有重写基类的虚函数,此时子类与基类共用同一张虚表
    C:虚表是在运行期间动态生成的
    D:一个类的不同对象共享该类的虚表

    4.下面程序输出结果是什么? ()

    #include
    
    using namespace std;
    class A
    {
    public:
    	A(const char* s) { cout << s << endl; }
    	~A() {}
    };
    class B :virtual public A
    {
    public:
    	B(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
    };
    class C :virtual public A
    {
    public:
    	C(const char* s1, const char* s2) :A(s1) { cout << s2 << endl; }
    };
    class D :public B, public C
    {
    public:
    	D(const char* s1, const char* s2, const char* s3, const char* s4) :B(s1, s2), C(s1, s3), A(s1)
    	{
    		cout << s4 << endl;
    	}
    };
    
    int main() {
    	D* p = new D("class A", "class B", "class C", "class D");
    	delete p;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33

    A:class A class B class C class D
    B:class D class B class C class A
    C:class D class C class B class A
    D:class A class C class B class D

    5.多继承中指针偏移问题,下面说法正确的是( )

    class Base1 { public: int _b1; };
    class Base2 { public: int _b2; };
    class Derive : public Base1, public Base2 { public: int _d; };
    int main()
    {
    	Derive d;
    	Base1* p1 = &d;
    	Base2* p2 = &d;
    	Derive* p3 = &d;
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    A:p1 == p2 == p3
    B:p1 < p2 < p3
    C:p1 == p3 != p2
    D:p1 != p2 != p3

    答案:

    1. A
    2. A
    3. D
    4. A
    5. C

    6.2、综合问答(简答)

    1.什么是多态?

    多态是指不同继承关系的类对象,去调用同一函数,产生了不同的行为。多态又分为静态的多态和动态的多态

    2.为什么要在父类析构函数前加上 virtual 修饰?

    与子类析构函数构成多态,确保析构函数能被成功调用

    3.什么是重载、重写、重定义?三者区别是什么?

    重载:同名函数因参数不同而形成不同的函数修饰名,因此同名函数可以存在,并且能被正确匹配调用
    重写:父子类中的函数被 virtual 修饰为虚函数,并且符合 “三同” 原则,构成重写
    重定义:父子类中的同名函数,在不被重写的情况下,构成重定义,父类同名函数被隐藏

    重载可以出现任何位置,只要函数在同一作用域中,而重定义是重写的基础,或者是重写包含重定义,假设因为没有 virtual 修饰不构成重写,那么必然构成重定义,重写和重定义只能发生在继承关系中

    4.为什么内联修饰可以构成多态?

    不同环境下结果可能不同
    内联对编译器只是建议,当编译器识别为虚函数时,会忽略 inline

    5.静态成员函数为什么不能构成多态?

    没有 this 指针,不进虚表,构造函数也不能构成多态

    6.普通函数与虚函数的访问速度?

    没有实现多态时,两者一样快
    实现多态后,普通函数速度快,因为虚函数还需要去虚表中调用


    🌆总结

    以上就是本次关于 C++【多态】的全部内容了,在本篇文章中,我们重点介绍了多态的相关知识,如什么是多态、如何使用多态、构成多态的两个必要条件及两个例外该,最后还学习了多继承模式下多态引发的相关问题,探究了其原理。本文中最重要的莫过于 虚表 的相关概念,只有自己多测试、多调试、多画图 才能加深对虚表的理解


    星辰大海

    相关文章推荐

    C++ 进阶知识

    C++【继承】

    STL 之 泛型思想

    C++【模板进阶】

    C++【模板初阶】

    STL 之 适配器

    C++ STL学习之【优先级队列】

    C++ STL学习之【反向迭代器】

    C++ STL学习之【容器适配器】

  • 相关阅读:
    《动手学深度学习》d2l 李沐B站视频配套学习笔记
    AMBA总线协议之AHB学习记录(2)—ahb_bus的测试(附testbench代码)
    VR全景技术打造“智慧亚运”,实现720度自由视角
    有趣的USB接口和颜色分类
    六级易混词整理
    【视频图像篇】FastStone Capture屏幕直尺功能设置
    vscode使用delve调试golang程序
    计算机组成原理期中考试
    长篇图解etcd核心应用场景及编码实战
    周赛366(记忆化搜索)
  • 原文地址:https://blog.csdn.net/weixin_61437787/article/details/130902311