C++虚函数和虚继承浅析

来源:互联网 发布:手机说唱软件 编辑:程序博客网 时间:2024/05/15 00:27

来源:http://www.cnblogs.com/xien7/archive/2013/03/12/2954364.html

本文针对C++里的虚函数,虚继承表现和原理进行一些简单分析,有不对的地方请指出。下面都是以VC2008编译器对这两种机制内部实现为例。

1. 虚函数

以下是百度百科对于虚函数的解释:

定义:在某基类中声明为 virtual 并在一个或多个派生类中被重新定 义的成员函数
语法:virtual 函数返回类型 函数名(参数表) { 函数体 }
用途:实现多态性,通过指向派生类的基类指针,访问派生类中同名覆盖成员函数

1.1 虚函数的行为

函数声明和定义和普通的类成员函数一样,只是在返回值之前加入了关键字“virtual”声明为虚函数。而虚函数是实现多态的重要手段,意思是只有对虚函数的调用才能动态决定调用哪一个函数,这是相对于普通成员函数而言的,普通的成员函数在编译阶段就能确定调用哪一个函数。举个栗子:

#include <stdio.h>class A {public:    void fn() { printf("fn in A\n"); }    virtual void v_fn() { printf("virtual fn in A\n"); }};class B : public A {public:    void fn() { printf("fn in B\n"); }    virtual void v_fn() { printf("virtual fn in B\n"); }};int main() {    A *a = new B();    a->fn();    a->v_fn();    return 0;}

基类 A 有两个成员函数 fn 和 v_fn,派生类 B 继承自基类 A,同样实现了两个函数,然后在 main 函数中用 A的指针指向 B 的实例(向上转型,也是实现多态的必要手段),然后分别调用 fn 和 v_fn 函数。结果是“fn in A” 和 “virtual fn in B”。这是因为 fn 是普通成员函数,它是通过类 A 的指针调用的,所以在编译的时候就确定了调用 A 的 fn 函数。而 v_fn 是虚函数,编译时不能确定,而是在运行时再通过一些机制来调用指针所指向的实例(B的实例)中的 v_fn 函数。

假如派生类 B 中没有实现(完全一样,不是重载)v_fn 这个函数,那么依然会调用基类类 A 中的 v_fn;如果它实现了,就可以说派生类 B 覆盖了基类 A 中的 v_fn 这个虚函数。这就是虚函数的表现和使用,只有通过虚函数,才能实现面向对象语言中的多态性。

1.2 占用空间

以上只是虚函数的表现和用途,下面来探讨它的实现机制。在此之前,先来看一个问题,还是以上的代码,基类 A 的大小为多少,也就是“printf(“%d\n”, sizeof(A));” 的输出会是多少呢?A中一个成员变量都没有,有人可能会说是 0。额,0 是绝对错误的,因为在C++中,即时是空类,它的大小也为 1,这是另外的话题,不在本文讨论。当然 1 也是不对的,实际结果是4(32位系统),4 刚好是一个 int,一个指针(32位)的大小,派生类 B 的大小同样为4。这四个字节和实现多态,虚函数的机制有着很重要的关系。

1.3 底层实现

其实用VC2008调试上面代码的时候,就会发现指针a所指向的实力中有一个成员常量(const),它的名字叫做 vftable,全称大概叫做 virtual function table(虚函数表)。它实际指向了一个数组,数组里面保存的是一系列函数指针,而上面的程序中,这个表只有一项,它就是派生类 B 中的 v_fn 函数入口地址。假如我们用一个 A 的指针指向一个 A 的实例呢?它同样有一个 vftable,而它指向的表中也只有一项,这项保存的基类的 v_fn 函数入口地址。这用代码表示,就类似于下面这样:

void* vftable_of_A[] = {  A::v_fn,  ...};class A {  const void* vftable = vftable_of_A;  virtual void v_fn() {}};void* vftable_of_B[] = {  B::v_fn,  ...};class B {  const void *vftable = vftable_of_B;  vritual void v_fn() {}};

