C++内存布局–从一个修改私有变量的问题想到的

class Test {
public:
int get_value() { return value; }
private:
int value;
};
要求不用友元,不在这个类里添加任何代码,把成员变量k的值改为100,结果自然是通过公共成员函数get_value来验证。
“不在类里添加任何代码”,除了#define private public
我实在想不出其它的“偏门”方法了。那就想想不偏门的吧,论坛里好几位朋友提供了相当于如下代码的方法(为控制篇幅,本文中所有程序段都假设已包含了<iostream>头文件并引入了std名字空间,必要时还有其它头文件):
Test t;
*(int*)&t = 100;
cout << t.get_value() << endl;
这种方式利用对象内存布局的特点:整个类只有一个整型成员,没有继承或虚拟继承,也没有任何虚函数,那么这个对象的地址也就是它的第一个成员变量的地址,所以只需要把对象地址强转成整型,那么获得的就是那个成员变量的地址,然后对转换后的地址再解引用,修改即可,在VC2003中验证,结果是正确的。
但指针的强制转换总给人带来不爽,不大安全的感觉,上面那条最关键的语句相当于:
*reinterpret_cast<int*>(&t) = 100;
也就是说,它动用了C++语言中最“强”的指针转换方式(说它最强,是因为没有什么指针之间他不能转换的)。其实我们完全可以做得更“文明”一点,方法是再定义一个联合体,比如:
union TestInt {
Testt;
inti;
};
然后再:
TestInt ti;
ti.i = 100;
cout << ti.t.get_value() << endl;
同样达到了目的,但实质上依据的机理跟上面的指针转换是一致的。
这个方法没啥大问题,就是有局限性,只能用于修改类的第一个成员,如果在value之前再加一个成员,比如:
class Test {
public:
int get_value() { return value; }
private:
char ch;
int value;
};
这种方法就不灵了。
当然,你可以手工算,认为char占一个字节,于是会试图取对象地址再加1得到成员value的地址。但第一,这种方法无法不跨平台跨实现,char及int类型在不同的平台和编译器实现中的长度都可能是不一样的;第二,没有考虑字对齐问题,在内存中,value成员一般不会紧接着排布在ch之后,而是中间间开几个字节,最后将int类型对齐到另一个位置,比如4的倍数的地址上;而更糟糕的是,字对齐不仅跟平台相关,还跟预编译指令,甚至编译选项都会有关。所以,这种手工计算的方式还是放弃了吧。
有朋友提到了使用一种宏求出value成员相对于整个对象起始地址的偏移量,即定义一个宏:
#define OFFSET(TYPE,MEM) ((int)(char*)&(((TYPE*)0)->MEM))
这个宏通过把0地址转换为TYPE指针类型,然后从这个指针上“取”MEM成员,而MEM成员的地址转换后结果就是MEM成员相对于整个对象的偏移量(我们既然是从0地址开始算的,就不用再减去起始地址0)。
然后同,我们通过使用这个宏作用于原来的类和目标字段,即:
OFFSET(Test, value)
就可以获得value字段在Test类型对象中的偏移量,用对象的首地址加上这个偏移量,就可以得到value变量的地址,从而可以像上面一样解引用,修改。
这种方法不仅看起来难受,费解。事实上也根本行不通,因为这个宏所用到的技巧是从Test类型的指针上访问value成员——而valuee是private的!所以连编译都通不过。

