前言:
此类问题在C++面试时易考察。
目录
一、概念
多态是什么?
多态即多种形态,多态表示不同的对象可以执行相同的动作,但要通过它们自己的实现代码来执行。
多态的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继承流程:
- 全盘接收。B把A的虚表继承过来——内有三个x虚函数,A::fa,A::fb,A::bc
- 改写。同名同参虚函数fa被重写/覆盖,把内容修改为B::fa
- 添加。在后面添加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,创建对象 2,初始化对象中的属性 3,类型转换
- 在类中定义了虚函数就会有一个虚函数表(vftable),对象模型就含有一个指向虚表的指针vfptr。在定义对象时构造函数设置虚表指针指向虚函数表。
- 使用指针和引用虚函数,在编译只需要知道函数接口,运行时指向具体对象,才能关联具体对象的虚方法(通过虚函数指针查虚函数表得到具体对象的虚方法)
- 构造函数是类的一个特殊的成员函数
- 如果构造函数可以定义为虚构造函数,使用指针调用虚构造函数,如果编译器采用静态链表,构造函数就不能为虚函数。如果采用动态联编,运行时指针指向具体对象,使用指针调用构造函数,相当于已经实例化的对象在调用构造函数,这是不允许的调用,对象的构造函数只执行一次。
- 如果构造函数可以定义为虚构造函数,通过查虚函数表,调用虚构造函数,那么当指针为nullptr,如何查虚函数表?
- 构造函数的调用是在编译时确定,如果是虚构造函数,编译器怎么会知道程序员项构建的是继承树上的哪类类型?
综上,构造函数不允许是虚构造函数。
析构函数可以是虚析构函数:
虚析构函数是类的一个特殊成员函数
- 当一个对象的生命周期结束时,系统会自动调用析构函数注销该对象并进行善后工作,对象自身也可以调用析构函数
- 析构函数的善后工作是释放对象在生命周期内获取的资源(如动态分配的内存、内核资源等)
- 析构函数也用来执行对象即将被撤销之前的任何操作
- 虚析构函数可以让基类指针调派生类析构函数,而派生类的又会调用基类的析构函数,完成彻底析构的操作
- 基类不是虚析构函数,可能会让子类的析构函数不被调用,从而分配的内存空间没被释放。也可能会导致子类的变量空间也没被释放,因为子类析构函数没被调用,那么成员变量的析构肯定也不会被掉。
综上,程序员最好把析构函数定义为虚函数,但注意如果类内没有指针,就不要把析构函数定义为虚。
以如下代码示例:
先看基类的析构函数不是虚析构时的情况:
在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