有学友谢灵兵列一问题。有以下代码,我删除部分无关内容。
- #include <iostream>
- using namespace std;
- class base
- {
- public:
- virtual void funb1()
- {
- cout << "funb1 base called." << endl;
- }
- void funb2()
- {
- cout << "funb2 base called." << endl;
- }
- };
- class der : public base
- {
- public:
- void funb1()
- {
- cout << "funb1 dev called." << endl;
- }
- virtual //个人认为,这个virtual要去掉
- void funb2()
- {
- cout << "funb2 dev called." << endl;
- }
- };
- int main()
- {
- base b;
- der * pder = (der *)&b; //把一个基类对象,硬生生转换为派生类对象
- pder->funb1();
- pder->funb2();
- return 0;
- }
说这是一面试题,问什么呢?我没看到,估计是:“上面的程序会有什么行为?”
这个问题回答起来还真的会很累!
我说个以下几点,请综合考虑,但其中第一点是牢骚,建议跳过。
第一、将基类对象,强制转换为派生类,在C++标准中,其行为是“未定义/undefined”,在C++中,什么问题最可怕?就是未定义的行为最可怕。
所以,如果你非要说这是一道“笔试题”,那么你得写出,这道题问的是什么啊? 只抄来一堆代码不抄来问题,不少人会这样,其实问题比代码重要。如果这是我出的笔试题,则我的预留的答案就是三个字,谁能在答案上写上“未定义”,就说明他平常看的C++书不少。这是我自己会的二等答案,一等答案是:根据常见编译器及版本,详细给出不同结果,这自然是牛人们才有本事的。
第二,说完标准,来说现实,
“现实中,谁会写这样不合情理的代码呀?”这是你说的,不一定噢,那是你还没碰上变态的项目……实话,在C++中是这类用法我感觉是比较少,但在Delphi,尤其是Delphi.net,这类用法可真不少。有时叫做“Helper”类。为什么?我们还是以C++来分析。
2.1 首先,请把第26行,der类中,funb2 前面的“virtual”删除掉---我认为你可能是抄错了吧?原因后面会说。
2.2 我们先来简单说一下“虚表”的概念。首先看base,它没有成员数据,所以似乎它可以被认为是C语言所喜欢的POD,但其实它是一个POD吗?换句话说,你可以把一个base的对象,直接传给纯C的函数吗?当然不行,因为它含有虚函数,而虚函数会在对象中插入一个“虚函数地址跳转表/vtable”。
这就是C++单一的虚函数机制为人诟病的地方:只能牺牲空间,以赢得时间,不像类似delphi等语言,你可以选择要空间还是要时间,这样说没冤枉C++标准,因为它根本没规定虚函数该如何实现。还好众多编译器都选择了当前我们熟悉的vtable方式,并且“不约而同地”选择了牺牲空间,赢得时间的方案,即:全都把vtable地址放到对象里去了,这样,手里有一个对象的地址(比如:this)以后,要取得某个虚函数,只需加一次偏移。要说省空间费时间的方法,是把虚表放在类里,然后对象保留一个指向类的指针,对于C++,如果不想违反C++标准的天条,那么,就只好想办法在基类里偷偷加静态数据了,就像MFC干的一样。这是另话,打住。
2.3 现在我们知道base其实是有个数据的。插一句话,如果base 真的有一个成员数据,那么,虚表数据是放在成员数据之前还是之后?这太重要的,但可惜不同C++编译器在这点上,这回不同了!C++ 带有导出类的动态库,为什么不存在二进制兼容接口?为什么VC写的C++风格的DLL,BC调用不了?而C风格的DLL却能通用?这就是罪责之一。
回到主题,由于base类并没有成员数据,所以VTAB在前或在后,结果一个样(就一个人在排队,你说它是排在最前还是排在最后?)。不过还是要说一下,现在常见的编译器,VTAB是放在实际成员数据前面的,注意,我说了“通常”。
虚表大致是一个“数组指针”的指针(没认真推敲,所以大致),即在对象里存一个地址再指向一张表(数组),这个地址。我们可以做这样一个实验:写一个既有虚表,又有成员数据的类:
- struct Coo
- {
- int d; //特意将d放在最前面,但其实它前面还有vtable
- Coo ()
- : d(100)
- {}
- virtual void foo();
- };
- void Coo::foo()
- {}
- int main()
- {
- Coo o;
- int *p = (int *)&o;
- cout << *p << endl
- << o.d << endl;
- return 0;
- }
如果Coo::foo函数不是virtual,那么屏幕输出肯定是两个100 (即d的值)。但现在呢?不是了。*p是一个大大的数字,其实就是vtable的内存地址。
说了半天,好像还没有说到正题。别急,马上来了:也就是说,当程序运行时,对于非虚成员函数的调用,是直接寻址的(call XXXX),对于虚成员函数的调用,则需要先找到“vtable”地址,然后再从表中找到真正函数的地址(call XXXX1+XXXX2).对应到本题的base类的对象,则funb1函数需找到vtable后再进行跳转,而funb2是直接跳转到手上的地址。说到这里,你应该能想到答案了,那你不用看后面了,后面还长啊。
2.4 前面说了,vtab其实是一个指针,它指向一个数组,并且数组里的每个元素都是一个函数的地址。好,我们可以对Coo类的对象,“hack”得更彻底点:
- Coo o;
- int *p = (int *)&o;
- int addr = *(int *)(*p);
- cout << addr << endl
- << o.d << endl;
现在,屏幕输出的addr,就是真真实实地,Coo::foo的地址了,如果你不信,你可以定义一个函数指针,再把addr(强制转换以后)赋值给它,然后调用函数指针,就会执行(当成作业吧)。你说不可能,因为没有this指针,如何调用成员函数?没关系,死不了,只要foo函数里,没有碰到成员数据,它就和一个静态成员函数,没多大区别。如果foo内用到了成员怎么办?也可以实现,定义函数指针时,额外添加一个Coo* 参数:
- typedef void (* PFUNC)(Coo*);
我在本文最后面,再给出完整答案吧,免得节外生枝,我后悔回答这个问题了。
2.5 接下来,是派生的类的问题vtable问题。简单地说:
首先,它会把基类的虚表完整地复制一份,就像派生普通的成员数据。(众多编译器作者再次高唱:我们的目标是:费空间省时间)。
假设我们有一个Coo2类,它派生自Coo,但它不增加任何新数据。(这里任何是指:成员数据和虚表),则可以在前述代码上,再做以下测试:
- struct Coo2 : public Coo
- {
- };
- Coo2 o2;
- p = (int *)(&o2);
- addr = *(int *)(*p);
- cout << addr << endl;
再次输出的addr,和前面输出的addr,完全一样,它们都是Coo::foo的地址嘛!
(p是强制将o的地址转换为一个整数指针,addr是将p所指向的内容,再强制当作是一个整数指针,然后再取出该指针指向的内容,就是函数的地址)
其次,如果派生类对基类某个虚函数,有重新实现。比如例中的der之于base,则有这下面的关系:
base 的虚表:
[base::funb1的地址]
der 的虚表:
[der::funb1的地址][base::funb1的地址]
可见,在der虚表中,der::funb1挤占了基类同名函数的地址!就这就是答案了。
请看这行代码:
- der * pder = (der *)&b;
pder被强制“看作”是一个派生类,但这个强制转换有可能改变它所指向的b其实是一个base对象的事实吗?有可能真了改变了b的内存布局吗?当然都不可能。下面,我们把*pder的虚表真实内存布局和被“误以为”的内存布局,排在一起:
真实的虚表:[base::funb1的地址]
愣装的虚表:[der::funb1的地址][base::funb1的地址]
一切水落石出,当调用: pder->funb1(); 由于funb1是虚函数,所以到虚表里去找,它找到了愣装的“[der::funb1的地址]”,它很高兴,但其实它找到的那个数字是“[base::funb1的地址]”。所以当然就是调用基类的函数了。
要不要再写下去,可说的还很多,比如:
如果在der中写一个函数,然后它调用base::funb1()会怎样?答:会死!为什么?
如果如原题,没有去掉der::funb2的virtual修饰,我们已经它会死,但为什么会死?
如果base中含有成员数据呢?如果派生类中也含有成员数据呢?大家当成作业想吧。
最后说一句,这样做法,有何用呢?只是在笔试时玩酷?
先问一下各位,如果手头有类的定义及实现库,但没有实现的源代码,有没有办法在类之外,访问到它的私有成员(数据或函数),针对具体编译器,肯定有办法啊!就是那样取对象地址,然后强制转换,就可以访问了嘛。
好,就是为了强制访问私有数据吗?也不一定,如果你正确回答了前面的三个如果,你就可以知道,我们可以通过这种方法来扩展一个类----扩展一个类难道不是“派生”吗?当然是派生,但有候,会派生不了啊~~~因为一个对象已经原类库中产生,并且new出该对象的代码,我们已经无法个修改了,在这种“非法”需求下,C++程序员们就创造了这种方法,并把它叫做是“hacker/黑客类”。咦,前面不是说,Delphi里,把它叫做“Helper/助手类”吗?怎么两种语言的叫法差别这么大?没办法,Delphi的程序员,通常比较善良(包括熊猫烧香的作者),而C++的程序员的文化,一直比较“黑”。
第三,也是最后,给出前面所问的,后面所说的“黑”的完整例子:
- #include <iostream>
- using namespace std;
- class Coo
- {
- public:
- Coo ()
- : d(100)
- {}
- private:
- virtual void foo(); //私有的!
- int d;
- };
- void Coo::foo()
- {
- cout << d << "~~~!!!!~~~~" << endl;
- }
- int main()
- {
- Coo o;
- int *p = (int *)(&o);
- int addr = *(int *)(*p);
- typedef void (* PFUNC)(Coo*);
- PFUNC pfunc = (PFUNC)(addr);
- pfunc(&o); //hack! 调用了私有成员函数
- return 0;
- }
半夜看到问题,半夜回答,文字或表述或有错乱。代码简单地通过gcc编译测试,无其它更多编译器测试。
补充几句:正如《白话C++》所说,学好C++语言,包括熟悉它的标准,那是“童子功”,但分析本题会发现:回答这个笔试题,几乎不靠"C++标准"的知识,而是需要了解C++幕后的实现。我总觉得这像“旁门左道”,或在某些时候有特殊功力,但基本上以初学者不要去纠缠它。我特意看了谢同学的原贴,看到他果然是在费力地想用“C++标准”去委婉解释。不客气一点,他是在犯一个“有趣”的错误: 在知道了“答案”之后,然后开始用“标准”去套这个“答案”(这对于标准理解,有时会是反作用)。
-------------------------------------
如果您想与我交流,请点击如下链接成为我的好友:
http://student.csdn.net/invite.php?u=112600&c=f635b3cf130f350c
最后
以上就是体贴冬日为你收集整理的C++语言的“黑客类”行为简析的全部内容,希望文章能够帮你解决C++语言的“黑客类”行为简析所遇到的程序开发问题。
如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。
发表评论 取消回复