上面 vftable 的类型之所以用 void* 表示,实际上一个类中所有虚函数的地址都被放到这个表中,不同虚函数对应的函数指针类型不尽相同,所以这个表用 C++ 的类型不好表述,但是在机器级里都是入口地址,即一个32位的数字(32位系统),等到调用时,因为编译器预先知道了函数的参数类型,返回值等,可以自动做好处理。

这样我们就能更好的理解虚函数和多态了。第一个代码中,a 指针虽然是 A* 类型的,但是它却调用了 B 中的 v_fn,因为不管是 A 类,还是 A 的基类,都会有一个变量 vftable,它指向的虚函数表中保存了正确的v_fn 入口。所以 a->v_fn() 实际做的工作就是从 a 指向的实例中取出 vftable 的值,然后找到虚函数表,再从表中去的 v_fn 的入口,进行调用。不管 a 是指向 A 的实例,还是指向 B 的实例,a->fn() 所做的步骤都是上面说的一样,只是 A 的实例和 B 的实例有着不同的虚函数表,虚函数表里也保存着可能不同的虚函数入口,所以最终将进入不同的函数调用中。通过表来达到不用判断类型,亦可实现多态的作用。还有一点指的提醒的是,因为虚函数表是一个常量表,在编译时,编译器会自动生成,并且不会改变,所以如果有多个B类的实例,每个实例中都会有一个 vftable 指针,但是它们指向的是同一个虚函数表。

1.4 纯虚函数

上面一段中说到了,A 和 B 的实例有着不同的虚函数表,但是虚函数表中只是可能保存着不同的 v_fn,那是因为 C++ 允许派生类不覆盖基类中的虚函数,意思就是假如派生类 B 中没有实现 v_fn 这个函数(不是重载),那么 B 的实例的虚函数表会保存着基类 A 中 v_fn 的入口地址。也就是说 B 类不实现 v_fn 函数,但是它同样提供了这个接口,实际上是调用基类 A 中的 v_fn。假如某个类只是一个抽象类,抽象出一些列接口,但是又不能实现这些接口,而要有派生类来实现,那么就可以把这些接口声明为纯虚函数,包含有纯虚函数的类称为抽象类。纯虚函数是一类特殊的虚函数,它的声明方式如下:

class A {public:  virtual 返回值 函数名(参数表)= 0;};

在虚函数声明方式后加一个“=0”,并且不提供实现。抽象类不允许实例化(这样做编译器会报错,因为有成员函数没有实现,编译器不知道怎么调用)。纯虚函数的实现机制和虚函数类似,只是要求派生类类必须自己实现一个(也可以不实现,但是派生类也会是个抽象类,不能实例化)。

顺带提一下,java中的每一个成员函数都可以以理解为C++中的virtual函数,不用显式声明都可以实现重载,多态。而java的接口类似于C++中的抽象类,需要实现里面的接口。

2. 虚继承

2.1 虚继承概念

C++支持多重继承,这和现实生活很类似,任何一个物体都不可能单一的属于某一个类型。就像马,第一想到的就是它派生自动物这个基类,但是它在某系地方可不可以说也派生自交通工具这一个基类呢?所以C++的多重继承很有用,但是又引入了一个问题(专业术语叫做菱形继承?)。动物和交通工具都是从最根本的基类——“事物”继承而来,事物包含了两个最基本的属性,体积和质量。那么动物和交通工具都保存了基类成员变量——体积和质量的副本。而马有继承了这两个类,那么马就有两份体积和质量,这是不合理的,编译器无法确定使用哪一个,所以就会报错。JAVA 中不存在这样的问题,因为 JAVA 不允许多重继承,它只可能实现多个接口,而接口里面只包含一些函数声明不包含成员变量,所以也不存在这样的问题。

这个问题用具体代码表述如下所示:

class A {public:    int a;};class B : public A {};class C : public A {};class D : public B, public C {};int main() {    D d;    d.a = 1;    return 0;}

这个代码会报错,因为d中保存了两份A的副本,即有两个成员变量a,一般不会报错,但是一旦对D中的a使用,就会报一个“对a的访问不明确”。虚继承就可以解决这个问题。在探讨虚函数之前,先来一个sizeof的问题。

2.2 占用空间

#include <stdio.h>class A {public:    int a;};class B : virtual public A {};int main() {    printf("%d\n", sizeof(B));    return 0;}

