C++类对象内存结构.docx
- 文档编号:27624754
- 上传时间:2023-07-03
- 格式:DOCX
- 页数:26
- 大小:38.58KB
C++类对象内存结构.docx
《C++类对象内存结构.docx》由会员分享,可在线阅读,更多相关《C++类对象内存结构.docx(26页珍藏版)》请在冰豆网上搜索。
C++类对象内存结构
目 录
1 前言
2 语法
3 与其它语言机制的混合使用
3.1 继承
3.2 虚函数
3.2 多继承
4 实现
4.1Microsoft的实现
4.1.1 内部表示
4.1.2Vcall_addr实现
4.1.3This指针调整
4.1.4 结论
4.2GCC的实现
4.2.1 内部表示
4.2.2 实现机制
5 语言限制与陷阱
5.1 例子
5.2static_cast干了些什么
5.3 默认的转换
5.4 教训
5.5 如何避开陷阱
1 前言
C++语言支持指向成员函数的指针这一语言机制。
就像许多其它C++语言机制一样,它也是一把双刃剑,用得好,能够提高程序的灵活性、可扩展性等等,但是也存在一些不易发现的陷阱,我们在使用它的时候需要格外注意, 尤其是在我们把它和c++其它的语言机制合起来使用的时候更是要倍加当心。
关键字:
成员函数指针,继承,虚函数,this指针调整,static_cast
2 语法
C++成员函数指针(pointertomemberfunction)的用法和C语言的函数指针有些相似.
下面的代码说明了成员函数指针的一般用法:
class ClassName{public:
int foo(int);}
int (ClassName:
:
*pmf)(int)=&ClassNmae:
:
foo;
ClassName c; //.*的用法,经由对象调用
(c.*pmf)(5); //A
ClassName*pc=&c; //->*的用法,经由对象指针调用
(Pc->*pmf)(6); //B
使用typedef可以让代码变得略微好看一点:
typedef int (ClassName:
:
*PMF)(int);
PMFpmf=&ClassName:
:
foo;
注意获取一个成员函数指针的语法要求很严格:
1) 不能使用括号:
例如&(ClassName:
:
foo)不对。
2) 必须有限定符:
例如&foo不对。
即使在类ClassName的作用域内也不行。
3) 必须使用取地址符号:
例如直接写ClassName:
:
foo不行。
(虽然普通函数指针可以这样)
所以,必须要这样写:
&ClassName:
:
foo。
C++成员函数的调用需要至少3个要素:
this指针,函数参数(也许为空),函数地址。
上面的调用中,->*和.*运算符之前的对象/指针提供了this(和真正使用this并不完全一致,后面会讨论),参数在括号内提供,pmf则提供了函数地址。
注意这里成员函数指针已经开始显示它“异类”的天性了。
上面代码中注释A和B处两个表达式,产生了一个在C++里面没有类型(type)的“东西”(这是C++语言里面唯一的例外,其它任何东西都是有类型的),这就是.*和->*运算符:
(c.*pmf)
(Pc->*pmf)
这两个运算符求值生成的“东西”我们只知道可以把它拿来当函数调用一样使唤,别的什么也不能干,甚至都不能把它存在某个地方。
就因为这个原因,AndreiAlexandrescu 在他那本著名的《Modernc++design》里面就说,成员函数指针和这两个操作符号是“curiouslyhalf-bakedconceptinc++”。
(5.9节)
C++里面引入了“引用”(reference)的概念,可是却不存在“成员函数的引用”,这也是一个特殊的地方。
(当然,我们可以使用“成员函数指针”的引用,呵呵)
3 与其它语言机制的混合使用
C++是一种Multi-Paradigm的语言,各种语言机制混合使用也是平常的事。
这里我们只提几种会影响到成员函数指针实现和运行的语言机制。
3.1 继承
根据C++语言规定,成员函数指针具有contravariance特性,就是说,基类的成员函数指针可以赋值给继承类的成员函数指针,C++语言提供了默认的转换方式,但是反过来不行。
3.2 虚函数
首先要说明,指向虚拟成员函数(virtualfunctionmember)的指针也能正确表现出虚拟函数的特性。
举例说明如下:
class B{ public:
virtual int foo(int){/*B'simplementation*/return 0;}};
class D:
public B{ public:
virtual int foo(int){ /*D'simplementation*/ return 0;}};
int (B:
:
*pmf)(int)=&B:
:
foo;
Dd;
B*pb=&d;
(d.*pmf)(0); //这里执行D:
:
foo
(pb->*pmf)(0); //这里执行D:
:
foo,多态
C++借由虚函数提供了运行时多态特性,虚函数的实现和普通函数有很大的不同。
一般编译器都是采用大家都熟悉的v-table(virtualfunctiontable)的方式。
所有的虚函数地址存在一个函数表里面,类对象中存储该函数表的首地址(vptr_point)。
运行时根据this指针、虚函数索引和虚函数表指针找到函数调用地址。
因为这些不同,所以成员函数指针碰上虚函数的时候,也需要作特殊的处理,才能正确表现出所期望的虚拟性质。
3.2 多继承
这里扯上多继承,是因为多继承的存在导致了成员函数指针的实现的复杂性。
这是因为编译器有时候需要进行”this”指针调整。
举例说明如下:
class B1{};
class B2{};
class D:
public B1, public B2{}
假设上面三个对象都不涉及到虚函数,D在内存中的典型布局如下图所示(如果有虚函数则多一个vptr指针,差别不大):
现在假设我们经由D对象调用B2的函数,
Dd;
d.fun_of_b2();
这里传给fun_of_b2的this指针不能是&d, 而应该对&d加上一个偏移,得到D内含的B2子对象的首地址处。
成员函数指针的实现必须考虑这种情况。
多继承总是不那么受欢迎。
不过即使是单继承,上面的情况也会出现。
考虑下面的例子:
classB{}; //non-virtualclass
classD:
publicB{};//virtualclass
假设B是一个普通的类,没有虚拟成员函数。
而D加上了虚拟成员函数。
那么D的典型内存布局如下图所示:
因为D引入了vptr指针,而一般的实现都将vptr放在对象的开头,这就导致经由D对象访问B的成员函数的时候,仍然需要进行this指针的调整。
Dd;
d.fun_of_b(); //this 指针也需要调整,否则fun_of_b的行为就会异常
4 实现
从上面一节我们可以看到,编译器要实现成员函数指针,有几个问题是绕不过去的:
1) 函数是不是虚拟函数,这个涉及到虚函数表(__vtbl)的访问。
2) 函数运行时,需不需要调整this指针,如何调整。
这个涉及到C++对象的内存布局。
事实上,成员函数指针必须记住这两个信息。
为什么要记住是否为虚函数就不用解释了。
但是this指针调整为什么要记住呢?
因为在.*和->*运算符求值时必须用到。
考虑上面那个多继承的例子:
int(D:
:
*pmf)(int)=&B2:
:
foo_of_b2; //A
Dd;
(d.*pmf)(0); //B
看看上面的代码,其实我们在A处知道需要进行this指针调整,也知道该怎么调整。
但是这时候this还没出世呢,还不到调整的时候。
到了B处终于有了This指针了,可是又不知道该怎样调整了。
所以pmf必须记住调整方式,到了B处调用的时候,再来进行调整。
4.1Microsoft的实现
4.1.1 内部表示
MicrosoftVC的实现采用的是Microsoft一贯使用的Thunk技术(不知道这个名字怎么来的,不过有趣的是把它反过来拼写就变成了大牛Knuth的名字,呵呵)。
对于Mircosoft来说,成员函数指针实际上分两种,一种需要调节this指针,一种不需要调节this指针。
先分清楚那些情况下成员函数指针需要调整this指针,那些情况下不需要。
回忆上一节讨论的c++对象内存布局的说明,我们可以得出结论如下:
如果一个类对象obj含有一些子对象subobj,这些子对象的首地址&subobj和对象自己的首地址&obj不等的话,就有可能需要调整this指针。
因为我们有可能把subobj的函数当成obj自己的函数来使用。
根据这个原则,可以知道下列情况不需要调整this指针:
1) 继承树最顶层的类。
2) 单继承,若所有类都不含有虚拟函数,那么该继承树上所有类都不需要调整this指针。
3) 单继承,若最顶层的类含有虚函数,那么该继承树上所有类都不需要调整this指针。
下列情况可能进行this指针调整:
1) 多继承
2) 单继承,最顶的baseclass不含virtualfunction,但继承类含虚函数。
那么这些继承类可能需要进行this指针调整。
Microsoft把这两种情况分得很清楚。
所以成员函数的内部表示大致分下面两种:
structpmf_type1{
void*vcall_addr;
};
structpmf_type2{
void*vcall_addr;
int delta; //调整this指针用
};
这两种表示导致成员函数指针的大小可能不一样,pmf_type1大小为4,pmf_type2大小为8。
有兴趣的话可以写一段代码测试一下。
4.1.2 Vcall_addr实现
上面两个结构中出现了vcall_addr, 它就是Microsoft 的Thunk技术核心所在。
简单的说,vcall_addr是一个指针,这个指针隐藏了它所指的函数是虚拟函数还是普通函数的区别。
事实上,若它所指的成员函数是一个普通成员函数,那么这个地址也就是这个成员函数的函数地址。
若是虚拟成员函数,那么这个指针指向一小段代码,这段代码会根据this指针和虚函数索引值寻找出真正的函数地址,然后跳转(注意是跳转jmp,而不是函数调用call)到真实的函数地址处执行。
看一个例子。
//源代码
class C
{
public:
int nv_fun1(int){return 0;}
virtual int v_fun(int){return 0;}
virtual int v_fun_2(int){return 0;}
};
void foo(C*c)
{
int (C:
:
*pmf)(int);
pmf=&C:
:
nv_fun1;
(c->*pmf)(0x12345678);
pmf=&C:
:
v_fun;
(c->*pmf)(0x87654321);
pmf=&C:
:
v_fun_2;
(c->*pmf)(0x87654321);
}
;foo的汇编代码,release版本,部分地方进行了优化
:
0040100056 pushesi
:
004010018B742408 movesi,dwordptr[esp+08]
; pmf=&C:
:
nv_fun1;
; (c->*pmf)(0x12345678);
:
004010056878563412 push12345678
:
0040100A 8BCE movecx,esi ;this
:
0040100C E81F000000 call 00401030
; pmf=&C:
:
v_fun;
; (c->*pmf)(0x87654321);
:
004010116821436587 push87654321
:
004010168BCE movecx,esi ;this
:
00401018E803070000 call 00401720
; pmf=&C:
:
v_fun_2;
; (c->*pmf)(0x87654321);
:
0040101D6821436587 push87654321
:
004010228BCE movecx,esi ;this
:
00401024E807070000 call 00401730
:
004010295E popesi
:
0040102A C3 ret
:
00401030 33C0 ; 函数实现 xoreax,eax
:
00401032C20400 ret0004
:
00401720 8B01 ;vcall moveax,dwordptr[ecx]
:
00401722FF20 jmpdwordptr[eax]
:
00401730 8B01 ;vcall moveax,dwordptr[ecx]
:
00401732FF6004 jmp[eax+04]
从上面的汇编代码可以看出vcall_addr的用法。
00401030, 00401720,00401730都是vcall_addr的值,其实也就是pmf的值。
在调用的地方,我们不能分别出是不是虚函数,所看到的都是一个函数地址。
但是在vcall_addr被当成函数地址调用后,进入vcall_addr,就有区别了。
00401720,00401730是两个虚函数的vcall,他们都是先根据this指针,计算出函数地址,然后jmp到真正的函数地址。
00401030是C:
:
nv_fun1的真实地址。
Microsoft的这种实现需要对一个类的每个用到了的虚函数,都分别产生这样的一段代码。
这就像一个template函数:
template
void vcall(void* this)
{
jmp this->vptr[index]; //pseudoasmcode
}
每种不同的index都要产生一个实例。
Microsoft就是采用这样的方式实现了虚成员函数指针的调用。
4.1.3 This指针调整
不过还有一个this调整的问题,我们还没有解决。
上面的例子为了简化,我们故意避开了this指针调整。
不过有了上面的基础,我们再讨论this指针调整就容易了。
首先我们需要构造一个需要进行this指针调整的情况。
回忆这节开头,我们讨论了哪些情况下需要进行this指针调整。
我们用一个单继承的例子来进行说明。
这次我们避开virtual/non-virtualfunction的问题暂不考虑。
class B{
public:
B():
m_b(0x13572468){}
int b_fun(int) {
std:
:
cout<<'B'< : endl; return 0; } private: int m_b; }; class D: public B{ public: D(): m_d(0x24681357){} virtual int foo(int) { std: : cout<<'D'< : endl; return 0; } private: int m_d; }; // 注意这个例子中virtual的使用 void test_this_adjust(D*pd, int (D: : *pmf)(int)) { (pd->*pmf)(0x12345678); } : 00401000 moveax,dwordptr[esp+04] ;this入参 : 00401004 movecx,dwordptr[esp+0C] ;delta入参 : 00401008 push12345678 ;参数入栈 : 0040100D addecx,eax ;this=ecx=this+delta : 0040100F call[esp+0C] ;vcall_addr入参 : 00401013 ret void test_main(D*pd) { test_this_adjust(pd,&D: : foo); test_this_adjust(pd,&B: : b_fun); } ;test_this_adjust(pd,&D: : foo); : 00401020 xorecx,ecx : 00401022 pushesi : 00401023 movesi,dwordptr[esp+08] ;pd,this指针 : 00401027 moveax, 004016A0 ;D: : foovcall地址 : 0040102C pushecx ;pushdelat=0,ecx=0 : 0040102D pusheax ;pushvcall_addr : 0040102E pushesi ;pushthis : 0040102F call00401000 ;calltest_this_adjust ;test_this_adjust(pd,&B: : b_fun); : 00401034 movecx,00000004 ;和上面的调用不同了 : 00401039 moveax,00401050 ;B: : b_fun地址 : 0040103E pushecx ;pushdelta=4,exc=4 : 0040103F pusheax ;pushvcall_addr,B: : b_fun地址 : 00401040 pushesi ;pushthis : 00401041 call00401000 ;calltest_this_adjust : 00401046 addesp,00000018 : 00401049 popesi : 0040104A ret 注意这里和上面一个例子的区别: 在调用test_this_adjust(pd,&D: : foo)的时候,实际上传入了3个参数,调用相当于 test_this_adjust(pd,vcall_address_of_foo,delta(=0)); 调用test_this_adjust(pd,&B: : b_fun)的时候,也是3个参数 test_this_adjust(pd,vcall_address_of_b_fun,delta(=4)); 两个调用有个明显的不同,就是delta的值。 这个delta,为我们后来调整this指针提供了帮助。 再看看test_this_adjust函数的汇编代码,和上一个例子的不同,也就是多了一句代码: : 0040100D addecx,eax ;this=ecx=this+delta 这就是对this指针作必要的调整。 4.1.4 结论 Microsoft根据情况选用下面的结构表示成员函数指针,使用Thunk技术(vcall_addr)实现虚拟函数/非虚拟函数的自适应,在必要的时候进行this指针调整(使用delta)。 struct pmf_type1{ void*vcall_addr; }; struct pmf_type2{ void*vcall_addr; int delta; //调整this指针用 }; 4.2GCC的实现 GCC对于成员函数指针的实现和Microsoft的方式有很大的不同。 4.2.1 内部表示 GCC对于成员函数指针统一使用类似下面的结构进行表示: struct { void*__pfn; //函数地址,或者是虚拟函数的index long __delta; //offset, 用来进行this指针调整 }; 4.2.2 实现机制 先来看看GCC是如何区分普通成员函数和虚拟成员函数的。 不管是普通成员函数,还是虚拟成员函数,信息都记录在__pfn里面。 这里有个小小的技巧。 我们知道一般来说因为对齐的关系,函数地址都至少是4字节对齐的。 这就意味这一个函数的地址,最低位两个bit总是0。 (就算没有这个对齐限制,编译器也可以这样实现。 ) GCC充分利用了这两个bit。 如果是普通的函数,__pfn记录该函数的真实地址,最低位两个bit就是全0,如果是虚拟成员函数,最后两个bit不是0,剩下的30bit就是虚拟成员函数在函数表中的索引值。 使用的时候,GCC先取出最低位两个bit看看是不是0,若是0就拿这个地址直接进行函数调用。 若不是0,就取出前面30位包含的虚拟函数索引,通过计算得到真正的函数地址,再进行函数调用。 GCC和Microsoft对这个问题最大的不同就是GCC总是动态计算出函数地址,而且每次调用都要判断是否为虚拟函数,开销自然要比Microsoft的实现要大一些。 这也差不多可以算成一种时间换空间的做法。 在this指针调整方面,GCC和Mircrosoft的做法是一样的。 不过GCC在任何情况下都会带上__delta这个变量,如果不需要调整,__delta=0。 这样GCC的实现比起Microsoft来说要稍简单一些。 在所有场合其实现方式都是一样的。 而且这样的实现也带来多一些灵活性。 这一点下面“陷阱”一节再进行说明。 GCC在不同的平台其实现细节可能略有不同,我们来看一个基于Intel平台的典型实现:
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- C+ 对象 内存 结构