虚函数与虚继承寻踪

函数与虚继承寻踪

封装、继承、多态是面向对象语言的三大特性,熟悉C++的人对此应该不会有太多异议。C语言提供的struct,顶多算得上对数据的简单封装,而C++的引入把struct升级”为class,使得面向对象的概念更加强大。继承机制解决了对象复用的问题,然而多重继承又会产生成员冲突的问题,虚继承在我看来更像是一种“不得已”的解决方案。多态让对象具有了运行时特性,并且它是软件设计复用的本质,虚函数的出现为多态性质提供了实现手段。

如果说C语言的struct相当于对数据成员简单的排列(可能有对齐问题),那么C++class让对象的数据的封装变得更加复杂。所有的这些问题来源于C++一个关键字——virtualvirtualC++中最大的功能就是声明虚函数和虚基类,有了这种机制,C++对象的机制究竟发生了怎样的变化,让我们一起探寻之。

为了查看对象的结构模型,我们需要在编译器配置时做一些初始化。在VS2010中,在项目——属性——配置属性——C/C++——命令行——其他选项中添加选项“/d1reportAllClassLayout”。再次编译时候,编译器会输出所有定义类的对象模型。由于输出的信息过多,我们可以使用“Ctrl+F”查找命令,找到对象模型的输出

一、基本对象模型

首先,我们定义一个简单的类,它含有一个数据成员和一个函数

class MyClass
{
    int var;
public:
    virtual void fun()
    {}
};

编译输出MyClass对象结构如下:

1>  class MyClass    size(8):
1>      +---
1>   0    | {vfptr}
1>   4    | var
1>      +---
1>  
1>  MyClass::$vftable@:
1>      | &MyClass_Meta
1>      |  0
1>   0    | &MyClass::fun
1>  
1>  MyClass::fun this adjustor: 0

从这段信息中我们看出,MyClass对象大小是8个字节。前四个字节存储的是虚函数表的指针vfptr,后四个字节存储对象成员var的值。虚函数表的大小为4字节,就一条函数地址,即虚函数fun的地址,它在虚函数vftable的偏移是0。因此,MyClass对象模型的结果如图1所示。

1 MyClass对象模型

MyClass的虚函数表虽然只有一条函数记录,但是它的结尾处是由4字节的0作为结束标记的。

adjust表示虚函数机制执行时,this指针的调整量,假如fun被多态调用的话,那么它的形式如下:

*(this+0)[0]()

总结虚函数调用形式,应该是:

*(this指针+调整量)[函数vftable内的偏移]()

二、单重继承对象模型

我们定义一个继承于MyClass类的子类MyClassA,它重写了fun函数,并且提供了一个新的虚函数funA

class MyClassA:public MyClass
{
    int vara;
public:
    virtual void fun()
    {}
    virtual void funA()
    {}
};

它的对象模型为:

1>  class MyClassA    size(12):
1>      +---
1>      | +--- (base class MyClass)
1>   0    | | {vfptr}
1>   4    | | var
1>      | +---
1>   8    | vara
1>      +---
1>  
1>  MyClassA::$vftable@:
1>      | &MyClassA_Meta
1>      |  0
1>   0    | &MyClassA::fun
1>   1    | &MyClassA::funA
1>  
1>  MyClassA::fun this adjustor: 0
1>  MyClassA::funA this adjustor: 0

可以看出,MyClassA将基类MyClass完全包含在自己内部,包括vfptrvar。并且虚函数表内的记录多了一条——MyClassA自己定义的虚函数funA。它的对象模型如图2所示。

 

2 MyClassA对象模型

我们可以得出结论:在单继承形式下,子类的完全获得父类的虚函数表和数据。子类如果重写了父类的虚函数(如fun),就会把虚函数表原本fun对应的记录(内容MyClass::fun)覆盖为新的函数地址(内容MyClassA::fun),否则继续保持原本的函数地址记录。如果子类定义了新的虚函数,虚函数表内会追加一条记录,记录该函数的地址(如MyClassA::funA)。

使用这种方式,就可以实现多态的特性。假设我们使用如下语句:

MyClass*pc=new MyClassA;
pc->fun();

编译器在处理第二条语句时,发现这是一个多态的调用,那么就会按照上边我们对虚函数的多态访问机制调用函数fun

*(pc+0)[0]()

因为虚函数表内的函数地址已经被子类重写的fun函数地址覆盖了,因此该处调用函数正是MyClassA::fun,而不是基类的MyClass::fun

如果使用MyClassA对象直接访问fun,则不会出发多态机制,因为这个函数调用在编译时期是可以确定的,编译器只需要直接调用MyClassA::fun即可。