论坛里有位朋友提出了另外一种方法可以巧妙地对付这个复杂一点的类,先做一个辅助类,它跟Test类很像,唯一的不同是它的成员都是public的:
class TestTwin {
public:
int get_value() { return value; }
public:
char ch;
int value;
};
于是,这个TestTwin类跟原来的Test类在内存布局上不会有什么不同,通过指针转换,很容易借助于它来修改Test类对象的value成员:
Test t;
TestTwin* p = reinterpret_cast<TestTwin*>(&t);
p->value = 100;
cout << t.get_value() << endl;
如果你不熟悉C++的显式指针转换方式:reinterpret_cast,在这里可以认为它相当于C风格的:
TestTwin* p = (TestTwin*)&t;
而前述的两条语句也可以合在一起,直接写成:
reinterpret_cast<TestTwin*>(&t)->value = 100;
还有,厌恶指针操作的朋友仍可采用前面介绍的联合体方法来运用这个模具类,只是这次定义的联合体是这样:
union TestTestTwin {
Testt;
TestTwintw;
};
而程序是这样:
TestTestTwin ttw;
ttw.tw.value = 100;
cout << ttw.t.get_value() << endl;

问题都解决了吗?如果类更复杂一些,会不会还有局限性呢?我们再把类改一改: 
class Test {
public:
int get_value() { return value; }
~Test() {}
private:
char ch;
int value;
public:
int a;
double b;
protected:
string e;
private:
short d;
};
这次不仅成员多了许多,有string类型的成员(须include <string>),还弄出个虚析构函数来(我们都知道拥有虚函数的类会导致其实例中多一个虚表指针)。但后面会看到,虚函数对我们讨论的问题影响不大,我们加上它只是想证明:只要方法足够好,不怕对象更复杂。
那上面的模具办法问题出在哪里呢?为什么不能同样再搞一个类,把那个value改为public的,然后用它来“套住”原来对象中value成员呢?
原因是C++语言只保证类中同一个access section(即从一个访问权限修饰符public/private/protected到另一个修饰符之间的部分)中定义的非静态成员变量会按照声明时的顺序分布的内存中,但并不保证跨越了不同access section的所有成员变量都在内存中按声明时的顺序存放,某种编译器完全有可能把所有的private块都合成一块,甚至整个给扔到所有protected成员的后边去(虽然VC并没这么做)。
换句话说:改掉了一个成员的访问权限,就可能改变了对象的内存布局。于是,改变了的模子也就不再能够套住相应位置上的成员。
但办法还是有,只需要将原来的改进一下:
在现有的C++对象模型中,为类增加一个非虚成员函数,不会改变对象的内存布局,我们可以利用这一点来写一个TestTwin:
class TestTwin {
public:
int get_value() { return value; }
void set_value(int v) { value = v; }
~TestTwin() {}
private:
char ch;
int value;
public:
int a;
double b;
protected:
float e;
private:
short d;
};
这个模具跟原来的Test类也是只有一点不同:增加了一个公共的,非虚的set_value方法,用来给私有成员value赋值。于是,程序可以这么写:
Test t;
reinterpret_cast<TestTwin*>(&t)->set_value(100);
cout << t.get_value() << endl;
验证通过。
增加的虚函数纯粹是个障眼物而已,它跟我们采用的方法几乎没有丝毫联系,所以也就丝毫不用担心虚函数对内存分布的影响会影响到这个方法的正确性。但被它一搞,那个使用联合体的方法这一次还真是不管用了,因为有了析构函数的类不能再放进联合体中了——否则当联合体实例的生命周期结束时,析构谁呢? 

想了关天,能想到的只有这么多了。
最后,不行不承认,“增加一个非虚成员函数,不会改变对象的内存布局”这句话也无法从C++标准中得到直接支持,只是对于目前大多数编译器来说,这都是没问题的。因为这种“让类的每个实例拥有一份独立的成员变量,而类的所有实例共享一份成员函数”的C++对象模型是C++之父Bjarne Stroustrup先生本人所提出的,其时间、空间效率都很好地符合了C++语言的设计初衷,不仅现代C++编译器没有不这么做的,就连Java/C#编译器也都这么做。所以,也算是个“相对真理”了。

By Lu Jun

80后男,就职于软件行业。习于F*** GFW。人生48%时间陪同电子设备和互联网,美剧迷,高清视频狂热者,游戏菜鸟,长期谷粉,临时果粉,略知摄影。

Leave a comment

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.