B 的大小是?首先回答 0 的是绝对错的,理由我之前都说了。1 也是错的,不解释。4 也是错的,如果 B 不是虚继承自 A 的,那么 4 就是对的。正确答案是 8,B 虚继承 A 了之后,比预想中的多了 4 个字节,这是怎么回事呢?这个通过调试是看不出来的,因为看不到类似于 vftable 的成员变量(实际上编译器生成了一个类似的东西,但是调试时看不到,但是在观察反汇编的时候,可以见到 vbtable 的字样,应该是 virtual base table 的意思)。

虚继承的提出就是为了解决多重继承时,可能会保存两份副本的问题,也就是说用了虚继承就只保留了一份副本,但是这个副本是被多重继承的基类所共享的,该怎么实现这个机制呢?编译器会在派生类B的实例中保存一个 A 的实例,然后在 B 中加入一个变量,这个变量是 A 的实例在实际 B 实例中的偏移量,实际上 B 中并不直接保存 offset 的值,而是保存的一个指针,这个指针指向一个表 vbtable,vbtable 表中保存着所有虚继承的基类在实例中的 offset 值,多个 B 的实例共享这个表,每个实例有个单独的指针指向这个表,这样就很好理解为什么多了 4 个字节了。用代码表示就像下面这样。

class A {public:    ...};int vbtable_of_B[] = {  offset(B::_a),    ...};class B :virtual public A{private:    const int* vbtable = vbtable_of_B;    A _a;};

每一个 A 的虚派生类,都会有自己的 vbtable 表,这个派生类的所有实例共享这个表,然后每个实例各自保存了一个指向 vbtable 表的指针。假如还有一个类 C 虚继承了 A,那么编译器就会为它自动生成一个 vbtable_of_C 的表,然后 C 的实例都会有一个指向这个 vbtable 表的指针。

2.3 多级虚继承

假如有多级的虚继承会发生什么情况,就像下面这段代码一样:

#include <stdio.h>class A {public:    int a;};class B : virtual public A {public:  int b;};class C : virtual public B {};int main() {    printf("%d\n", sizeof(C));    return 0; }

程序运行的结果是 16,按照之前的理论,大概会这么想。基类 A 里有1个变量,4 个字节。B 类虚继承了 A,所以它有一个 A 的副本和一个 vbtable,还有自己的一个变量,那就是12字节。然后C类又虚继承了B类,那么它有一个 B 的副本,一个 vbtable,16字节。但实际上通过调试和反汇编发现,C 中保存分别保存了 A 和 B 的副本(不包括B类的 vbtable),8字节。然后有一个 vbtable 指针,4字节,表里面包含了 A 副本和B副本的偏移量。最后还有一个无用的 4 字节(?),一共 16 字节。不仅是这样,每经过一层的虚继承,便会多出 4 字节。这个多出来的四字节在反汇编中没发现实际用途,所以这个有待探讨,不管是编译器不够智能,还是有待其它作用,虚继承和多重继承都应该谨慎使用。

还是以上面的例子,假如 C 类是直接继承 B 类,而不是使用虚继承,那么 C 类的大小为 12 字节。它里面是直接保存了 A 和 B 的副本(不包含 B 的 vbtable),然后还有一个自己的 vbtable 指针,所以一共12字节,没有了上一段所说的最后的4个字节。

但是如果想下面一种继承,会是什么情况?

#include <stdio.h>class A {public:    int a;};class B : virtual public A {};class C : virtual public A {};class D : public B, public C{};int main() {    printf("%d\n", sizeof(D));    return 0; }

D 从 B,C 类派生出来,而 B 和 C 又同时虚继承了A。输出的结构是12,实际调试反汇编的时候发现,D 中继承了 B 和 C 的vbtable,这就是 8 字节,而同时还保存了一个 A 的副本,4 字节,总共 12 字节。它和上面的多重虚继承例子里的 12 字节是不一样的。之前一个例子中只有一个 vbtable,一个 A 的实例,末尾还有一个未知的 4 字节。而这个例子中是有两个仅挨着的 vbtable(都有效)和一个A的实例。

0 0
原创粉丝点击