什么是常量?相信绝大多数写过程序的人见到这两个字的第一反应就是——不能更改的量。没问题,书本上这样告诉我们,编译器也这样告诉我们,好像常量就真的是那么简单,纯粹,让人心安理得的去使用它,不需要任何顾虑。但事实并没有想象中的那么单纯。
1.常量并不一定是常量
int main()
{
const int x=3;
}
这是一个“假常量”,当我们在下边敲出 x=4; 这样的代码时,编译器根本不会让你通过,至于为什么说它是假的,因为编译器可以被轻而易举的绕过去。
首先我们来看看表面现象,对p的一顿操作让p成功的指向了x,我们成功的绕过了编译器,获得了指向常量x的非常量指针,并且没有动用const_cast,当我们窃喜可以修改这个“常量”x的值的时候,运行结果给我们当头一棒,x的值并没有改。难道是编译器明察秋毫发现我们的小伎俩了?不,编译器真的那么聪明是不会把结果显示出来的,甚至都不会让你通过编译。那为什么x的值没有改变呢?让我们来看看汇编代码。我先吧整体的代码贴出来再节选部分进行分析。
const int x=3;
00176E2E mov dword ptr [x],3
int y=4;
00176E35 mov dword ptr [y],4
int*p=&y;
00176E3C lea eax,[y]
00176E3F mov dword ptr [p],eax
p=p-(&y-&x);
00176E42 lea eax,[y]
00176E45 lea ecx,[x]
00176E48 sub eax,ecx
00176E4A sar eax,2
00176E4D shl eax,2
00176E50 mov edx,dword ptr [p]
00176E53 sub edx,eax
00176E55 mov dword ptr [p],edx
*p=5;
00176E58 mov eax,dword ptr [p]
00176E5B mov dword ptr [eax],5
cout<<x<<endl;
00176E61 mov esi,esp
00176E63 mov eax,dword ptr ds:[001802F0h]
00176E68 push eax
00176E69 mov edi,esp
00176E6B push 3
00176E6D mov ecx,dword ptr ds:[1802ECh]
00176E73 call dword ptr ds:[1802F4h]
00176E79 cmp edi,esp
00176E7B call __RTC_CheckEsp (01712D0h)
00176E80 mov ecx,eax
00176E82 call dword ptr ds:[1802F8h]
00176E88 cmp esi,esp
00176E8A call __RTC_CheckEsp (01712D0h)
cout<<y<<endl;
00176E8F mov esi,esp
00176E91 mov eax,dword ptr ds:[001802F0h]
00176E96 push eax
00176E97 mov edi,esp
00176E99 mov ecx,dword ptr [y]
00176E9C push ecx
00176E9D mov ecx,dword ptr ds:[1802ECh]
00176EA3 call dword ptr ds:[1802F4h]
00176EA9 cmp edi,esp
00176EAB call __RTC_CheckEsp (01712D0h)
00176EB0 mov ecx,eax
00176EB2 call dword ptr ds:[1802F8h]
00176EB8 cmp esi,esp
00176EBA call __RTC_CheckEsp (01712D0h)
cout<<&x<<endl;
00176EBF mov esi,esp
00176EC1 mov eax,dword ptr ds:[001802F0h]
00176EC6 push eax
00176EC7 mov edi,esp
00176EC9 lea ecx,[x]
00176ECC push ecx
00176ECD mov ecx,dword ptr ds:[1802ECh]
00176ED3 call dword ptr ds:[1802FCh]
00176ED9 cmp edi,esp
00176EDB call __RTC_CheckEsp (01712D0h)
00176EE0 mov ecx,eax
00176EE2 call dword ptr ds:[1802F8h]
00176EE8 cmp esi,esp
00176EEA call __RTC_CheckEsp (01712D0h)
cout<<p<<endl;
00176EEF mov esi,esp
cout<<p<<endl;
00176EF1 mov eax,dword ptr ds:[001802F0h]
00176EF6 push eax
00176EF7 mov edi,esp
00176EF9 mov ecx,dword ptr [p]
00176EFC push ecx
00176EFD mov ecx,dword ptr ds:[1802ECh]
00176F03 call dword ptr ds:[1802FCh]
00176F09 cmp edi,esp
00176F0B call __RTC_CheckEsp (01712D0h)
00176F10 mov ecx,eax
00176F12 call dword ptr ds:[1802F8h]
00176F18 cmp esi,esp
00176F1A call __RTC_CheckEsp (01712D0h)
不要被这一大堆乱七八糟的字符吓到,其实大多数跟我们的主题没有关系,这里全贴出来是为了让读者相信的确是所有的代码都在这了,并没有漏掉什么关键的语句,先来看下面这两行。
const int x=3;
00176E2E mov dword ptr [x],3
int y=4;
00176E35 mov dword ptr [y],4
所以说为什么把这个x叫做假常量,因为有没有这个const并不影响翻译出来的汇编代码,也就是说除了我们和编译器,谁也不知道有这个const的存在,我们之所以不能无视它,是因为“严厉”的编译器决定了我们的代码能不能变成可执行的程序。这里有一点非常重要,x是在栈上有空间的,和y一样,都是栈上的变量。一定记住这句话,这很重要。在下文中,形如x这种常量我称作“栈上常量”。
*p=5;
00176E58 mov eax,dword ptr [p]
00176E5B mov dword ptr [eax],5
我跳过了前两行语句,这里只需要知道p现在确实指向x的地址就够了,下边的输出结果也印证了这一点。可见,我们确实改变了x的值。那么为什么打印出来的x还是3呢?
所有的原因都在这里,当我们输出y的时候,编译器忠实的把y的值放到了寄存器里,把寄存器里的值当成参数传给了函数,但是对于x,编译器根本没有从x的内存里取x的值,它直接就push了一个3给函数,这就是为什么打印出的是3而不是5。那么这个3是从哪里来的呢?3是一个常量,它存在于进程地址空间的常量区,当我们敲出const int x=3; 的时候,我们不但把x所属的空间赋值为3,我们还在常量区创建了一个数字3的常量。
那么为什么我们使用x的时候编译器却回过头来操作这个3呢?这个现象叫做“常量折叠”,是一种编译器对于栈上常量的优化,当使用栈上常量的时候会直接从常量区取对应的值出来,这样少了一个从变量中取值到寄存器的过程,可以和对y的操作对比来看。
如果你还是不理解常量折叠,可以试试看一下下边的代码
换成字符串可能会比整数要方便理解一些,不过本质上来看,作为常量的它们都是内存地址空间常量段中的一块内存罢了。
想要禁用常量折叠也很简单,使用volitile让x在每次使用的时候都从内存中取值就ok了
这里出现了一个很大的问题,对x取地址居然输出的是1,我测试了一下只要变量前边加了volatile,对它取地址就是1,汇编代码我暂时也看不懂发生了什么,这个坑以后再填。
2.常量真的就是常量
const int x=3;
int main ()
{
}
把定义的位置放到全局空间里,一切都不一样了,这下它真的是常量了。
const int y=4;
int main()
{
const int x=3;
int*p=const_cast<int*>(&y);
int*q=const_cast<int*>(&x);
}
const int x=3;
003D4BFE mov dword ptr [x],3
int*p=const_cast<int*>(&y);
003D4C05 mov dword ptr [p],3DCBB0h
int*q=const_cast<int*>(&x);
003D4C0C lea eax,[x]
003D4C0F mov dword ptr [q],eax
对y取地址获得的是一个实打实的常量区地址,我们可以用 *q=12345; 改变x的值,但是就别想 *p=56789了,编译器虽然发现不了,但是操作系统是不会允许你这样做的,运行的时候程序会报错,因为你企图修改进程虚拟地址空间中的只读段。
总结
在全局空间用const声明常量的时候,这个常量处于地址空间的常量段,是一个真正的常量
在栈内空间用const声明常量的时候,这个常量本质上和非常量没有区别,它处于堆栈中,存的值和常量相同,因为在赋值的时候执行了拷贝,但是在声明的同时,进程虚拟地址空间的常量段也会同时多出一个对应的常量,当我们使用这个栈上常量的时候,编译器会自动帮我们把它替换成常量段的常量。