三、多重继承对象模型

和前边MyClassA类似,我们也定义一个MyClassB

class MyClassB:public MyClass
{
    int varB;
public:
    virtual void fun()
    {}
    virtual void funB()
    {}
};

它的对象模型和MyClassA完全类似,这里就不再赘述了。

为了实现多重继承,我们再定义一个MyClassC

class MyClassC:public MyClassA,public MyClassB
{
    int varC;
public:
    virtual void funB()
    {}
virtual void funC()
    {}
};

为了简化,我们让MyClassC只重写父类MyClassB的虚函数funB,它的对象模型如下:

1>  class MyClassC    size(28):
1>      +---
1>      | +--- (base class MyClassA)
1>      | | +--- (base class MyClass)
1>   0    | | | {vfptr}
1>   4    | | | var
1>      | | +---
1>   8    | | vara
1>      | +---
1>      | +--- (base class MyClassB)
1>      | | +--- (base class MyClass)
1>  12    | | | {vfptr}
1>  16    | | | var
1>      | | +---
1>  20    | | varB
1>      | +---
1>  24    | varC
1>      +---
1>  
1>  MyClassC::$vftable@MyClassA@:
1>      | &MyClassC_Meta
1>      |  0
1>   0    | &MyClassA::fun
1>   1    | &MyClassA::funA
1>   2    | &MyClassC::funC
1>  
1>  MyClassC::$vftable@MyClassB@:
1>      | -12
1>   0    | &MyClassB::fun
1>   1    | &MyClassC::funB
1>  
1>  MyClassC::funB this adjustor: 12
1>  MyClassC::funC this adjustor: 0

和单重继承类似,多重继承时MyClassC会把所有的父类全部按序包含在自身内部。而且每一个父类都对应一个单独的虚函数表。MyClassC的对象模型如图3所示。

 

3 MyClassC对象模型

多重继承下,子类不再具有自身的虚函数表,它的虚函数表与第一个父类的虚函数表合并了。同样的,如果子类重写了任意父类的虚函数,都会覆盖对应的函数地址记录。如果MyClassC重写了fun函数(两个父类都有该函数),那么两个虚函数表的记录都需要被覆盖!在这里我们发现MyClassC::funB函数对应的adjust值是12,按照我们前边的规则,可以发现该函数的多态调用形式为:

*(this+12)[1]()

此处的调整量12正好是MyClassBvfptrMyClassC对象内的偏移量。

四、虚拟继承对象模型

虚拟继承是为了解决多重继承下公共基类的多份拷贝问题。比如上边的例子中MyClassC的对象内包含MyClassAMyClassB子对象,但是MyClassAMyClassB内含有共同的基类MyClass。为了消除MyClass子对象的多份存在,我们需要让MyClassAMyClassB都虚拟继承于MyClass,然后再让MyClassC多重继承于这两个父类。相对于上边的例子,类内的设计不做任何改动,先修改MyClassAMyClassB的继承方式:

class MyClassA:virtual public MyClass
class MyClassB:virtual public MyClass
class MyClassC:public MyClassA,public MyClassB

由于虚继承的本身语义,MyClassC内必须重写fun函数,因此我们需要再重写fun函数。这种情况下,MyClassC的对象模型如下:

1>  class MyClassC    size(36):
1>      +---
1>      | +--- (base class MyClassA)
1>   0    | | {vfptr}
1>   4    | | {vbptr}
1>   8    | | vara
1>      | +---
1>      | +--- (base class MyClassB)
1>  12    | | {vfptr}
1>  16    | | {vbptr}
1>  20    | | varB
1>      | +---
1>  24    | varC
1>      +---
1>      +--- (virtual base MyClass)
1>  28    | {vfptr}
1>  32    | var
1>      +---
1>  
1>  MyClassC::$vftable@MyClassA@:
1>      | &MyClassC_Meta
1>      |  0
1>   0    | &MyClassA::funA
1>   1    | &MyClassC::funC
1>  
1>  MyClassC::$vftable@MyClassB@:
1>      | -12
1>   0    | &MyClassC::funB
1>  
1>  MyClassC::$vbtable@MyClassA@:
1>   0    | -4
1>   1    | 24 (MyClassCd(MyClassA+4)MyClass)
1>  
1>  MyClassC::$vbtable@MyClassB@:
1>   0    | -4
1>   1    | 12 (MyClassCd(MyClassB+4)MyClass)
1>  
1>  MyClassC::$vftable@MyClass@:
1>      | -28
1>   0    | &MyClassC::fun
1>  
1>  MyClassC::fun this adjustor: 28
1>  MyClassC::funB this adjustor: 12
1>  MyClassC::funC this adjustor: 0
1>  
1>  vbi:       class  offset o.vbptr  o.vbte fVtordisp
1>           MyClass      28       4       4 0

