• Cpp多态机制的深入理解(20)



    前言

      多态也是三大面向对象语言的特性之一,同时我也觉得他也蛮有意思的
      与封装“一个方法,多个接口”不同的是,多态可以实现 “一个接口,多种方法

      调用同名函数时,可以根据不同的对象(父类对象或子类对象)调用属于自己的函数,实现不同的方法,因此 多态 的实现依赖于 继承


    一、多态的概念

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

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

    二、多态的定义与实现

    两个必要条件

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

    在这里插入图片描述

    虚函数

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

    全局虚函数没有意义,因为虚函数是为多态而用的

    在这里插入图片描述

    虚函数的重写

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

    // 基类
    class Person 
    {
    public:
        // 虚函数
        virtual void BuyTicket() { cout << "买票-全价" << endl; }
    };
    
    // 派生类
    class Student : public Person 
    {
    public:
        // 虚函数重写
        virtual void BuyTicket() { cout << "买票-半价" << endl; }
    };
    
    // 三种函数实现
    // 引用
    void Func(Person& p)
    {
    	p.BuyTicket();
    }
     
    // 指针
    //void Func(Person* p)
    //{
    //	p->BuyTicket();
    //}
     
    // 非引用指针,调用父类
    //void Func(Person p)
    //{
    //	p.BuyTicket();
    //}
    

    测试结果:
    在这里插入图片描述

    重写的三个例外

    1. 协变(基类与派生类虚函数返回值类型不同)

      派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变

    这个了解一下就行,实际我感觉挺没啥用处的
    如果你也有这种感觉,鼓励你致电老本,去好好批斗他!

    class A {};
    class B : public A {};
     
    class Person
    {
    public:
    	// 协变 返回值可以是父子类对象指针或引用
    	//virtual A* BuyTicket() // 返回值是父类指针
    	virtual Person* BuyTicket()
    	{
    		cout << "Person-> 买票-全价" << endl;
    		return nullptr;
    	}
    };
    
    class Student : public Person
    {
    public:
    	//virtual B* BuyTicket()// 返回值是子类指针
    	virtual Student* BuyTicket()
    	{
    		cout << "Student-> 买票-半价" << endl;
    		return nullptr;
    	}
    };
    
    1. 析构函数的重写(基类与派生类析构函数的名字不同)

      如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字,都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同,看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成destructor

    class Person
    {
    public:
    	// 析构函数名不同,构成重写,编译器将析构函数名字统一处理成destructor
    	virtual ~Person()
    	{
    		cout << "~Person()" << endl;
    	}
    };
    
    class Student : public Person
    {
    public:
        virtual ~Student()
    	{
    		cout << "delete[]" << _ptr << endl;
    		
    		delete[] _ptr;
    		cout << "~Student()" << endl;
    	}
    private:
    	int* _ptr = new int[10];
    };
    
    void Func(Person& p)
    {
    	p.BuyTicket();
    }
    
    int main()
    {
    	// 正常情况调用析构没有问题
    	//Person p;
    	//Student s;
    	//Func(p);
    	//Func(s);
     
    	// 派生类有动态开辟的内存,需要调用多态
    	// 指向谁调用谁
    	Person* p1 = new Person;
    	Person* p2 = new Student;
     
    	delete p1;
    	delete p2;
     
    	return 0;
    }
    

    在这里插入图片描述

    1. 派生类重写虚函数virtual关键字可以省略
    class Person
    {
    public:
    	virtual ~Person()
    	{
    		cout << "~Person()" << endl;
    	}
    };
     
    class Student : public Person
    {
    public:
        // 派生类virtual关键字省略
    	~Student()
    	{
    		cout << "~Student()" << endl;
    	}
    };
    

    在这里插入图片描述

    override 和 final

      C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数名字母次序写反而无法构成重载

    1. final:修饰虚函数,表示该虚函数不能再被重写
    2. override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错
    // final 修饰虚函数,不能重写
    class Car
    {
    public:
    	// 加了final关键字,虚函数不能被重写
    	virtual void Drive() final {}
    };
    
    class Benz :public Car
    {
    public:
    	virtual void Drive() { cout << "Benz-舒适" << endl; }
    };
    
    int main()
    {
    	Benz b;
    	return 0;
    }
    

    在这里插入图片描述

    重载、重写(覆盖)、重定义(隐藏)

    在这里插入图片描述
    在这里插入图片描述

    三、抽象类

    概念

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

    class Car
    {
    public:
    	// 纯虚函数 强制派生类重写虚函数 
    	virtual void Drive() = 0;
    };
    
    int main()
    {
    	Car c;
    	return 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;
    	}
    };
    
    int main()
    {
    	// Car c;
    	Benz b1;
    	BMW b2;
     
    	// 基类可以定义指针 指向谁调用谁
    	Car* ptr1 = &b1;
    	Car* ptr2 = &b2;
     
    	ptr1->Drive();
    	ptr2->Drive();
    	
    	return 0;
    }
    

    在这里插入图片描述

    接口继承和实现继承

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

    四、多态的原理

      在讲解原理之前,不如我们先来看这么一段神奇代码

    #include 
    using namespace std;
    
    class Test
    {
    	virtual void func() {};
    };
    
    int main()
    {
    	Test t;	//创建一个对象
    	cout << "Test sizeof(): " << sizeof(t) << endl;
    	
    	return 0;
    }
    

      可能你会觉得没有对象,会觉得是0,但是你突然想起了之前讲过的空类也占内存空间,你可能会想是不是1

      但是其实都错了,真相是4/8(取决于你的系统是32位还是64位),可能我这么一说,你也猜到了其实有一个隐藏变量,且类型是指针类型

    其实,就是靠着这个虚表指针和虚表实现了多态

    虚表和虚表指针

      在 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;
    }
    

    在这里插入图片描述

    //打印虚表
    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;
    }
    

    子类重写后的虚函数地址与父类不同
    在这里插入图片描述

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

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

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

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

    传递参数时的类型转换路径
    在这里插入图片描述
      不能直接写成 PrintVFTable((VF_T*)&p);,因为此时取的是整个虚表区域的首地址地址,无法定位我们所需要虚表的首地址,打印时会出错

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

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

    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. 实际调用时,父类指针的调用逻辑是一致的:比如虚表第一个位置调用第一个函数,虚表第二个位置调用第二个函数,但是因为此时的虚表是切片得到的,所以 同一位置 可以调用到不同的函数,这就是多态

    也就是说,父类和子类的虚表其实是不一样的,在构成重写的前提下!
    这就是多态!

    int main()
    {
    	Person* p1 = new Person();
    	Person* p2 = new Student();
    
    	p1->func1();
    	p2->func1();
    
    	delete p1;
    	delete p2;
    	return 0;
    }
    

    通过汇编代码来看的话:
    在这里插入图片描述
    在这里插入图片描述

    动态绑定与静态绑定

      其实我们想一想,函数重载某种程度上也是一种多态,也是一个函数面对不同对象的时候有不同的效果,但是不同的是,重载在编译的时候就确定了待调用函数的地址,而动态绑定的代码,待调用地址存放在 eax 中,不确定
    在这里插入图片描述

    五、那…那单继承甚至多继承呢?

      坦白说,这很麻烦,我也不敢说我很懂,于是我在这里贴两篇文章,大家自行参阅吧!

    C++虚函数表解析
    C++对象的内存布局


    总结

      我们终于学完三大面向对象特性了,坦白说,多态还是蛮困难的,但是,我们难度的最高峰再过几篇就要来了,怕不怕!

  • 相关阅读:
    AtCoder Beginner Contest 279 G. At Most 2 Colors(计数/组合数学/dp递推)
    Oxygen XML Editor 25.0.X Crack
    月薪12K,蝶变向新,勇往直前—她通过转行测试实现月薪翻倍~
    Learn Prompt- Midjourney 图片生成:Image Prompts
    [附源码]计算机毕业设计基于SpringBoot的毕业生就业系统
    小红书热点是什么,怎么找到热点话题!
    基于训练和推理场景下的MindStudio高精度对比
    CPU是如何执行任务的
    c编译器学习02:chibicc文档翻译
    【Flask基础】一,app对象的初始化与静态参数配置
  • 原文地址:https://blog.csdn.net/2301_80392199/article/details/143415459