• 【C++】多态


    前言

            hi~ 大家好呀,欢迎来到我的C++笔记系列~

            本篇我会揭开C++中多态这一层神秘的面纱,理解C++中多态的实现,底层逻辑。做到比较熟悉的掌握C++面向对象的相关知识。

    上一篇的C++笔记在这里哦~

    【C++】继承- 赋值兼容转换、虚基表_柒海啦的博客-CSDN博客​​​​​​​C

            废话不多说,我们直接开始吧:

     (ams冲冲冲~)

    目录

    一、多态概念

    1.虚函数-函数重写

    virtual关键字

    2.形成多态的条件

    二、多态底层原理

    1.虚函数表

    2.静态绑定和动态绑定

    3.单继承中和多继承中的虚函数表

    打印虚函数表

    多继承中的虚表

    三、抽象类

    纯虚函数

    四、多态相关面试问题


    一、多态概念

            首先,多态是什么?

            多态(英语:polymorphism)指为不同数据类型的实体提供统一的接口多态类型(英语:polymorphic type)可以将自身所支持的操作套用到其它类型的值上。(百度百科)

            通俗的来讲,就是去完成某一个行为,不同的对象执行出来的状态不一样

            比如动物可以发出叫声,那么是鸡的话就会发出只因(bushi)叫,是猫的话就会发出喵~叫,是狗的话会发出汪汪叫。这种就是一种多态。

            那么,了解了多态后,C++下面是如何实现多态的呢?

    1.虚函数-函数重写

            首先利用一个程序引入C++下的多态的实现:

    1. class animal
    2. {
    3. public:
    4. virtual void call()
    5. {
    6. cout << "动物叫" << endl;
    7. }
    8. };
    9. class bird : public animal
    10. {
    11. public:
    12. virtual void call()
    13. {
    14. cout << "叽叽喳喳" << endl;
    15. }
    16. };
    17. class cat : public animal
    18. {
    19. public:
    20. virtual void call()
    21. {
    22. cout << "喵~" << endl;
    23. }
    24. };
    25. class dog : public animal
    26. {
    27. public:
    28. virtual void call()
    29. {
    30. cout << "汪!" << endl;
    31. }
    32. };
    33. void Call(animal& o)
    34. {
    35. o.call();
    36. }
    37. int main()
    38. {
    39. animal a;
    40. bird b;
    41. cat c;
    42. dog d;
    43. Call(a);
    44. Call(b);
    45. Call(c);
    46. Call(d);
    47. return 0;
    48. }

            运行结果: 

             可以看到,我们传入不同的对象完成相同的动作发生的状态并不相同。这就是C++中的多态。可以发现,多态是和继承相关的,也就是说需要和继承联系在一起。

            观看上面的代码,可以看见老熟人virtual了。

    virtual关键字

    格式:

            virtual 返回类型 函数名.....

    作用:

            定义虚函数,为继承后的多态做准备。(基类)

            三同规则:函数名相同、参数列表相同、返回值相同(派生类),此时派生类构成虚函数重写。(派生类相同虚函数可以不用带关键字virtual,但是基类必须要带的)否则就是隐藏(如果相同函数名不是构成虚函数重写就是隐藏)

            虚函数的重写:派生类中有一个和基类完全相同的虚函数(三同),称派生类的虚函数重写了基类的虚函数。

             假如满足虚函数的重写了,那么如何形成多态呢?

    2.形成多态的条件

             观察我们上面所写的程序,可以发现最后汇总于Call函数时,参数是一个基类对象的引用。传参的时候根据继承时所学的,发生了赋值切片转化。

    形成多态的两个条件:

            1.虚函数重写

            2.基类指针、引用去调用虚函数

    特例:

            1.子类虚函数没写virtual,也可构成重写 (最好加上 virtual)

            2.重写值的协变。返回值可以不同,但要求返回值为父子关系为指针或引用。(父是父类指针/引用,子是子类指针/引用

            3.如果基类析构函数定义为虚函数,那么就会发生析构函数的重写,派生类继承基类,编译器会将父子的析构函数处理为distructor,这样就会方便重写。(表面上是不一样的)(建议继承中析构函数重写)

            我们这里来演示一下第二个特例:

    1. class A
    2. {
    3. public:
    4. virtual A& test()
    5. {
    6. cout << "A::test" << endl;
    7. return *this;
    8. }
    9. };
    10. class B : public A
    11. {
    12. public:
    13. B& test() // 子类返回子类引用(引用对引用, 指针对指针)
    14. {
    15. cout << "B::test" << endl;
    16. return *this;
    17. }
    18. };
    19. int main()
    20. {
    21. B b;
    22. A& a = b; // A* a = b
    23. a.test(); // a->test()
    24. return 0;
    25. }

             这种特例条件下多态也是可以的。

            然后看一看析构函数的重写吧~

    1. virtual ~A()
    2. {
    3. cout << "delete::A" << endl;
    4. }
    5. ~B()
    6. {
    7. cout << "delete::B" << endl;
    8. }
    9. int main()
    10. {
    11. A* a1 = new A;
    12. A* a2 = new B;
    13. delete a1;
    14. delete a2;
    15. return 0;
    16. }

             注意只有正确的实现多态即定义基类析构为虚函数才可以哦,否则这里还是只会调基类的析构函数,并没有发生重写。

            既然我们能够实现C++中的多态了,那么基于底层,多态是如何能做到这一点让虚函数重写实现多态的呢?

    二、多态底层原理

            我们首先利用编译器底下的变量属性窗口去看一下我们上面的第一个程序:

             可以看到,在基类定义了函数后,出现了一个属性,__vfptr。在接下来继承的派生类中基类域中都有这个属性存在。实际上__vfptr就是虚函数表指针

    1.虚函数表

            首先我想问个问题,在基类A中如果对其求大小是多少呢?sizeof(a):

            结果为4。因为当前是在32位环境下运行的,所以指针存储的就是4byte。这个指针就是上面我们测试的 __vfptr虚函数表指针,它指向虚函数表。

            我们查看上面的虚表,虽然成员相同,但是实际上里面存放的地址是不一样的,所以,这也就是多态实现的原因:

    虚函数表

            1.函数声明虚函数后,会在对应域中留下个指针,指向一个虚函数表。

            2.派生类继承后,会在对应的基类域中留下虚函数表指针,如果完成虚函数重写,就会把原来虚函数表内的指针给覆盖掉。

            3.虚函数表实际上就是一个指针数组。存储的指针就是指向函数实现的地址(这里稍微有点复杂的指向,要经过多次变换),一般此数组最后放了一个nullptr。

            4.虚函数和普通函数一样,存在代码段的,对象里存的是指向虚函数表的指针,虚表里存的是虚函数的指针,而虚表在vs下是存在代码段的。

             5.多态调用时,根据切片(此时对象为基类指针或者引用)调用对应派生类中基类域的虚函数,根据虚函数表找到对应虚函数指针进行调用虚函数,完成多态。

            6.派生类自己新增加的虚函数按其在派生类的声明次序增加到虚表的后面。

            下面我们利用新的代码去解释上面的知识点:

    1. class P
    2. {
    3. public:
    4. virtual void call1()
    5. {
    6. cout << "P::call1" << endl;
    7. }
    8. virtual void call2()
    9. {
    10. cout << "P::call2" << endl;
    11. }
    12. };
    13. class S : public P
    14. {
    15. public:
    16. virtual void call1() // 重写了call1虚函数
    17. {
    18. cout << "S::call1" << endl;
    19. }
    20. virtual void call3() // 派生类新增虚函数
    21. {
    22. cout << "S::call3" << endl;
    23. }
    24. virtual void call4() // 派生类新增虚函数
    25. {
    26. cout << "S::call4" << endl;
    27. }
    28. };
    29. void test(P& p)
    30. {
    31. p.call1();
    32. p.call2();
    33. }
    34. int main()
    35. {
    36. P p;
    37. S s;
    38. test(p);
    39. cout << endl;
    40. test(s);
    41. cout << endl;
    42. s.call1(); // 直接调用虚函数
    43. return 0;
    44. }

            首先看运行结果:

      

             很明显,我们可以看到S类(派生类)没有完成对call2函数的重写,所以没有发生多态调用。那么我们来细致的观察一下多态调用的过程:

    2.静态绑定和动态绑定

            我们可以看一看汇编是如何处理的:

    1. p.call1();
    2. 006C67E1 mov eax,dword ptr [p]
    3. 006C67E4 mov edx,dword ptr [eax]
    4. 006C67EB mov eax,dword ptr [edx]
    5. 006C67ED call eax
    6. 006C67EF cmp esi,esp

    解释

    006C67E1  p中存的是派生类对象的引用,将p移动到eax中。(eax是一种寄存器) 

    006C67E4  [eax]就是取出eax中的数据,相当于把p中对象头四个字节移动到了edx 
    006C67EB  同理,[edx]取出对应内容(可以理解为指针解引用),然后在把其中存的四个字节移动到eax(此时移动的数据就是虚函数指针)  
    006C67ED  找到对应的虚函数,建立栈帧进行函数调用。

    结论

            可以发现,并不是编译就决定了函数地址,而是在运行中需要根据传入的对象,找到对象虚函数表中对应存的那个虚函数指针进行调用。而这个指针并不确定

            再来看看派生类对象直接调用虚函数。(不满足多态条件)

    1. s.call1(); // 直接调用虚函数
    2. 006C69DB lea ecx,[s]
    3. 006C69DE call S::call1 (06C156Eh)

    解释

            虽然call1是一个虚函数,但是不满足多态的条件,直接在对象取出对应虚函数地址,直接call地址。此时编译就已经确定地址了,运行直接到指定位置调用即可。 

            综上,我们发现多态调用和普通函数调用的不同:

    1.静态绑定:

            在程序的编译阶段就已经确定了程序的行为,也叫作静态多态(函数重载)

    2.动态绑定:

            在程序运行阶段拿到具体类型在决定程序的行为,调用具体的函数,也称为动态多态(多态)

            在进一步解析了多态的原理后,我们会发现其实我们派生类继承了基类后,也实现的有自己的虚函数(上面程序的call3和call4),那么它是不是就和结论说的那样加入继承得来的虚函数表吗?我们能否进行验证呢?

    3.单继承中和多继承中的虚函数表

            首先,我们对上面的程序观察变量窗口:

             可以看到,在vs这个编译器下看属性窗口似乎看不到派生类新加入的虚函数,难道是不会加入这个虚函数表吗?并不是这样的,我们可以先看看内容窗口:

             可以看到,虚函数表内果然存在着其他指针的,我们知道虚函数表一般以nullptr结束,所以上面保存的应该都是指针,那么具体是什么呢?我们能否根据这个虚函数表具体的用程序输出出来呢? 

    打印虚函数表

            不知道大家还记得C语言学过的函数指针吗?我用下面这个程序让大家快速拾起对函数指针的记忆:

    1. void test1()
    2. {
    3. cout << "测试函数" << endl;
    4. }
    5. int main()
    6. {
    7. P p;
    8. void (*test) () = test1;
    9. test();
    10. return 0;
    11. }

            可以看到,函数指针的定义格式是优先和*结合表示这是一个指针,然后在表示这个指针类型是void ()类型的函数。(其他类型的对应返回值和参数进行改变即可)

            那么这里我们想要从函数表中把函数打印出来,首先自然要获得函数指针的二级指针,也就是专门保存函数指针的数组。为了方便利用函数指针数组,我们可以选择定义一个宏:

    函数指针宏:

          typedef  返回类型(*名字) (参数) 

             可以如上定义一个函数指针宏,这样就可以代表一个函数指针类型。

            那么,现在的关键是如何取出来。观察内存窗口,我们不难发现虚函数表就是在对象空间的头4个字节(x86 - 指针类型),那么我们可以将此对象进行取地址,然后利用(int*)强转为int*类型在解引用就可以取出4字节的数据了(但此时是int类型的,需要转化为我们的函数二级指针类型)。

            那么,实现上述代码如下:

    1. typedef void(*VFPTR) (); // 定义函数指针的宏
    2. void PrintVTable(VFPTR* VTable)
    3. {
    4. // 首先打印虚函数表地址
    5. cout << "虚表地址:" << VTable << endl;
    6. for (int i = 0; VTable[i] != nullptr; ++i) // 虚表以nullptr结束
    7. {
    8. printf("第%d个虚表地址:0x%x, ->", i, VTable[i]);
    9. VFPTR f = VTable[i]; // 传递函数指针
    10. f(); // 调用打印
    11. }
    12. cout << endl;
    13. }
    14. int main()
    15. {
    16. P p;
    17. S s;
    18. VFPTR* VTable1 = (VFPTR*)(*((int*)(&p)));
    19. VFPTR* VTable2 = (VFPTR*)(*((int*)(&s)));
    20. PrintVTable(VTable1);
    21. PrintVTable(VTable2);
    22. return 0;
    23. }

            这样,我们就能制作出一个打印虚函数表(只能打印void无参数的函数)。针对之前的那个程序,这下我们可以直观的讲类型P和类型S的对象内虚函数表存的虚函数给打印出来了:

     

             终于,我们将此虚函数表打印了出来,可以看到,派生类新增的虚函数会按照声明顺序添加入虚函数表,并且完成多态的,虚函数表内指针就会完成覆盖,完美验证了我们之前得出的结论。

            那么在多继承下,虚函数表又是如何的呢?

    多继承中的虚表

            首先,我们写下如下简单的一个多继承:

    1. class A
    2. {
    3. public:
    4. virtual void test1()
    5. {
    6. cout << "A::test1" << endl;
    7. }
    8. virtual void test2()
    9. {
    10. cout << "A::test2" << endl;
    11. }
    12. };
    13. class B
    14. {
    15. public:
    16. virtual void test1()
    17. {
    18. cout << "B::test1" << endl;
    19. }
    20. virtual void test2()
    21. {
    22. cout << "B::test2" << endl;
    23. }
    24. };
    25. class C : public A, public B
    26. {
    27. public:
    28. virtual void test1() // 虚函数重写
    29. {
    30. cout << "C::test1" << endl;
    31. }
    32. virtual void test3() // 派生类新增虚函数
    33. {
    34. cout << "C::test3" << endl;
    35. }
    36. };

            首先可以观察一下vs下的属性窗口:(可以在main函数创建一个C对象c,打断点调试查看)

     

             可以看到,在多继承的情况下,派生类继承两个基类,对应的两个基类都有对应的虚表。并且,因为我们程序故意写的原因,两个虚表中的test1均同名。还是老毛病显示不了派生类新增虚函数test3的位置,我们能否利用上面的虚函数表打印出来呢?

            首先来确定一下第二个虚表的位置。第一个虚表就是对象内存中的头4个字节,那第二个虚表呢?

             可以看到就是相差4个字节。是吗?还是说只是A类域只有这四个字节的数据呢?别忘了之前继承所学,多继承对应的有着不同的类域,并且按照继承的先后顺序在对象中存储的先后顺序就不同,那么我们可以给A加上一个int变量再来试试:

             看来丞相所言极是,所以如果我们想要找到下一个虚表的地址的话,就要在第一个虚表的位置前提下加上第一个基类域所占空间的字节数。

            所以,第一个虚表好找,直接去对象的前四个字节解引用后转化为对应的函数指针数组即可,而第二个就可以先将其首元素地址移动到第一个基类域占用大小字节之后,此时在取4个字节进行解引用在找:

    1. int main()
    2. {
    3. C c;
    4. VFPTR* Vtable1 = ((VFPTR*)*((int*)&c));
    5. VFPTR* Vtable2 = ((VFPTR*)*((int*)((char*)&c + sizeof(A)))); // 谨慎+小心
    6. PrintVTable(Vtable1);
    7. PrintVTable(Vtable2);
    8. return 0;
    9. }

             运行结果:

             根据运行结果,可以发现,多继承后的派生类如果新增虚函数的话,会填到第一个继承的基类域下的虚表下。(其实派生类重写两个域相同虚函数名字的两个均会被重写-覆盖~)

    三、抽象类

            在了解了基本的单继承多继承下的虚函数表后我们再来扩展一个和虚函数相关的:抽象类:

            抽象类往往用来表征对问题领域进行分析、设计中得出的抽象概念,是对一系列看上去不同,但是本质上相同的具体概念的抽象。

            通常在编程语句中用 abstract 修饰的类是抽象类。在C++中,含有纯虚拟函数的类称为抽象类,它不能生成对象;在java中,含有抽象方法的类称为抽象类,同样不能生成对象。

    抽象类是不完整的,它只能用作基类。在面向对象方法中,抽象类主要用来进行类型隐藏和充当全局变量的角色。(--百度百科)

    纯虚函数

            首先来看一下纯虚函数的定义:

            virtual 函数名() = 0;

            纯虚函数相当于只是声明一下。

            纯虚函数表示当前类为抽象类,不可被实例化。被继承的派生类如果不重写此纯虚函数,那么也还是抽象类。只有被重写后,才不是抽象类。

            我们可以使用下面一个程序来解释并且证明上述结论:

    1. class ganfan // 干饭-嘿嘿.....
    2. {
    3. public:
    4. virtual void pay() = 0; // 纯虚函数
    5. };
    6. class Phone : public ganfan
    7. {
    8. public:
    9. virtual void pay()
    10. {
    11. cout << "微信支付&支付宝支付" << endl;
    12. }
    13. };
    14. class CampusCard : public ganfan
    15. {
    16. public:
    17. virtual void pay()
    18. {
    19. cout << "滴~刷卡成功!" << endl;
    20. }
    21. };
    22. int main()
    23. {
    24. ganfan* p = new Phone; // 注意此处是切片,进行多态调用
    25. ganfan* c = new CampusCard;
    26. p->pay();
    27. c->pay();
    28. return 0;
    29. }

     

     

    四、多态相关面试问题

            相信关于多态还有很多根类与对象相互是否兼容的问题,这里我都一一整理出来方便今后的复习与反思:

    1.inline可以是虚函数吗?
        可以。inline需要展开,编译时不存在地址,但是虚函数需要将其地址存入虚表中,表现上来说,两者是互斥的。但是需要注意,inline只是一个建议性关键字,关键取决于编译器,不会强制性执行。两者关键字存在的时候,如果是多态调用,编译器会自动忽略inline这个建议,因为没法将这个虚函数直接展开,这个建议无了。不是多态就可以利用此建议。
    2.static函数可以是虚函数吗?
        不可以。静态成员函数没有this指针,直接利用类域指定的方式调用。虚函数都是为多态服务的。多态是运行时决议,而静态成员函数都是编译性决议。
    3.构造函数可以是虚函数吗?
        不可以。构造函数之前,虚表没有进行初始化。virtual函数是为了实现多态,运行时去虚表找对应虚函数进行调用。对象的虚表也是在构造函数的初始化列表进行初始化的。
    4.析构函数可以是虚函数。
    5.拷贝构造和赋值可不可以是虚函数?
        拷贝构造不可以,拷贝构造同样也是构造函数。
        赋值可以,但是没有意义。
            (但是可以简单实现一下父类给给子  ... ... )但是,赋值一般是同类对象之间数据进行拷贝,这样就不存在实际价值。
    6.对象访问普通函数快还是虚函数快?
        不构成多态一样快,否则普通函数快。
    7.虚函数表是在什么阶段生成的,存在哪里的?
        构造函数初始化列表初始化的是虚函数表指针,对象中也是存的指针。
        存在代码区--利用验证法,和只读常量或者静态变量的地址进行验证。

    未完待续哦~~~~ 

  • 相关阅读:
    缓存设计的创新之旅:架构的灵魂之一
    UE 材质,如何只取0~1之间的值,其余值抛弃
    零基础小白怎么自学 Python ,大概要多久?
    群晖(Synology)NAS 后台安装 Docker 后配置 PostgreSQL
    左旋肉碱除铁,左旋肉碱铁离子超标怎么解决?
    国家数据局正式揭牌,数据专业融合型人才迎来发展良机
    JavaSE——类和对象 重点梳理
    拉美巴西阿根廷媒体宣发稿墨西哥哥伦比亚新闻营销如何助推跨境出海推广?
    TransactionScope的使用
    线程的六种状态
  • 原文地址:https://blog.csdn.net/weixin_61508423/article/details/127650333