虚继承的引入把对象的模型变得十分复杂,除了每个基类(MyClassAMyClassB)和公共基类(MyClass)的虚函数表指针需要记录外,每个虚拟继承了MyClass父类还需要记录一个虚基类表vbtable的指针vbptrMyClassC的对象模型如图4所示。

 

4 MyClassC对象模型

虚基类表每项记录了被继承的虚基类子对象相对于虚基类表指针的偏移量。比如MyClassA的虚基类表第二项记录值为24,正是MyClass::vfptr相对于MyClassA::vbptr的偏移量,同理MyClassB的虚基类表第二项记录值12也正是MyClass::vfptr相对于MyClassA::vbptr的偏移量。

和虚函数表不同的是,虚基类表的第一项记录着当前子对象相对与虚基类表指针的偏移。MyClassAMyClassB子对象内的虚表指针都是存储在相对于自身的4字节偏移处,因此该值是-4。假定MyClassAMyClassC或者MyClassB内没有定义新的虚函数,即不会产生虚函数表,那么虚基类表第一项字段的值应该是0

通过以上的对象组织形式,编译器解决了公共虚基类的多份拷贝的问题。通过每个父类的虚基类表指针,都能找到被公共使用的虚基类的子对象的位置,并依次访问虚基类子对象的数据。至于虚基类定义的虚函数,它和其他的虚函数的访问形式相同,本例中,如果使用虚基类指针MyClass*pc访问MyClassC对象的fun,将会被转化为如下形式:

*(pc+28)[0]()

通过以上的描述,我们基本认清了C++的对象模型。尤其是在多重、虚拟继承下的复杂结构。通过这些真实的例子,使得我们认清C++class的本质,以此指导我们更好的书写我们的程序。本文从对象结构的角度结合图例为大家阐述对象的基本模型,和一般描述C++虚拟机制的文章有所不同。作者只希望借助于图表能把C++对象以更好理解的形式为大家展现出来,希望本文对你有所帮助。



原文地址:https://www.cnblogs.com/fanzhidongyzby/archive/2013/01/14/2859064.html

版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 dio@foxmail.com 举报,一经查实,本站将立刻删除。

相关推荐


