• 【C++】多态/虚表


    前言:

            此类问题在C++面试时易考察。

    目录

    一、概念

    二、虚表工作/运行原理

    1.虚函数在一个类内存储的大小

    2.对虚函数的访问(一维数组)

    3.单继承

    (1)虚函数继承情况

    (2)单继承存储的大小

    (3)基类子类调用情况

    4.多继承

    (1)存储的大小

    (2)继承情况

    (3)对虚函数的访问(二维数组)

    三、多态

    1.多态的条件

    2.联编/绑定/捆绑

    3.析构的使用——调用类型

    4.多态实例——虚析构函数产生多态

    5.多态的参数传递

    6.虚基类

    用途:

    虚基类的定义方式:

    虚继承后字节大小

    虚继承的调用顺序?


    一、概念

    多态是什么?
            多态即多种形态,多态表示不同的对象可以执行相同的动作,但要通过它们自己的实现代码来执行。

    多态的4种状态:重载多态、包含多态、参数多态、强制多态

    • 重载多态:函数重载与运算符重载
    • 包含多态:含有虚函数的多态
    • 参数多态:模板——类模板、函数模板
    • 强制多态:强制类型转化——static_cast,cost_cast...

    虚函数:

    什么函数不能声明为虚函数?

    五种:普通函数(非成员函数)、构造函数、内联函数、静态函数、友元函数

    二、虚表工作/运行原理

    1.虚函数在一个类内存储的大小

            如果一个类包含了虚函数,不管有多少的虚函数,则增加列一个指针的大小,该指针vptr叫虚指针,它指向一个虚表,该虚表中存放虚函数入口地址

    • 64位:8字节(指针大小)
    • 32位:4字节(指针大小)

    如下A类,sizeof(A)= 4,无论几个f都是4个字节

    普通函数不占类的空间大小 Class{ void fun()}; sizeof(A)=1

    空类大小为1 Class A{}; sizeof(A)=1

    2.对虚函数的访问(一维数组)

            virtual函数表称为vtable,他是一个包含函数指针的数组。每个含有virtual函数的类都有一个vtable。对于类中每个virtual函数,在vatble中都有一个包含函数指针的项,此函数指针指向该类对象的virtual函数版本。        

            每个含有virtual函数的类的对象,都含有一个指向该类的vtable指针。在类内有虚指针vptr,指针指向虚表vtable,虚表内存储本类中函数入口地址,存储的函数叫虚函数vfptr(f:函数)。对虚函数的范围可以通过先查找到虚表,然后在表内以数组名+下标的形式查数得到函数。

    • 存储:虚指针->虚表->虚函数
    • 访问:虚表->数组+下标

    以如下形式进行范围:

    1. int main()
    2. {
    3. A a;
    4. typedef void(*FUN)();
    5. FUN pf=NULL;
    6. FUN* (*((int*)(*((int*)&a)));
    7. pf();
    8. FUN* (*((int*)(*((int*)&a))+1);//对下一个的范围
    9. pf();
    10. }

    解析:

    • 函数指针:FUN
    • pf函数指针类型,指向返回值为void 无参的函数FUN
    • a是A类型的对象
    • &a:取地址
    • (int*)&a 原A*类型转换为int*类型
    • *((int*)&a) 解引用,a的内容,虚表的地址
    • (int*)(*((int*)&a)) 强制转换类型
    • *((int*)(*((int*)&a)) 取内容,虚表首地址->第一个元素的地址——函数名
    • FUN*  (*((int*)(*((int*)&a)) )强转为FUN类型

    3.单继承

    (1)虚函数继承情况

    继承情况与普通函数的一致,如下代码所示,B继承A,在B内对fa重写,gb是B自己的:

    B继承流程:

    1. 全盘接收。B把A的虚表继承过来——内有三个x虚函数,A::fa,A::fb,A::bc
    2. 改写。同名同参虚函数fa被重写/覆盖,把内容修改为B::fa
    3. 添加。在后面添加B::gb,B::hb

    =》B内总共5个函数:fa,fb,fc,gb,hb

    也就是说,继承含有虚表时,该继承继承该改写改写与一般无虚表的继承无差别。

    (2)单继承存储的大小

    无论继承了多少个虚函数,还是一个指针的大小

    (3)基类子类调用情况

    1. B b;
    2. b.fa;

    👆输出的结果是B::fa

    1. void test(A a)//子类对象不能接收基类,基类可接收子类(基类少,子类多)
    2. {
    3. a.fa();
    4. }
    5. void main()
    6. {
    7. B b;
    8. test(b);//将子类对象传给基类
    9. }

    👆此时输出结果为A::fa,因为此处没有虚函数因此没有产生多态。(多态在下面讲)

    4.多继承

    (1)存储的大小

    存储大小为:继承的类个数*一个指针的大小

    如下代码,sizeof(D)=12:3*4

    1. class A
    2. {
    3. public:
    4. virtual void fa(){cout<"<
    5. virtual void ha(){cout<<
    6. };
    7. class B
    8. {
    9. public:
    10. virtual void fb(){cout<"<
    11. virtual void hb(){cout<<
    12. };
    13. class C
    14. {
    15. public:
    16. virtual void fc(){cout<"<
    17. virtual void hc(){cout<<
    18. };
    19. class D :public A,public B,public C
    20. {
    21. public:
    22. virtual void fd(){cout<"<
    23. virtual void hd(){cout<<
    24. };

    单继承与多继承的字节大小?

    • 单继承时,无论内部几个虚表都是一个指针的大小
    • 多继承时,继承n个类,大小为n*指针的大小。有几个类就有几个虚指针

    (2)继承情况

    ①子类的虚表跟在哪个子类的虚表后面?

    • 谁先继承就先跟在哪,D先继承A,就把D的虚表挂在A虚表后面,查看继承下来A的虚表内个数:

     如下图所示:

    • 其中A,B,C的虚表为:
    • D的虚表为:
    • 三个虚指针vptr指向三个虚表(基类有几个就有几个虚表)

    因此,子类内有虚函数时,会把子类新添加的虚函数挂到第一个父类的虚函数后面

    • 笔试题如果问:该例虚表的运行原理?
    • 可以用上面代码+画图+一些语言描述回答

    ②如果基类们ABC有相同的函数f,D内重写f函数,重写了哪些类的?

    基类的同名同参函数都会被改写

    (3)对虚函数的访问(二维数组)

    1. int main()
    2. {
    3. D d;
    4. FUN pf=NULL;
    5. FUN* (*((int*)(*((int*)&d)));//0行0列
    6. pf();
    7. FUN* (*((int*)(*((int*)&a))+1);//0行1列
    8. pf();
    9. FUN* (*((int*)(*((int*)&a))+2);//0行2列
    10. pf();
    11. FUN* (*((int*)(*((int*)&a))+3);//0行3列
    12. pf();
    13. FUN* (*((int*)(*((int*)&a)+1));//1行0列
    14. pf();
    15. FUN* (*((int*)(*((int*)&a)+1)+1);//1行1列
    16. pf();
    17. FUN* (*((int*)(*((int*)&a)+2));//2行0列
    18. pf();
    19. ...
    20. }

    总结:

    • 继承:
      • 单继承与普通函数的继承一样
      • 对于多继承,在子类的对象中,每个父类都有自己的虚表,将最终子类的虚函数放在第一个父类的虚表中,使得不同父类类型指针的指向清晰。
    • 类的大小:
      • 单继承时,无论内部几个虚表都是一个指针的大小
      • 多继承时,继承n个类,大小为n*指针的大小。有几个类就有几个虚指针
    • 改写/覆盖情况:
      • 单继承多继承一样:如果在子类中重写了父类们中的同名同参虚函数,那么虚表中同样修改。也就是基类提供一个virtual成员函数时,派生类可以重写此virtual函数,但并不是必须的。

    三、多态

    1.多态的条件

    • 覆盖/重写(两个类之间必须是父子关系、最少两个类)
    • 同名同参虚函数
    • 基类指针或者引用指向基类对象或者派生类对象

    2.联编/绑定/捆绑

            联编是指计算机程序彼此关联的过程,是把一个标识符名和一个存储地址联系在一起的过程,也就是把函数的调用和函数的入口地址相结合的过程

    • 联编种类:早捆绑、晚捆绑;早期联编、晚期联编
    • 静态联编(static binding)早期绑定:静态联编是指在编译和链接阶段,就将函数实现和函数调用关联起来。(编译:检查语法错误)
      • C语言中,所有联编都是静态联编,并且任何一种编译器都支持静态链表。
      • C++语言,函数重载和函数模板也是静态联编。
    • 动态联编(dynamic binding)滞后联编/晚期联编:是指在程序执行的时候才将函数的实现和函数调用关联起来。
      • C++语言中,实用类类型的引用或指针调用虚函数(成员选择符"->"),则程序在运行时选择虚函数的过程,称为动态联编。(运行:检查逻辑错误)
      • 动态绑定需要在运行时把virtual成员函数的调用传送到恰当类virtual函数的版本。

    3.析构的使用——调用类型

    ①如下代码中 ​​​​​​编译时输出:A::fn,B::fn。

    此时,基类与子类函数同名均无参,单基类有Virtual函数,不满足隐藏规则,因此子类b并未对子类的函数进行覆盖,但是满足虚函数子类对基类,同名函数进行改写的条件,因此子类改写为B::fn。子类类型的对象b调用fn()函数的输出结果就是B::fn()

     

    ②如下代码中的调用情况输出为: A::fn A::fn

    因为test函数参数类型为A类,aa:A类。因此,用aa调用fn就是A类内fn。实参进行传递时,并未对形参的类型进行改变,因为在编译时就确定了调用类型

    这是在编译时确定了调用类型,而不是通过参数传递,也就是说这种情况下没有联编上,那么如何联编上?

    不能通过上例所示的值类型进行传递,值类型传递时调用拷贝构造复制,没有传递b参数本身,需要修改为引用与指针类型。

    Ⅰ修改为引用:

    Ⅱ指针

            如果不是虚函数的话,就没有多态,无论如何改变参数类型,结果都是A::fn,因为A和B类具有同名无参函数fn,而且基类中不含virtual函数,此时满足了隐藏的规则。可是test类型的参数是基类的,是用基类对象进行调用。因此,即便B内有2个fn——A::fn和B::fn,可是无论如果调用B,都没有用子类对象进行调用,调用的都是A的。

            只有是虚函数才查虚表,不是的话就直接调基类内的函数。在调用时,被覆盖就调用子类的,没覆盖就基类的。

    ③还有一点对函数的访问:

    Ⅰ基类指针无法调用基类中没有的子类函数,因为基类中不存在

    Ⅱ如果子类没有对基类的函数进行重写,基类的调用并无意义

    • 总结:
      • 是虚函数,满足多态进行联编时,类型必须为指针或者引用类型,值类型不可以。
      • 不是虚函数,如果是基类对象调用函数,无论参数形式如何,都是调用基类函数
      • 基类类型的指针无法调用基类中没有的子类函数
      • 如果子类没有对基类的函数进行重写,基类的调用并无意义

    4.多态实例——虚析构函数产生多态

    C++中构造函数不能定义为虚函数

            因为虚函数调用只需要“部分的”信息,即只需要知道函数接口,不需要对象的具体类型但是构建一个对象,却必须知道具体的类型信息。如果程序员调用一个虚构造函数,编译器不知道程序员想构建是继承树上的哪种类型,所以构造函数不能为虚。

     为什么构造函数不可以是虚函数

    1. 构造函数的用途:1,创建对象 2,初始化对象中的属性 3,类型转换
    2. 在类中定义了虚函数就会有一个虚函数表(vftable),对象模型就含有一个指向虚表的指针vfptr。在定义对象时构造函数设置虚表指针指向虚函数表。
    3. 使用指针和引用虚函数,在编译只需要知道函数接口,运行时指向具体对象,才能关联具体对象的虚方法(通过虚函数指针查虚函数表得到具体对象的虚方法)
    4. 构造函数是类的一个特殊的成员函数
    5. 如果构造函数可以定义为虚构造函数,使用指针调用虚构造函数,如果编译器采用静态链表,构造函数就不能为虚函数。如果采用动态联编,运行时指针指向具体对象,使用指针调用构造函数,相当于已经实例化的对象在调用构造函数,这是不允许的调用,对象的构造函数只执行一次。
    6. 如果构造函数可以定义为虚构造函数,通过查虚函数表,调用虚构造函数,那么当指针为nullptr,如何查虚函数表?
    7. 构造函数的调用是在编译时确定,如果是虚构造函数,编译器怎么会知道程序员项构建的是继承树上的哪类类型?

    综上,构造函数不允许是虚构造函数

    析构函数可以是虚析构函数:

    虚析构函数是类的一个特殊成员函数

    1. 当一个对象的生命周期结束时,系统会自动调用析构函数注销该对象并进行善后工作,对象自身也可以调用析构函数
    2. 析构函数的善后工作是释放对象在生命周期内获取的资源(如动态分配的内存、内核资源等)
    3. 析构函数也用来执行对象即将被撤销之前的任何操作
    4. 虚析构函数可以让基类指针调派生类析构函数,而派生类的又会调用基类的析构函数,完成彻底析构的操作
    5. 基类不是虚析构函数,可能会让子类的析构函数不被调用,从而分配的内存空间没被释放。也可能会导致子类的变量空间也没被释放,因为子类析构函数没被调用,那么成员变量的析构肯定也不会被掉。

     综上,程序员最好把析构函数定义为虚函数,但注意如果类内没有指针,就不要把析构函数定义为虚

    以如下代码示例:        

            先看基类的析构函数不是虚析构时的情况:

            在A类内定义一个指针对象,并对构造和析构函数进行输出,以便在调用时进行查看。在B类对A进行继承,然后执行相同的操作。主函数内定义基类指针pb指向子类对象B。

    问调用输出结果为?

    答案:AB~A

    原因:代码执行时,因为pb是A类类型的指针,因此析构时只析构基类。

    1. class A
    2. {
    3. public:
    4. A()
    5. {
    6. cout<<"A"<
    7. m_i=new int;
    8. }
    9. ~A()
    10. {
    11. cout<<"~A"<
    12. delete m_i;
    13. }
    14. private:
    15. int *m_i;
    16. };
    17. class B:public A
    18. {
    19. public:
    20. B()
    21. {
    22. cout<<"B"<
    23. m_j=new int;
    24. }
    25. ~B()
    26. {
    27. cout<<"~B"<
    28. delete m_j;
    29. }
    30. private:
    31. int *m_j;
    32. };
    33. void main()
    34. {
    35. A*pb=new B;
    36. delete pb;
    37. }

    虽然指向了基类析构不释放子类,那么子类怎么释放才能防止内存泄漏呢?

    答:添加虚表,进行动态绑定,如下代码所示,将基类的析构设置为虚析构函数,此时执行结果就是AB~B~A

    为什么不是同名还可以产生多态?

    系统会所有析构函数的名字变成一个名字。

    基类写了虚,可以继承在子类内 ,光标放在该函数下也可以看到有前缀virtual,当然也可以在~B前写上虚

    总结:类内有指针作为数据成员,必须要写析构函数,如果当前类被继承了,则析构函数写成virtual,为了实现多态,将子类的空间合理地释放,防止内存泄漏。

    5.多态的参数传递

            如下代码parent类中定义带默认参数的虚函数fn,子类child函数继承基类parent,也定义同名带默认参数的fn函数,主函数定义child类型对象cc,parent类指针p指向child类的cc,并调用fn函数,参数传递为200,问输出结果?

    答:输出结果为b=200。输出了实际实参值,因为p指向派生类cc,p调用fn函数,调用的是子类的。将实参200传递给形参100,对结果进行输出。

    按照下面代码中,基类指针p直接进行调用,而不传参,输出结果为?

    答:输出为b=10,因为进行了动态绑定,但是虚函数默认参数是静态的。

    子类类型调用输出为?

    答:输出为b=100,虚函数带了默认值,默认值是静态绑定的。

    总结:子类中重新定义虚函数(子类内重写/覆盖),并未重定义继承来的参数的值,除非在主函数调用时实际传参进行修改,或者 本类对象直接调用,否则参数值就是静态绑定的,默认值不变。

    想要修改默认值:

    • 自己传参,实际调用时传递参数
    • 本类对象进行调用

    6.虚基类

    用途:

            可以把共同基类设置为虚基类,这样从不同路径继承下来的同名数据成员在内存中就只有一个拷贝,同名函数也只有一种映射。

    虚基类的定义方式:

    class 派生类名:virtual 访问限定符 基类类名{...};

    class 派生类名:访问限定符 virtual 基类类名{...};

    这样构造后,virtual关键字只对紧随其后的基类名起作用 

    注意,此处的“virtual”与虚函数中的虚并无关系,这是类的虚继承。

    代码示例:

    假设两个基类,一个沙发类和一个床类:

    子类沙发床:

            此时如果在main函数内 SofaBed ss; ss.sit();这样调用是不对的,因为sit()不明确,sofabed内有两个sit——sofa::sit,bed::seit。需要对其显示调用——ss.Sofa::sit()。

    但是如何让类内相同的东西(sofa::sit,bed::sit)在子类中都只继承一份呢?引入虚基类:

    提取sofa和bad共同属性,建立一个基类:Furniture,

     

    在继承的基类处添加"virtual"

    此时,虚基类的残生就可以防止产生如下的菱形形式的多继承:

    • 总结:有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般建设设计处多继承,不要设计出菱形继承。否则在复杂度及性能上就都有问题。

    虚继承后字节大小

    原大小:4字节(一个int类型的成员变量)

    单继承添加为虚继承后:8字节(指向一个虚基类的虚指针4字节)

    多继承添加为虚继承后:4*n+4字节(n个指向虚函数的虚指针+成员函数4字节,两个成员函数相同,在虚继承下,相同的只被继承一份)

    虚继承的调用顺序?

    先按照顺序调用虚继承、然后按照顺序调用非虚继承、组合,最后自己

    如下代码,输出结果为:CABDE

     

  • 相关阅读:
    神经网络的图像识别技术,神经网络识别物体形状
    Prometheus之Dockerfile编写、镜像构建、容器启动
    CAD中角度如何平分、CAD特性匹配的作用是什么?
    RocketMQ安装和使用
    网络工程类面试非技术问题
    毕业设计EMS办公管理系统(B/S结构)+J2EE+SQLserver8.0
    (done) 什么是词嵌入技术?word embedding ?(这里没有介绍词嵌入算法)(没有提到嵌入矩阵如何得到)
    网鼎杯预赛2022密码
    2041. 面试中被录取的候选人
    回归理性,直面低代码
  • 原文地址:https://blog.csdn.net/qq_53830608/article/details/128005158