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這個關鍵字。具體是怎么回事?下一篇博客再說。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM