面向对象的三大特质之多态 | 什么是多态?虚表的工作原理是什么?

前言:

        此类问题在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:函数)。对虚函数的范围可以通过先查找到虚表,然后在表内以数组名+下标的形式查数得到函数。

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

以如下形式进行范围:

int main()
{
    A a;
    typedef void(*FUN)();
    FUN pf=NULL;
    FUN* (*((int*)(*((int*)&a)));
    pf();
    FUN* (*((int*)(*((int*)&a))+1);//对下一个的范围
    pf();
}

解析:

  • 函数指针: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)基类子类调用情况

B b;
b.fa;

👆输出的结果是B::fa

void test(A a)//子类对象不能接收基类,基类可接收子类(基类少,子类多)
{
    a.fa();
}
void main()
{
    B b;
    test(b);//将子类对象传给基类
}

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

4.多继承

(1)存储的大小

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

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

class A
{
public:
    virtual void fa(){cout<<A::fa"<<endl;}
    virtual void ha(){cout<<A::ha"<<endl;}
};
class B
{
public:
    virtual void fb(){cout<<B::fb"<<endl;}
    virtual void hb(){cout<<B::hb"<<endl;}
};
class C
{
public:
    virtual void fc(){cout<<C::fc"<<endl;}
    virtual void hc(){cout<<C::hc"<<endl;}
};
class D :public A,public B,public C
{
public:
    virtual void fd(){cout<<D::fd"<<endl;}
    virtual void hd(){cout<<D::hd"<<endl;}
};

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

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

(2)继承情况

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

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

 如下图所示:

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

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

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

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

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

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

int main()
{
    D d;
    FUN pf=NULL;
    FUN* (*((int*)(*((int*)&d)));//0行0列
    pf();
    FUN* (*((int*)(*((int*)&a))+1);//0行1列
    pf();
    FUN* (*((int*)(*((int*)&a))+2);//0行2列
    pf();
    FUN* (*((int*)(*((int*)&a))+3);//0行3列
    pf();
    FUN* (*((int*)(*((int*)&a)+1));//1行0列
    pf();
    FUN* (*((int*)(*((int*)&a)+1)+1);//1行1列
    pf();
    FUN* (*((int*)(*((int*)&a)+2));//2行0列
    pf();
...
}

总结:

  • 继承:
    • 单继承与普通函数的继承一样
    • 对于多继承,在子类的对象中,每个父类都有自己的虚表,将最终子类的虚函数放在第一个父类的虚表中,使得不同父类类型指针的指向清晰。
  • 类的大小:
    • 单继承时,无论内部几个虚表都是一个指针的大小
    • 多继承时,继承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类类型的指针,因此析构时只析构基类。

class A
{
public:
    A()
    {
        cout<<"A"<<endl;
        m_i=new int;
    }
    ~A()
    {
        cout<<"~A"<<endl
        delete m_i;
    }
private:
    int *m_i;
};

class B:public A
{
public:
    B()
    {
        cout<<"B"<<endl;
        m_j=new int;
    }
    ~B()
    {
        cout<<"~B"<<endl
        delete m_j;
    }
private:
    int *m_j;
};
void main()
{
    A*pb=new B;
    delete pb;
}

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

答:添加虚表,进行动态绑定,如下代码所示,将基类的析构设置为虚析构函数,此时执行结果就是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

 

  • 3
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值