对象的传值与返回说起函数,就不免要谈谈函数的参数和返回值。一般的,我们习惯把函数看作一个处理的封装(比如黑箱),而参数和返回值一般对应着处理过程的输入和输出。这种情况下,参数和返回值都是值类型的,也就是说,函数和它的调用者的信息交流方式是用过数据的拷贝来完成,即我们习惯上称呼的“值传递”。但是自从引
从实现装饰者模式中思考C++指针和引用的选择最近在看设计模式的内容,偶然间手痒就写了一个“装饰者”模式的一个实例。该实例来源于风雪涟漪的博客,我对它做了简化。作为一个经典的设计模式,本身并没有太多要说的内容。但是在我尝试使用C++去实现这个模式的实例的时候,出现了一些看似无关紧要但是却引人深思的问题
关于vtordisp知多少?我相信不少人看到这篇文章,多半是来自于对标题中“vtordisp”的好奇。其实这个关键词也是来源于我最近查看对象模型的时候偶然发现的。我是一个喜欢深究问题根源的人(有点牛角尖吧),所以当我第一次发现vtordisp的时候,我也是很自然的把它输进google查找相关资料,但
那些陌生的C++关键字学过程序语言的人相信对关键字并不陌生。偶然间翻起了《C++ Primer》这本书,书中列举了所有C++的关键字。我认真核对了一下,竟然发现有若干个从未使用过的关键字。一时间对一个学了六年C++的自己狠狠鄙视了一番,下决心一定要把它们搞明白!图1红色字体给出的是我个人感觉一般大家
命令行下的树形打印最近在处理代码分析问题时,需要将代码的作用域按照树形结构输出。问题的原型大概是下边这个样子的。图中给了一个简化的代码片段,该代码片段包含5个作用域:全局作用域0、函数fun作用域1、if语句作用域2、else语句作用域3和函数main作用域4。代码作用域有个显著的特点就是具有树形结
虚函数与虚继承寻踪封装、继承、多态是面向对象语言的三大特性,熟悉C++的人对此应该不会有太多异议。C语言提供的struct,顶多算得上对数据的简单封装,而C++的引入把struct“升级”为class,使得面向对象的概念更加强大。继承机制解决了对象复用的问题,然而多重继承又会产生成员冲突的问题,虚继
不要被C++“自动生成”所蒙骗C++对象可以使用两种方式进行创建:构造函数和复制构造函数。假如我们定义了类A,并使用它创建对象。Aa,b;Ac=a;Ad(b);对象a和b使用编译器提供的默认构造函数A::A()创建出来,我们称这种创建方式为对象的定义(包含声明的含义)。对象c和d则是使用已有的对象,
printf背后的故事 说起编程语言,C语言大家再熟悉不过。说起最简单的代码,Helloworld更是众所周知。一条简单的printf语句便可以完成这个简单的功能,可是printf背后到底做了什么事情呢?可能很多人不曾在意,也或许你比我还要好奇!那我们就聊聊printf背后的故事。 一、printf
定义 浮点数就是小数点位置不固定的数,也就是说与定点数不一样,浮点数的小数点后的小数位数可以是任意的,根据IEEE754-1985(也叫IEEE Standard for Binary Floating-Point Arithmetic)的定义,浮点数的类型有两种:单精度类型(用4字节存储)和双精度
在《从汇编看c++的引用和指针》一文中,虽然谈到了引用,但是只是为了将两者进行比较。这里将对引用做进一步的分析。1 引用的实现方式在介绍有关引用的c++书中,很多都说引用只是其引用变量的一个别名。我自己不是很喜欢这种解释,因为觉得这种解释会给人误解,好像引用和变量就是一回事,而且,书中也没有给出,为
今天写程序的时候,创建了一个结构体:struct BufferObj {char* buf;int bufLen;SOCKADDR_STORAGE addr;int addrLen;struct BufferObj* next;};该结构体有一个next指针,本意是这个指针初始的时候应该为NULL,
placement operator new是重载的operator new运算符,它允许我们将对象放到一个指定的内存中。下面来看c++源码:class X {private: int _x;public: X(int xx = 0) : _x(xx) {} ~X() {} void* operat
编码的目的,就是给抽象的字符赋予一个数值,好在计算机里面表示。常见的ASCII使用8bit给字符编码,但是实际只使用了7bit,最高位没有使用,因此,只能表示128个字符;ISO-8859-1(也叫Latin-1,或者直接8859)使用全8bit编码,可以看成是ASCII的超集,因为它的低128个字
在宏定义当中,常常可以看到宏的参数以及整个宏的定义都被小括号包围,就像下面的 MIN、MAX、ABS 宏一样: 上面的图截取自 iOS 的系统库,那为什么它们需要这些括号包围起来呢? 下面假如我们自定义了宏 ceil_div,代码如下: #define ceil_div(x, y) (x + y -
c++中,当继承结构中含有虚基类时,在构造对象时编译器会通过将一个标志位置1(表示调用虚基类构造函数),或者置0(表示不调用虚基类构造函数)来防止重复构造虚基类子对象。如下图菱形结构所示:当构造类Bottom对象时,Bottom构造函数里面的c++伪码如下(单考虑标志位,不考虑其他)://Botto
在C中,使用fopen打开文件有两种模式:一种是文本模式,一种是二进制模式。那这两种模式之间有什么区别,是不是使用文本模式打开的文件就只能使用文本函数比如fprintf来操作,而使用二进制打开的文件就只能使用二进制函数比如fwrite来操作呢? 答案是否定的。C里面之所以有文本模式和二进制模式,完全
尾数英文名叫mantissa,significand,coefficient,用于科学计数法中。科学计数法的表示方法为: Mantissa x Base^Exponent 举个例子,123.45用科学计数法可以表示为: 12345 x 10^(-2) 其中12345就是尾数Mantissa,10是基
定义宏时可以让宏接收可变参数,对于可变参数的定义,标准 C 和 GNU C(GNU 对 C的扩展)是不一样的。 标准 C 标准 C 对于可变参数的定义如下,使用...: #define eprintf(...) fprintf (stderr, __VA_ARGS__) 在宏定义中,__VA_ARG
宏分为两种,一种是 object-like 宏,比如: #define STR "Hello, World!" 另一种是 function-like 宏,比如: #define MIN(X, Y) ((X) < (Y) ? (X) : (Y)) 对于 function-li
副作用(Side Effect) 在计算机当中,副作用指当调用一个函数时,这个函数除了返回一个值之外,还对主调函数产生了影响,比如修改了全局变量,修改了参数等等。 宏的重复副作用 对于求两个数中的最小数,常常可以定义一个宏 MIN,定义如下: #define MIN(X, Y) ((X) <