C++的常量折叠(一)

时间:2021-07-22 23:20:08

前言

前几天女票问了我一个阿里的面试题,是有关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 = ; const int i = ; //定义常量i
int *j = (int *) &i; //看到这里能对i进行取值,判断i必然后自己的内存空间
*j = ; //对j指向的内存进行修改
printf("0x%p\n0x%p\n%d\n%d\n",&i,j,i,*j); //观看实验效果 const int ck = ; //这个对照实验是为了观察,对常量ck的引用时,会产生的效果
int ik = ck; int i1 = ; //这个对照实验是为了区别,对常量和变量的引用有什么区别
int i2 = i1; return ;
}
下面看一下不同编译器的输出结果

vc6.0:

C++的常量折叠(一)

vs2010:

C++的常量折叠(一)

g++的输出结果:

C++的常量折叠(一)

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

C++的常量折叠(一)C++的常量折叠(一)

(2)反汇编代码:

 --- d:\cv_projects\commontest\commontest\commontest.cpp ------------------------
#include <stdio.h>
int main()
{
push ebp
mov ebp,esp
sub esp,114h
push ebx
0131138A push esi
0131138B push edi
0131138C lea edi,[ebp-114h]
mov ecx,45h
mov eax,0CCCCCCCCh
0131139C rep stos dword ptr es:[edi]
int i0 = ;
0131139E mov dword ptr [i0],0Bh const int i = ; //定义常量i
013113A5 mov dword ptr [i], //编译器确实为常量i分配了栈空间,并赋值为0
int *j = (int *) &i; //看到这里能对i进行取值,判断i必然后自己的内存空间
013113AC lea eax,[i]
013113AF mov dword ptr [j],eax
*j = ; //对j指向的内存进行修改
013113B2 mov eax,dword ptr [j]
013113B5 mov dword ptr [eax],
printf("0x%p\n0x%p\n%d\n%d\n",&i,j,i,*j); //观看实验效果
013113BB mov esi,esp
013113BD mov eax,dword ptr [j]
013113C0 mov ecx,dword ptr [eax]
013113C2 push ecx
013113C3 push
013113C5 mov edx,dword ptr [j]
013113C8 push edx
013113C9 lea eax,[i]
013113CC push eax
013113CD push offset string "0x%p\n0x%p\n%d\n%d\n" (131573Ch)
013113D2 call dword ptr [__imp__printf (13182B0h)]
013113D8 add esp,14h
013113DB cmp esi,esp
013113DD call @ILT+(__RTC_CheckEsp) (131112Ch) const int ck = ; //这个对照实验是为了观察,对常量ck的引用时,会产生的效果
013113E2 mov dword ptr [ck], //为常量分配栈空间,这是符号表中已经有了这个变量
int ik = ck;
013113E9 mov dword ptr [ik], //看到否,对常量ck的引用,会直接替换为常量的值9,这种替换很类似于宏替换,再看下面的实验 int i1 = ; //这个对照实验是为了区别,对常量和变量的引用有什么区别
013113F0 mov dword ptr [i1],
int i2 = i1; //这里引用变量i1,对i2进行赋值,然后看到否,对常量i1引用没有替换成i1的值,而是去栈中先取出i1的值,到edx寄存器中,然后再把值mov到i2所在的内存中
013113F7 mov eax,dword ptr [i1]
013113FA mov dword ptr [i2],eax return ;
013113FD xor eax,eax
}

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

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


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

C++的常量折叠(一)

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