C++的常量折叠(一)
前言
前几天女票问了我一个阿里的面试题,是有关C++语言的const常量的,其实她一提出来我就知道考察的点了:肯定是const常量的内存不是分配在read-only的存储区的,const常量的内存分配区是很普通的栈或者全局区域。也就是说const常量只是编译器在编译的时候做检查,根本不存在什么read-only的区域。
所以说C++的const常量和常量字符串是不同的,常量字符串是存储在read-only的区域的,他们的具体的存储区域是不同的。
就好像杨立翔老师在上课举得那个例子(讲的东西忘得差不多了,但是例子都记得,什么撕户口本,月饼模子之类的):
场景:老式的电影放映机,门口的检票员,最后排那个座位上的放映师傅,好多的观众席。
你的电影票上有你的名字,检票员的手里有个名字和座位号的对应表,检票员不允许观众乱坐位置。
所以游戏就这样开始了,电影院的每一个观众席都是const常量类型的,检票员就是编译器,在观众进门的时候,每个人都必须坐自己票上的位子,也就是说观众必须拿着自己的票进门。你拿着别人的票就不让你进去,所以就不能更换座位。
类比到C++语言上,场景应该是这样的:对于哪些const常量,在编译的时候就已经决定了,他们在内存的存储位置,你如果直接改这个常量的值是不合法的。就好像你不能拿着一张别人的电影票进电影院,因为检票员手里有一张座位号到姓名的映射表,他知道哪个座位该坐谁,并且不让观众随意调换(这个例子尼玛真的好形象啊,杨老师说的没有这么好,我后期根据自己的理解稍微添油加醋)。其实检票员手里的那张映射表就是编译器的符号表,是不是真的很形象,关于这个符号表后面说。
那么新的问题来了,观众进到电影院以后,我跟旁边的哥们说一声,我给你100块,你给我换一下位置吧,如果他同意了,我们就能换。检票员管不着,进都进来了,还能怎样。所以两个人就调换了位子。
C++的const常量一样是这个样子,编译器在编译的时候要查看它手里的符号表,如果你拿的是一个const常量,并且你要修改这个常量的值,他就拒绝。但是这种检查值会发生在编译器,如果我能绕过编译器,在运行的时候修改这个const的值,将会是很easy的一件事,就像前面说的,我给那哥们100块就搞定了。所以说const常量的存储空间同其他的变量的存储空间没有任何的区别,它的这种常量不允许就该的检查只发生在编译器的编译阶段。
但是常量字符串的存储位置就不同了,它的存储位置是read-only的区域,就像电影院最后的那个放映师的位置,那里是一个特殊的位置,是真的不允许随便的调换,你给他1000也不给你换,因为师傅要在那个位置放映,不然没法看电影。当然了,实现这种read-only的存储区域也很简单,把那个内存的页(page)的属性标记为只读的就好了。
对了,当时我还想起了这个:
const int *p1; /* p1所指向的int变量值不可改变,为常量,但可以改变p1指针的值 */ int * const p2; /* p2指针为常量,即p2的值不可改变,但可以改变p2指向对象的值 */ const int * const p3; /* p3指针是常量,同时p3所指向int对象的值也是常量 */
啰啰嗦嗦说这么长一个例子,其实很简单的道理,就是为了好玩,好记。先宏观的说一下const常量的实现机制,下面说一些具体的实现。
其实女票提出的问题不算难,是C++语言的一个知识点,总而言之就是一个常量折叠。
先说一个错误的理解(为什么要说一个错误的理解,因为它有助于正确的理解,哈哈):可折叠的常量就像宏一样,在预编译阶段对const常量的引用一律被替换为常量所对应的值。就和普通的宏替换没有什么区别,并且编译器不会为该常量分配存储空间。
看清楚了,上面说的是一个错误的理解,常量折叠确实会像宏替换一样把对常量的引用替换为常量对应的值,但是该常量是分配空间的,并且靠编译器来检查常量属性。
#define PI 3.14
int main()
{
const int r = 10;
int p = PI;//这里在预编译阶段产生宏替换,PI直接替换为3.14,其实就是int p = 3.14
int len = 2 * r;//这里会发生常量折叠,也就是说常量r的引用会替换成它对应的值,相当于int len = 2 * 10;
return 0;
}
如上述代码中所述,常量折叠表面上的效果和宏替换是一样的,只是,“效果上是一样的”,而两者真正的区别在于,宏是字符常量,在预编译阶段的宏替换完成后,该宏名字会消失,所有对宏如PI的引用已经全部被替换为它所对应的值,编译器当然没有必要再维护这个符号。而常量折叠发生的情况是,对常量的引用全部替换为该常量如r的值,但是,常量名r并不会消失,编译器会把他放入到符号表中,同时,会为该变量分配空间,栈空间或者全局空间。既然放到了符号表中,就意味着可以找到这个变量的地址(埋一个伏笔先)。
符号表不是一张表,是一系列表的统称,这里的const常量,会把这个常量的名字、类型、内存地址、值都放到常量表中。符号表还有一个变量表,这个表放变量的名字、类型、内存地址,但是没有放变量的值。
为了更能体现出常量折叠,看下面的对比实验:
int main() { int i0 = 11; const int i = 0; //定义常量i int *j = (int *) &i; //看到这里能对i进行取值,判断i必然后自己的内存空间 *j = 1; //对j指向的内存进行修改 printf("0x%p\n0x%p\n%d\n%d\n",&i,j,i,*j); //观看实验效果 const int ck = 9; //这个对照实验是为了观察,对常量ck的引用时,会产生的效果 int ik = ck; int i1 = 5; //这个对照实验是为了区别,对常量和变量的引用有什么区别 int i2 = i1; return 0; }
下面看一下不同编译器的输出结果
vc6.0:
vs2010:
g++的输出结果:
注意:对于Linux的GUN中的gcc是用来编译.c文件的C语言编译器,g++是用来编译.cpp的C++语言编译器。
我们这里讲的是C++的商量折叠,所以源文件要是.cpp的才可以。(C语言的const常量最后再说)
上面的程序的运行结果至少说明两点:
(1)i和j地址相同,指向同一块空间,i虽然是可折叠常量,但是,i确实有自己的空间
(2)i和j指向同一块内存,但是*j = 1对内存进行修改后,按道理来说,*j==1,i也应该等于1,而实验结果确实i实实在在的等于0。
这是为什么呢,就是本文所说的内容,i是可折叠常量,在编译阶段对i的引用已经别替换为i的值了,同时不同于宏替换的是,这个i还被存到了常量表中。
也就是说:
printf("0x%p\n0x%p\n%d\n%d\n",&i,j,i,*j);
中的i在预处理阶段已经被替换,其实已经被改为:
printf("0x%p\n0x%p\n%d\n%d\n",&i,j,0,*j);
同时在常量表中也有i这个变量,不然的话对i取地址是不合法的,这是和宏替换的不同点,宏替换是不会把宏名称放到常量表中的,预编译完就用不到了。
为了更加直观,下面直接上这个程序的反编译的汇编语言:
(1)方法:打个断点调试->窗口反汇编(还有好多功能,比如内存多线程等)
(2)反汇编代码:
1 --- d:\cv_projects\commontest\commontest\commontest.cpp ------------------------ 2 #include <stdio.h> 3 int main() 4 { 5 01311380 push ebp 6 01311381 mov ebp,esp 7 01311383 sub esp,114h 8 01311389 push ebx 9 0131138A push esi 10 0131138B push edi 11 0131138C lea edi,[ebp-114h] 12 01311392 mov ecx,45h 13 01311397 mov eax,0CCCCCCCCh 14 0131139C rep stos dword ptr es:[edi] 15 int i0 = 11; 16 0131139E mov dword ptr [i0],0Bh 17 18 const int i = 0; //定义常量i 19 013113A5 mov dword ptr [i],0 //编译器确实为常量i分配了栈空间,并赋值为0 20 int *j = (int *) &i; //看到这里能对i进行取值,判断i必然后自己的内存空间 21 013113AC lea eax,[i] 22 013113AF mov dword ptr [j],eax 23 *j = 1; //对j指向的内存进行修改 24 013113B2 mov eax,dword ptr [j] 25 013113B5 mov dword ptr [eax],1 26 printf("0x%p\n0x%p\n%d\n%d\n",&i,j,i,*j); //观看实验效果 27 013113BB mov esi,esp 28 013113BD mov eax,dword ptr [j] 29 013113C0 mov ecx,dword ptr [eax] 30 013113C2 push ecx 31 013113C3 push 0 32 013113C5 mov edx,dword ptr [j] 33 013113C8 push edx 34 013113C9 lea eax,[i] 35 013113CC push eax 36 013113CD push offset string "0x%p\n0x%p\n%d\n%d\n" (131573Ch) 37 013113D2 call dword ptr [__imp__printf (13182B0h)] 38 013113D8 add esp,14h 39 013113DB cmp esi,esp 40 013113DD call @ILT+295(__RTC_CheckEsp) (131112Ch) 41 42 const int ck = 9; //这个对照实验是为了观察,对常量ck的引用时,会产生的效果 43 013113E2 mov dword ptr [ck],9 //为常量分配栈空间,这是符号表中已经有了这个变量 44 int ik = ck; 45 013113E9 mov dword ptr [ik],9 //看到否,对常量ck的引用,会直接替换为常量的值9,这种替换很类似于宏替换,再看下面的实验 46 47 int i1 = 5; //这个对照实验是为了区别,对常量和变量的引用有什么区别 48 013113F0 mov dword ptr [i1],5 49 int i2 = i1; //这里引用变量i1,对i2进行赋值,然后看到否,对常量i1引用没有替换成i1的值,而是去栈中先取出i1的值,到edx寄存器中,然后再把值mov到i2所在的内存中 50 013113F7 mov eax,dword ptr [i1] 51 013113FA mov dword ptr [i2],eax 52 53 return 0; 54 013113FD xor eax,eax 55 }
通过上述实验的分析可以容易看出,对可折叠的常量的引用会被替换为该常量的值,而对变量的引用就需要访问变量的内存。
总结:常量折叠说的是,在编译阶段,对该变量进行值替换,同时,该常量拥有自己的内存空间,并非像宏定义一样不分配空间,需澄清这点
前面说了个不许用gcc编译.c的C语言的程序,不然就没有了常量折叠的问题,先看一下执行的结果:
最后说一下gcc编译的C语言的const常量,这里并没有做常量折叠的这种优化,类似于const常量前面加上volatile这个关键字。具体是怎么回事?下一篇博客再说。