自底向上地探究虚函数
作者:Jason Lee @http://blog.csdn.net/jasonblog
日期:2010-05-19
环境:Visual C++ Express2008
声明:本文发表在csdn博客,如有转载,请注明出处
[1]C++对象模型基础
一个类中可以包含静态数据成员、静态成员函数、非静态成员函数和非静态数据成员以及虚函数。其中,前三者(静态数据成员、静态成员函数、非静态成员函数)都并没有被放到对象的布局中,可以从以下两段代码得到验证:
#include <iostream>
using namespace std;
class Base {
};
int main(){
Base a;
cout << sizeof (a) << endl;// 输出1
return 0;
}
上述的
Base 类是一个空类,占据了一个字节的内存空间,这是为了保证每个类实例化后都拥有独一无二的内存空间。接着我们往
Base 类中添加静态数据成员、静态成员函数和非静态成员函数:
#include <iostream>
using namespace std;
class Base {
public :
Base(){}
~Base(){}
static int v1;
static void f1(){}
void f2(){}
};
int main(){
Base a;
cout << sizeof (a) << endl;// 仍然输出1
return 0;
}
在经过内容填充后,
Base 类的实例
a 仍然仅占据 1
个字节的内存空间,与空类无异,这说明了静态数据成员、静态成员函数和非静态成员函数并未被放在对象的内存布局当中 。
接下来往类中添加非静态数据成员:
#include <iostream>
using namespace std;
class Base {
public :
int a;
int b;
};
int main(){
Base a;
cout << sizeof (a) << endl;// 输出8
cout << hex << &a << endl;// 输出0012F 3CC
cout << hex << &a.a << endl;// 输出0012F 3CC
cout << hex << &a.b << endl;// 输出0012F 3D0
return 0;
}
从上面的代码可以看出:一,非静态数据成员是会被放到对象的内存布局中;二,数据成员是根据声明顺序有序地在内存中进行分布的;三,在没有虚函数的情况下对象所占据的内存大小就是数据成员所占据的空间之和。布局如下图:
a |
b |
那么如果添加了虚函数以后呢?先看一段代码:
#include <iostream>
using namespace std;
class Base {
public :
int a;
int b;
void doit(){ }
protected :
virtual void vf(){ cout << "hi" << endl; }
virtual void vf_(){}
};
int main(){
Base a;
cout << sizeof (a) << endl;// 输出12
cout << hex << &a << endl;// 输出0012F 3C 8
cout << hex << &a.a << endl;// 输出0012F 3CC
cout << hex << &a.b << endl;// 输出0012F 3D0
return 0;
}
Base
基类中有两个虚函数,这两个虚函数(如果只有一个虚函数情况也一样)出现后使得对象 a
的大小变为 12 ,相较于没有虚函数的情况多了
4 个字节,即
32 位,相当于一个指针所占的内存大小。
包含虚函数的类的对象实例中会在内存布局中多添加了一个
vptr
指针,这个指针指向(不仅)存放虚函数地址的虚表 vtbl
,所以不管类中只有一个虚函数或者有多个,在对象实例中只会多出一个指针需要的空间大小,即 4
个字节。
此外,
vptr
通常存放在对象内存布局中的起始处 。从上一段代码输出的地址就可以看出成员变量
a 占据
0012F 3CC 到
0012F 3CF 的空间,成员变量
b 占据
0012F 3D0 到
0012F 3D3 的空间,而对象首址
0012F 3C 8
到 0012F
3CB 则是用来存放 vptr
的。总计 12
字节,布局如下图:
vptr |
a |
b |
[2] 继承关系下的模型和指针类型
在单继承的情况下,对象实例的内存布局中,基类部分位于子类部分前;而对于多继承,不同的基类部分会按照继承声明顺序在内存中陆续分布。如下是一个代码示例:
#include <iostream>
using namespace std;
class Base {
public :
void doit(){ vf(); }
int bv;
protected :
virtual void vf(){ cout << "Base" << endl; }
};
class Base1 {
public :
void doit1(){ vf1(); }
int b1v;
protected :
virtual void vf1(){ cout << "Base1" << endl; }
};
class Demo : public Base, public Base1 {
protected :
void vf(){ cout << "Demo" << endl; }
virtual void vf1(){ cout << "Demo1" << endl; }
virtual void vf2(){}
public :
int dv;
};
int main(){
Demo d;
cout << sizeof (d) << endl;// 输出
cout << hex << &d << endl;// 输出F3B8
cout << hex << &d.bv << endl;// 输出F3BC
cout << hex << &d.b1v << endl;// 输出F3C4
cout << hex << &d.dv << endl;// 输出F3C8
Base *p = &d;
p->doit();// 输出Demo ,即执行子类重写的虚函数
cout << hex << p << endl;// 输出F3B8
Base1 *p1 = &d;
cout << hex << p1 << endl;// 输出F3C0
return 0;
}
Base
基类因为有一个 vptr 和一个数据成员
bv ,所以占据了
8 个字节; Base1
基类同样有 vptr
和数据成员 b1v ,所以也占据了
8 个字节;而子类
Demo 继承了 Base
和 Base1
,又新增了一个数据成员 dv ,所以总共占据了
20 个字节的空间。
对象
d 占据了从
0012F 3B8 到
0012F 3CB 的
20 个字节内存空间,其中 Base
基类的部分位于 0012F
3B8 到 0012F
3BF 的 8
个字节, Base1 基类部分紧随其后,占据
0012F 3C 0
到 0012F
3C 7 的内存空间,最后是子类本身的数据成员
dv 。布局如下图所示:
vptr_Base |
bv |
vptr_Base1 |
b1v |
dv |
注意到指针
p 和
p1 的声明和赋值,以及所指向的首址。指针
p 的类型是 Base *
,它指向了对象 d
中的 Base 基类部分;指针
p1 的类型是
Base1 * ,它指向了对象 d
中的 Base1
部分。指针类型的作用是给予编译器信息,表明指针指向的对象类型 (包含首址以及大小等信息),因为子类中含有基类部分,所以基类指针可以指向子类,更实质地讲是指向子类中的基类部分。
既然
Base * 类型的指针指向的是内存中
Base 基类的部分,那么为什么运行下述语句会执行子类中的虚函数呢?
Base *p = &d;
p->doit();// 输出Demo ,即执行子类重写的虚函数
首先我们确定指针
p 能访问的只有
vptr_Base 和 bv
这两个成员,其中 vptr_Base
指向一个虚表,虚表中存放着类型信息和虚函数的地址。这里不妨认为 vptr_Base[1]
存放着虚函数 vf 的地址。
在编译阶段可以针对虚函数机制做的工作有:一,确定虚表的地址,即
vptr 的指向;二,确定虚表的大小和内容;三,针对不同虚函数的调用,转换为对虚表不同表项的索引。在确定虚表的内容时,如果子类重写了基类的虚函数,那么虚表中对应的表项会被修改指向子类重新实现的函数地址;否则的话,仍旧指向基类中定义的虚函数。
在上述代码中,由于
Demo 类中重写了虚函数
vf ,所以 vptr_Base[1]
指向了子类中的虚函数实体,而非 Base
中定义的 vf
。
经过编译后,我们知道对
vf 函数的调用相当于调用
vptr_Base[1] 所指向的函数,但是在这个阶段无法知道调用的虚函数到底是基类定义的还是子类中重写的,因为
Base *
类型的指针可以指向一个 Base
类型对象,也可以指向子类 Demo
中的 Base
基类部分 。因此,只有在执行期才可以知道
vptr_Base[1] 到底指向哪一个
vf 函数实体。
[3] 编码层次的虚函数
如果基类希望某个成员函数由子类重定义,那么应该将其声明为
virtual 类型。虚函数是动态绑定的基础,(只有)通过基类类型的指针或引用进行虚函数的调用才可以触发动态绑定,这体现了多态这一面向对象的关键思想。可以看出,基类类型的指针或引用既可以指向基类类型对象也可以指向子类类型对象的特性是动态绑定的关键 。
通过虚函数可以实现运行时多态,这有利于公共接口的实现。即在继承体系中处于不同层次的类使用同一接口,但运行时会根据具体对象类型的差异而采取不同的策略。这种风格在良好的软件体系结构可以经常发现,比如
Qt 。
在实际编码设计过程中需要注意以下几点:
1、
要发生动态绑定,实现运行时的多态,需要通过基类的指针或者引用调用虚函数,这样在运行时才会根据指针或引用所指向的对象的实际类型调用相应的目标函数。
2、
关键字 virtual
只能在类内部的成员声明中出现,而不能出现在类定义体外部。
3、
一经声明为虚函数,则一直是虚函数;子类可以不用显示声明
virtual ,但虚函数的特性不会改变。
4、
子类中虚函数的声明必须和基类的定义方式匹配,除了基类中虚函数返回对基类类型的指针或引用,在这种情况下,子类中的虚函数可以返回基类函数所返回类型的子类的指针或引用。
5、
子类中虚函数调用基类的虚函数必须使用显示作用域声明,或者会递归调用自身。
6、
纯虚函数仅作为抽象接口以供覆盖,包含纯虚函数的类是抽象基类,不能被实例化。
[4] 参考资料
《
C++ Primer 》
《
Inside The C++ Object Model
》