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:

image

vs2010:

image

g++的输出结果:

image

注意:对于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)方法:打个断点调试->窗口反汇编(还有好多功能,比如内存多线程等)

imageimage

(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 }
View Code

通过上述实验的分析可以容易看出,对可折叠的常量的引用会被替换为该常量的值,而对变量的引用就需要访问变量的内存。

总结:常量折叠说的是,在编译阶段,对该变量进行值替换,同时,该常量拥有自己的内存空间,并非像宏定义一样不分配空间,需澄清这点


前面说了个不许用gcc编译.c的C语言的程序,不然就没有了常量折叠的问题,先看一下执行的结果:

image

最后说一下gcc编译的C语言的const常量,这里并没有做常量折叠的这种优化,类似于const常量前面加上volatile这个关键字。具体是怎么回事?下一篇博客再说。

posted @ 2015-04-09 02:46  stemon  阅读(2776)  评论(10编辑  收藏  举报