虚函数表,以及虚函数指针:
1)每个有虚函数的类都有自己的虚函数表,每个包含虚函数的类对象都有虚函数表指针。
2)对于多重继承,如果多个基类都有虚函数,则继承类中包含多个基类虚函数表。
无覆盖时子类的虚函数地址放在声明的第一个基类虚函数表后面,有覆盖时基类的虚函数表被替换
Vptr与Vbptr
- 在多继承情况下,即使是多虚拟继承,继承而得的类只需维护一个Vbptr;
而多继承情况下Vptr则可能有要维护多个Vptr,视其基类有几个有虚函数。 - 一条继承线路只有一个Vptr,但可能有多个Vbptr,视有几次虚拟
继承而定。换言之,对于一个继承类对象来说,不需要新合成vptr,而是使用其基类子对象的vptr。而对于一个虚拟继承类来说,必须新合成一个自己的Vbptr。
三种对象模型
简单对象模型
一个C++对象存储了所有指向成员的指针,而成员本身不存储在对象中。也
就是说不论数据成员还是成员函数,也不论这个是普通成员函数还是虚函数,它们都存储
在对象本身之外,同时对象保存指向它们的指针。
简单对象模型对于编译器来说虽然极尽简单,但同时付出的代价是空间和执行期的效率.显而
易见的是对于每一个成员都要额外搭上一个指针大小的空间以及对于每成员的操作都增加了
一个间接层。因此C++并没有采用这样一种对象模型,但是被用到了C++中“指向成员的指针”
的概念当中。
表格驱动对象模型
它将对象中所有的成员都抽离出来在外建表,而对象本身只存储指向
这个表的指针。右图可以看到,它将所有的数据成员抽离出来建成数据成员表,将所有的函
数抽取出来建成一张函数成员表,而对象本身只保持一个指向数据成员表的指针。
侯大大认为,在对象与成员函数表中间应当加一个虚箭头,他认为这是Lippman的疏漏之处,
应当在对象中保存指向函数成员表的指针。
当然C++也没有采用这一种对象模型,但C++却以此模型作为支持虚函数的方案。
C++对象模型
所有的非静态数据成员存储在对象本身中。所有的静态数据成员、成员函数(包括静态与非
静态)都置于对象之外。另外,用一张虚函数表(virtual table)存储所有指向虚函数的指
针,并在表头附加上一个该类的type_info对象(该对象用以支撑动态运行绑定),在对象中则保存一个指向虚函数表的指针。如下图:
c++额外成本与对象大小
c++额外成本
virtual引起,包括:
- virtual function 机制,用来支持“执行期绑定”。
- virtual base class ——虚基类机制,以实现共享虚基类的 subobject。
一个类的对象的内存大小包括:
- 所有非静态数据成员的大小。
- 由内存对齐而填补的内存大小。
- 为了支持virtual有内部产生的额外负担。
如下类:
class ZooAnimal {
public:
ZooAnimal();
virtual ~ZooAnimal();
virtual void rotate();
protected:
int loc;
String name;
};
在32位计算机上所占内存为16字节:int四字节,String8字节(一个表示长度的整形,一个
指向字符串的指针),以及一个指向虚函数表的指针vptr。对于继承类则为基类的内存大小
加上本身数据成员的大小。
构造函数
构造函数是否会自动生成
通常很多C++程序员存在两种误解:
- 没有定义默认构造函数的类都会被编译器生成一个默认构造函数。
- 编译器生成的默认构造函数会明确初始化类中每一个数据成员。
在读《深度探索C++对象模型》之前,我一直停留在上述二种误解上,所幸的是
Lippman为我破除了藩篱。下面的部分我将随《深度探索C++对象模型》对C++默
认构造函数一探究竟。
C++标准规定:如果类的设计者并未为类定义任何构造函数,那么会有一个默认
构造函数被暗中生成,而这个暗中生成的默认构造函数通常是不做什么事的(无
用的),下面四种情况除外。
换句话说,有以下四种情况编译器必须为未声明构造函数的类生成一个会做点事
的默认构造函数。我们会看到这些默认构造函数仅“忠于编译器”,而可能不会按
照程序员的意愿程效命。
总结:简单来讲编译器会为构造函数做的一点事就是调用其基类或成员对象的默
认构造函数,以及初始化vprt以及准备虚基类的位置。
总的来说,编译器将对构造函数动这些手脚:
- 如果类虚继承自基类,编译器将在所有构造函数中插入准备虚基类位置的代
码和提供支持虚基类机制的代码。 - 如果类声明有虚函数,那么编译器将为之生成虚函数表以存储虚函数地址,
并将虚函数指针(vptr)的初始化代码插入到类的所有构造函数中。 - 如果类的父类有默认构造函数,编译将会对所有的默认构造函数插入调用其
父类必要的默认构造函数。必要是指设计者没有显示初始化其父类,调用顺
序,依照其继承时声明顺序。 - 如果类包含带有默认构造函数的对象成员,那么编译器将会为所有的构造函
数插入对这些对象成员的默认构造函数进行必要的调用代码,所谓必要是指
类设计者设计的构造函数没有对对象成员进行显式初始化。成员对象默认构
造函数的调用顺序,依照其声明顺序。
若类没有定义任何构造函数,编译器会为其合成默认构造函数,再执行上述
四点。
1.包含有带默认构造函数的对象成员的类
若一个类X没有定义任何构造函数,但却包含一个或以上定义有默认构造函数的
对象成员,此时编译器会为X合成默认构造函数,该默认函数会调用对象成员的
默认构造函数为之初始化。如果对象的成员没有定义默认构造函数,那么编译器
合成的默认构造函数将不会为之提供初始化,例如类A包含两个数据成员对象,
分别为:string str和char *Cstr
,那么编译器生成的默认构造函数将只提
供对string类型成员的初始化,而不会提供对char*类型的初始化。(因为string有默认构造函数,所以会初始化)
假如类X的设计者为X定义了默认的构造函数来完成对str的初始化,形如:
A::A(){Cstr=”hello”};因为默认构造函数已经定义,编译器将不能再生成一
个默认构造函数。但是编译器将会扩充程序员定义的默认构造函数——在最前面插
入对初始化str的代码。若有多个定义有默认构造函数的成员对象,那么这些成员
对象的默认构造函数的调用将依据声明顺序排列。
2.继承自带有默认构造函数的基类的类
如果一个没有定义任何构造函数的类派生自带有默认构造函数的基类,那么编译
器为它定义的默认构造函数,将按照声明顺序为之依次调用其基类的默认构造函
数。若该类没有定义默认构造函数而定义了多个其他构造函数,那么编译器扩充
它的所有构造函数——加入必要的基类默认构造函数。另外,编译器会将基类的默
认构造函数代码加在对象成员的默认构造函数代码之前。
3.带有虚函数的类
带有虚函数的类,与其它类不太一样,因为它多了一个vptr,而vptr的设置是由
编译器完成的,因此编译器会为类的每个构造函数添加代码来完成对vptr的初始
化。
4.带有一个虚基类的类
编译器要将虚基类在类中的位置准备妥当,提供支持虚基类的机
制。也就是说要在所有构造函数中加入实现前述功能的的代码。没有构造函数将
合成以完成上述工作。
拷贝构造函数(探讨编译器的工作)
通常C++初级程序员会认为当一个类为没有定义拷贝构造函数的时候,编译器会
为其合成一个,答案是否定的。编译器只有在必要的时候在合成拷贝构造函数。
那么编译器什么时候合成,什么时候不合成,合成的拷贝构造函数在不同情况下
分别如何工作呢?这是本文的重点。
定义
有一个参数的类型是其类类型的构造函数是为拷贝构造函数。如下:
X::X( const X& x);
Y::Y( const Y& y, int =0 );
//可以是多参数形式,但其第二个即后继参数都有一个默认值
拷贝构造函数的应用
当一个类对象以另一个同类实体作为初值时,大部分情况下会调用拷贝构造函数。
一般是这三种具体情况:
显式地以一个类对象作为另一个类对象的初值,形如X xx=x;
当类对象被作为参数交给函数时。
当函数返回一个类对象时。
后两种情形会产生一个临时对象。
何时生成拷贝构造函数(类没有的话编译器生成)
通常按照“成员逐一初始化的手法来解决“一个类对象以另一个同类实体作为
初值”——也就是说把内建或派生的数据成员从某一个对象拷贝到另一个对象身上,
如果数据成员是一个对象,则递归使用“成员逐一初始化(Default Memberwise
Initialization)”的手法。 以下四种情况,如果类没有定义拷贝构造函数,那么编译器将必须为类合成一个拷贝构造函数。
- 当类内含一个成员对象,而后者的类声明有一个拷贝构造函数时(不论是设
计者定义的还是编译器合成的)。 - 当类继承自一个声明有拷贝构造函数的类时(同样,不论这个拷贝构造函数
是被显示声明还是由编译器合成的)。 - 类中声明有虚函数。
- 当类的派生串链中包含有一个或多个虚基类。
前两者类内成员对象有拷贝构造函数说明希望完成一些附加工作;
后者主要是如果基类由其继承类进行初始化时,此时若按照位逐次拷贝来
完成这个工作,那么基类的vptr将指向其继承类的虚函数表
对于继承串链中有虚基类的情况,问题同样出现在继承类向基类提供初值。
成员初始化队列(Member Initialization List)
对于初始化队列,我相信理清一个概念是非常重要的:在构造函数中对于对象
成员的初始化发生在初始化队列中——或者我们可以把初始化队列直接看做是对
成员的定义,而构造函数体中进行的则是赋值操作。所以不难理解有四种情况
必须用到初始化列表:
- 有const成员
- 有引用类型成员
- 成员对象没有默认构造函数
- 基类对象没有默认构造函数
前两者因为要求定义时初始化,所以必须明确的在初始化队列中给它们提供初
值。后两者因为不提供默认构造函数,所有必须显示的调用它们的带参构造函
数来定义即初始化它们。
Data语意
C++类对象的大小
影响C++类的大小的三个因素:
- 支持特殊功能所带来的额外负担(对各种virtual的支持)。
- 编译器对特殊情况的优化处理。
- alignment操作,即内存对齐。
class X{};
class Y:virtual public X{};
class Z:virtual public X{};
class A:public Y, public Z{};
Lippman的一个法国读者的结果是:
sizeof X yielded 1
sizeof Y yielded 8
sizeof Z yielded 8
sizeof A yielded 12
A的大小是共享的X实体1字节,X和Y的大小分别减去虚基类带来的内存空间(虚基类开销),都是4。A的总计大小为9(8+1),alignment以后就是12了。
我在vs2010上的结果是:
sizeof X yielded 1
sizeof Y yielded 4
sizeof Z yielded 4
sizeof Z yielded 8
原因是编译器优化,共享实体地址继承到Y,Z没用,所以去掉
VC内存对齐准则
VC的内存对齐准则;同样的数据,
不同的排列有不同的大小,另外在有虚函数或虚拟继承情况下又有如何影响.
内存对齐的原因:
对于一台32位的机器来说如何才能发挥它的最佳存取效率呢?当然是每次都读4字节(32bit),
这样才可以让它的bus处于最高效率。要求数据的地址都是4的倍数,否则将对齐。
边界该如何调整
变量存放的起始位置应为变量的大小与规定对齐量中较小者的倍数。例如,假
设规定对齐量为4,那么char(1byte)变量应该存储在偏移量为1的倍数的地方,而整形变
量(4byte)则是从偏移量为4的倍数的地方,而double(8 byte)也同样应存储在偏移量为
4的倍数的地方,为什么不是8?因为规定对齐量默认值为4,而4 < 8。在VC中默认对齐量
为8,而非4。
结构体整体的大小也应该对齐,对齐依照规定对齐量与最大数据成员两者中较小的进行。
Vptr影响对齐而VbcPoint(Virtual base class pointer)不影响。
一个实例
class T {
char c;
int i;
double d;
}
将其sizeof输出后的大小为16,其内存布局如图T.变量c从偏移量为0开始存储,而整形i第一个
符号条件的偏移量为4,double型d的第一个符号条件的为8。整个对象的大小为16,不需要再进
行额外的对齐。
class L {
char c;
double d;
int i;
}
sizeof后的结果是24!同样是一个int,一个char,一个double却整整多出了8个字节。这期间
发生了什么?我们依据前面两条规则来看看(之所以是8,是因为结构体要跟最大数据成员double作对比)。C存储于0的位置,1~7都不能整除8,所以d存储
在8~15, 16给i正好合适,i存储在16~19。总共花费了20个字节,抱歉不是8的倍数,还得补
齐4个。现在你可以看看图L的关于类L的内存布局,再比较一下类L和类T的内存布局。
虚函数指针影响对齐而虚基类指针不影响
class X{char a;};
class Y: virtual public X{};
Y的大小为:a占一个字节,VbcPoint(我称他为虚基类指针)占四个字节。我们不论a与VbcPoint的位置如何摆放,如果将VbcPoint等同于一个成员数据来看的话,sizeof(Y)都应该为8.实际上它是5!就我目前的水平,我只能先将其解释为VbcPoint不参与对齐。
class X{
char a;
virtual int vfc(){};}
sizeof(X)的大小确实为8.
c++对象的数据成员
对于一个类来说它的对象中只存放非静态的数据成员,但是除此之外,编译器为了实现virtual功能还会合成一些其它成员插入到对象中。我们来看看这些成员的布局。
为了实现虚函数和虚拟继承两个功能,编译器一般会合成Vptr和Vbptr两个指针。
对于由虚拟继承而得的类,VC会在其每一个对象中插入一个Vbptr,这个Vbptr指向vitual base class table(我称之为虚基类表)。虚基类表中则存放有其虚基类子对象相对于虚基类指针的偏移量。例如声明如class Y:virtual public X的类的virtual base class table的虚基类表中当存储有X对象相对于Vbptr的偏移量。
Vptr与Vbptr
- 在多继承情况下,即使是多虚拟继承,继承而得的类只需维护一个Vbptr;
而多继承情况下Vptr则可能有要维护多个Vptr,视其基类有几个有虚函数。 - 一条继承线路只有一个Vptr,但可能有多个Vbptr,视有几次虚拟
继承而定。换言之,对于一个继承类对象来说,不需要新合成vptr,而是使用其基类子对象的vptr。而对于一个虚拟继承类来说,必须新合成一个自己的Vbptr。
class X{
virtual void vf(){};
};
class X2:virtual public X
{
virtual void vf(){};
};
class X3:virtual public X2
{
virtual void vf(){};
}
X3将包含有一个Vptr,两个Vbptr。确切的说这两个Vbptr一个属于X3,一个属于X3的子对象X2,X3通过其Vbptr找到子对象X2,而X2通过其Vbptr找到X。
其中差别在于vptr通过一个虚函数表可以确切地知道要调用的函数,而Vbptr通过虚基类表只能够知道其虚基类子对象的偏移量。这两条规则是由虚函数与虚拟继承的实现方式,以及受它们的存取方式和复制控制的要求决定的。
Function语意学
成员函数调用
c++支持三种类型的成员函数,分别为static,nostatic,virtual。每一种调用方式都不尽相同。
非静态成员函数
保证非静态成员函数和普通非成员函数效率一样
- 通过对函数改变,加this指针
float Point::X();
//成员函数X被插入额外参数this
float Point:: X(Point* this
- 将每一个对非静态数据成员的操作都改写为经过this操作
- 对名称进行独一无二的处理,防止函数名字冲突
虚拟成员函数
如果function()是一个虚拟函数,那么用指针或引用进行的调用将发生一点特别的转换——一个中间层被引入进来。
p->function()
//将转化为
(*p->vptr[1])(p);
- 其中vptr为指向虚函数表的指针,它由编译器产生。vptr也要进行名字处理,因为一个继承体系可能有多个vptr。
- 1是虚函数在虚函数表中的索引,通过它关联到虚函数function().
当通过指针调用的时候,要调用的函数实体无法在编译期决定,必需待到执行期才能获得,所以上面引入一个间接层的转换必不可少。但是当我们通过对象(不是引用,也不是指针)来调用的时候,进行上面的转换就显得多余了,因为在编译器要调用的函数实体已经被决定。此时调用发生的转换,与一个非静态成员函数(Nonstatic Member Functions)调用发生的转换一致。
静态成员函数
静态成员函数的一些特性:
- 不能够直接存取其类中的非静态成员(nostatic members),包括不能调用非静态
成员函数(Nonstatic Member Functions)。 - 不能够声明为 const、voliatile或virtual。
- 它不需经由对象调用,当然,通过对象调用也被允许。
除了缺乏一个this指针他与非静态成员函数没有太大的差别。在这里通过对象调用和通过指针或引用调用,将被转化为同样的调用代码。
虚函数
在C++中,多态表示“以一个public base class的指针(或引用),寻址出一个derived class object”的意思。
消极多态与积极多态
消极多态表示在编译期间就可以确定,积极多态表示在运行期间。
用基类指针来寻址继承类的对象,我们可以这样:(消极多态)
Point ptr=new Point3d; //Point3d继承自Point
在这种情况下,多态可以在编译期完成(虚基类情况除外),因此被称作消极多态(没有进行虚函数的调用)。
单继承下的虚函数
虚函数的实现:(编译期确定)
- 为每个有虚函数的类配一张虚函数表,它存储该类类型信息和所有虚函数执行期的地址。
- 为每个有虚函数的类插入一个指针(vptr),这个指针指向该类的虚函数表。
- 给每一个虚函数指派一个在表中的索引。
用这种模型来实现虚函数得益于在C++中,虚函数的地址在编译期是可知的,而且这一地址是固定不变的。而且表的大小不会在执行期增大或减小。
一个类的虚函数表中存储有类型信息(存储在索引为0的位置)和所有虚函数地址,这些虚函数地址包括三种:
- 这个类定义的虚函数,会改写(overriding)一个可能存在的基类的虚函数实体——假如基类也定义有这个虚函数。
- 继承自基类的虚函数实体,——基类定义有,而这个类却没有定义。直接继承之。
- 一个纯虚函数实体。用来在虚函数表中占座,有时候也可以当做执行期异常处理函数。
每一个虚函数都被指派一个固定的索引值,这个索引值在整个继承体系中保持前后关联,例如,假如z()在Point虚函数表中的索引值为2,那么在Point3d虚函数表中的索引值也为2。
当一个类单继承自有虚函数的基类的时候,将按如下步骤构建虚函数表:
- 继承基类中声明的虚函数——这些虚函数的实体地址被拷贝到继承类中的虚函数表中对于的slot中。
- 如果有改写(override)基类的虚函数,那么在1中应将改写(override)的函数实体的地址放入对应的slot中而不是拷贝基类的。
- 如果有定义新的虚函数,那么将虚函数表扩大一个slot以存放新的函数实体地址。
我们假设z()函数在Point虚函数表中的索引为4,回到最初的问题——要如何来保证在执行期调用的是正确的z()实体?其中微妙在于,编译将做一个小小的转换:
ptr->z();
//被编译器转化为:
(*ptr->vptr[4])(ptr);
这个转换保证了调用到正确的实体,因为:
- 虽然我们不知道ptr所指的真正类型,但它可以通过vptr找到正确类型的虚函数表。
- 在整个继承体系中z()的地址总是被放在slot 4。
多重继承下的虚函数
在多重继承下,继承类需要为每一条继承线路维护一个虚函数表(也有可能这些表被合成为一个,但本质意义并没有变化)。当然这一切都发生在需要的情况下。
当使用第一继承的基类指针来调用继承类的虚函数的时候,与单继承的情况没有什么异样,问题出生在当以第二或后继的基类指针(或引用)的使用上。例如:
虚函数表一个通透的解释
参考链接
C++中的虚函数的作用主要是实现了多态的机制。关于多态,简而言之就是用父类型别的指针指向其子类的实例,然后通过父类的指针调用实际子类的成员函数。
虚函数(Virtual Function)是通过一张虚函数表(Virtual Table)来实现的。简称为V-Table。在这个表中,主是要一个类的虚函数的地址表,这张表解决了继承、覆盖的问题,保证其容真实反应实际的函数。这样,在有虚函数的类的实例中这个表被分配在了这个实例的内存中,所以,当我们用父类的指针来操作一个子类的时候,这张虚函数表就显得由为重要了,它就像一个地图一样,指明了实际所应该调用的函数。
一般继承(无虚函数覆盖)
请注意,在这个继承关系中,子类没有重载任何父类的函数。那么,在派生类的实例中,其虚函数表如下所示:
对于实例:Derive d; 的虚函数表如下:
我们可以看到下面几点:
1)虚函数按照其声明顺序放于表中。
2)父类的虚函数在子类的虚函数前面。
一般继承(有虚函数覆盖)
我只覆盖了父类的一个函数:f()。对于派生类的实例,其虚函数表会是下面的一个样子:
我们从表中可以看到下面几点,
1)覆盖的f()函数被放到了虚表中原来父类虚函数的位置。
2)没有被覆盖的函数依旧。
多重继承(无虚函数覆盖)
类并没有覆盖父类的函数。对于子类实例中的虚函数表,是下面这个样子:
我们可以看到:
1) 每个父类都有自己的虚表。
2) 子类的成员函数被放到了第一个父类的表中。(所谓的第一个父类是按照声明顺序来判断的)
这样做就是为了解决不同的父类类型的指针指向同一个子类实例,而能够调用到实际的函数。
多重继承(有虚函数覆盖)
我们在子类中覆盖了父类的f()函数。下面是对于子类实例中的虚函数表的图:
我们可以看见,三个父类虚函数表中的f()的位置被替换成了子类的函数指针。这样,我们就可以任一静态类型的父类来指向子类,并调用子类的f()了。
Derive d;
Base1 *b1 = &d;
Base2 *b2 = &d;
Base3 *b3 = &d;
b1->f(); //Derive::f()
b2->f(); //Derive::f()
b3->f(); //Derive::f()
b1->g(); //Base1::g()
b2->g(); //Base2::g()
b3->g(); //Base3::g()
安全性
通过父类型的指针访问子类自己的虚函数是非法的
任何妄图使用父类指针想调用子类中的未覆盖父类的成员函数的行为都会被编译器视为非法,所以,这样的程序根本无法编译通过。但在运行时,我们可以通过指针的方式访问虚函数表来达到违反C++语义的行为。(关于这方面的尝试,通过阅读后面附录的代码,相信你可以做到这一点)
访问non-public的虚函数
如果父类的虚函数是private或是protected的,但这些非public的虚函数同样会存在于虚函数表中,所以,我们同样可以使用访问虚函数表的方式来访问这些non-public的虚函数,通过取地址的方式