作者:Rockics
來源:http://blog.csdn.net/rockics/article/details/7015067
說明:譯自Deep C (and C++) by Olve Maudal and Jon Jagger,本身半桶水不到,如果哪位網友發現有錯,留言指出吧:)
編程是困難的,正確的使用C/C++編程尤其困難。確實,不管是C還是C++,很難看到那種良好定義並且編寫規範的代碼。為什麼專業的程序員寫出這樣的代碼?因為絕大部分程序員都沒有深刻的理解他們所使用的語言。他們對語言的把握,有時他們知道某些東西未定義或未指定,但經常不知道為何如此。這個幻燈片,我們將研究一些小的C/C++代碼片段,使用這些代碼片段,我們將討論這些偉大而充滿危險的語言的基本原則,局限性,以及設計哲學。
假設你將要為你的公司招聘一名C程序言,你們公司是做嵌入式開發的,為此你要面試一些候選人。作為面試的一部分,你希望通過面試知道候選人對於C語言是否有足夠深入的認識,你可以這樣開始你們的談話:
- int main ()
- {
- int a= 42;
- printf(“%d\n”,a);
- }
當你嘗試去編譯鏈接運行這段代碼時候,會發生什麼?
一個候選者可能會這樣回答:
你必須通過#include<stdio.h>包含頭文件,在程序的後面加上return 0; 然後編譯鏈接,運行以後將在屏幕上打印42.
沒錯,這個答案非常正確。
但是另一個候選者也許會抓住機會,藉此展示他對C語言有更深入的認識,他會這樣回答:
你可能需要#include<stdio.h>,這個頭文件顯示地定義了函數printf(),這個程序經過編譯鏈接運行,會在標準輸出上輸出42,並且緊接著新的一行。
然後他進一步說明:
C++編譯器將會拒絕這段代碼,因為C++要求必須顯示定義所有的函數。然而,有一些特別的C編譯器會為printf()函數創建隱式定義,把這個文件編譯成目標文件。再跟標準庫鏈接的時候,它將尋找printf()函數的定義,以此來匹配隱式的定義。
因此,上面這段代碼也會正常編譯、鏈接然後運行,當然你可能會得到一些警告信息。
這位候選者乘勝追擊,可能還會往下說,如果是C99,返回值被定義為給運行環境指示是否運行成功,正如C++98一樣。但是對於老版本的C語言,比如說ANSI C以及K&R C,程序中的返回值將會是一些未定義的垃圾值。但是返回值通常會使用寄存器來傳遞,如果返回值的3,我一點都不感到驚訝,因為printf()函數的返回值是3,也就是輸出到標準輸出的字符個數。
說到C標準,如果你要表明你關心C語言,你應該使用intmain (void)作為你的程序入口,因為標準就這麼說的。
C語言中,使用void來指示函數聲明中不需要參數。如果這樣聲明函數int f(),那表明f()函數可以有任意多的參數,雖然你可能打算說明函數不需要參數,但這裡並非你意。如果你的意思是函數不需要參數,顯式的使用void,並沒有什麼壞處。
- int main ( void )
- {
- inta = 42;
- printf(“%d\n”,a);
- }
然後,有點炫耀的意思,這位候選人接著往下說:
如果你允許我有點點書生氣,那麼,這個程序也並不完全的符合C標準,因為C標準指出源代碼必須要以新的一行結束。像這樣:
- int main ()
- {
- inta = 42;
- printf(“%d\n”,a);
- }
同時別忘了顯式的聲明函數printf():
- #include <stdio.h>
- int main ( void )
- {
- inta = 42;
- printf(“%d\n”,a);
- }
現在看起來有點像C程序了,對嗎?
然後,在我的機器上編譯、鏈接並運行此程序:
- $ cc–std=c89 –c foo.c
- $ ccfoo.o
- $ ./a.out
- 42
- $ echo $?
- 3
- $ cc–std=c99 –c foo.c
- $ ccfoo.o
- $ ./a.out
- 42
- $ echo $?
- 0
這兩名候選者有什麼區別嗎?是的,沒有什麼特別大的區別,但是你明顯對第二個候選者的答案更滿意。
也許這並不是真的候選者,或許就是你的員工,呵呵。
讓你的員工深入理解他們所使用的語言,對你的公司會有很大幫助嗎?
讓我們看看他們對於C/C++理解的有多深……
- #include <stdio.h>
- void foo( void )
- {
- int a = 3;
- ++a;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- foo();
- foo();
- }
這兩位候選者都會是,輸出三個4.然後看這段程序:
- #include <stdio.h>
- void foo( void )
- {
- static int a = 3;
- ++a;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- foo();
- foo();
- }
他們會說出,輸出4,5,6.再看:
- #include <stdio.h>
- void foo( void )
- {
- static int a;
- ++a;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- foo();
- foo();
- }
第一個候選者發出疑問,a未定義,你會得到一些垃圾值?
你說:不,會輸出1,2,3.
候選者:為什麼?
你:因為靜態變量會被初始化未0.
第二個候選者會這樣來回答:
C標准說明,靜態變量會被初始化為0,所以會輸出1,2,3.
再看下面的代碼片段:
- #include <stdio.h>
- void foo( void )
- {
- int a;
- ++a;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- foo();
- foo();
- }
第一個候選者:你會得到1,1,1.
你:為什麼你會這樣想?
候選者:因為你說他會初始化為0.
你:但這不是靜態變量。
候選者:哦,那你會得到垃圾值。
第二個候選者登場了,他會這樣回答:
a的值沒有定義,理論上你會得到三個垃圾值。但是實踐中,因為自動變量一般都會在運行棧中分配,三次調用foo函數的時候,a有可能存在同一內存空間,因此你會得到三個連續的值,如果你沒有進行任何編譯優化的話。
你:在我的機器上,我確實得到了1,2,3.
候選者:這一點都不奇怪。如果你運行於debug模式,運行時機制會把你的棧空間全部初始化為0.
接下來的問題,為什麼靜態變量會被初始化為0,而自動變量卻不會被初始化?
第一個候選者顯然沒有考慮過這個問題。
第二個候選者這樣回答:
把自動變量初始化為0的代價,將會增加函數調用的代價。C語言非常注重運行速度。
然而,把全局變量區初始化為0,僅僅在程序啟動時候產生成本。這也許是這個問題的主要原因。
更精確的說,C++並不把靜態變量初始化為0,他們有自己的默認值,對於原生類型(native types)來說,這意味著0。
再來看一段代碼:
- #include<stdio.h>
- static int a;
- void foo( void )
- {
- ++a;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- foo();
- foo();
- }
第一個候選者:輸出1,2,3.
你:好,為什麼?
候選者:因為a是靜態變量,會被初始化為0.
你:我同意……
候選者:cool…
這段代碼呢:
- #include<stdio.h>
- int a;
- void foo( void )
- {
- ++a;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- foo();
- foo();
- }
第一個候選者:垃圾,垃圾,垃圾。
你:你為什麼這麼想?
候選者:難道它還會被初始化為0?
你:是的。
候選者:那他可能輸出1,2,3?
你:是的。你知道這段代碼跟前面那段代碼的區別嗎?有static那一段。
候選者:不太確定。等等,他們的區別在於私有變量(private variables)和公有變量(public variables).
你:恩,差不多。
第二個候選者:它將打印1,2,3.變量還是靜態分配,並且被初始化為0.和前面的區別:嗯。這和鏈接器(linker)有關。這裡的變量可以被其他的編譯單元訪問,也就是說,鏈接器可以讓其他的目標文件訪問這個變量。但是如果加了static,那麼這個變量就變成該編譯單元的局部變量了,其他編譯單元不可以通過鏈接器訪問到該變量。
你:不錯。接下來,將展示一些很不錯的玩意。靜候:)
好,接著深入理解C/C++之旅。我在翻譯第一篇的時候,自己是學到不不少東西,因此打算將這整個ppt翻譯完畢。
請看下面的代碼片段:
- #include <stdio.h>
- void foo( void )
- {
- int a;
- printf( "%d\n" , a);
- }
- void bar( void )
- {
- int a = 42;
- }
- int main( void )
- {
- bar();
- foo();
- }
編譯運行,期待輸出什麼呢?
- $ cc foo.c && ./a.out
- 42
你可以解釋一下,為什麼這樣嗎?
第一個候選者:嗯?也許編譯器為了重用有一個變量名稱池。比如說,在bar函數中,使用並且釋放了變量a,當foo函數需要一個整型變量a的時候,它將得到和bar函數中的a的同一內存區域。如果你在bar函數中重新命名變量a,我不覺得你會得到42的輸出。
你:恩。確定。。。
第二個候選者:不錯,我喜歡。你是不是希望我解釋一下關於執行堆棧或是活動幀 (activation frames,操作代碼在內存中的存放形式,譬如在某些系統上,一個函數在內存中以這種形式存在:
ESP
形式參數
局部變量
EIP
)?
你:我想你已經證明了你理解這個問題的關鍵所在。但是,如果我們編譯的時候,採用優化參數,或是使用別的編譯器來編譯,你覺得會發生什麼?
候選者:如果編譯優化措施參與進來,很多事情可能會發生,比如說,bar函數可能會被忽略,因為它沒有產生任何作用。同時,如果foo函數會被inline,這樣就沒有函數調用了,那我也不感到奇怪。但是由於foo函數必須對編譯器可見,所以foo函數的目標文件會被創建,以便其他的目標文件鏈接階段需要鏈接foo函數。總之,如果我使用編譯優化的話,應該會得到其他不同的值。
- $ cc -O foo.c && ./a.out
- 1606415608
候選者:垃圾值。
那麼,請問,這段代碼會輸出什麼?
- #include <stdio.h>
- void foo( void )
- {
- int a = 41;
- a= a++;
- printf( "%d\n" , a);
- }
- int main( void )
- {
- foo();
- }
第一個候選者:我沒這樣寫過代碼。
你:不錯,好習慣。
候選者:但是我猜測答案是42.
你:為什麼?
候選者:因為沒有別的可能了。
你:確實,在我的機器上運行,確實得到了42.
候選者:對吧,嘿嘿。
你:但是這段代碼,事實上屬於未定義。
候選者:對,我告訴過你,我沒這樣寫過代碼。
第二個候選者登場:a會得到一個未定義的值。
你:我沒有得到任何的警告信息,並且我得到了42.
候選者:那麼你需要提高你的警告級別。在經過賦值和自增以後,a的值確實未定義,因為你違反了C/C++語言的根本原則中的一條,這條規則主要針對執行順序(sequencing)的。C/C++規定,在一個序列操作中,對每一個變量,你僅僅可以更新一次。這裡,a = a++;更新了兩次,這樣操作會導致a是一個未定義的值。
你:你的意思是,我會得到一個任意值?但是我確實得到了42.
候選者:確實,a可以是42,41,43,0,1099,或是任意值。你的機器得到42,我一點都不感到奇怪,這裡還可以得到什麼?或是編譯前選擇42作為一個未定義的值:)呵呵:)
那麼,下面這段代碼呢?
- #include <stdio.h>
- int b( void )
- {
- puts( "3" );
- return 3;
- }
- int c( void )
- {
- puts( "4" );
- return 4;
- }
- int main( void )
- {
- int a = b() + c();
- printf( "%d\n" , a);
- }
第一個候選者:簡單,會依次打印3,4,7.
你:確實。但是也有可能是4,3,7.
候選者:啊?運算次序也是未定義?
你:準確的說,這不是未定義,而是未指定。
候選者:不管怎樣,討厭的編譯器。我覺得他應該給我們警告信息。
你心裡默念:警告什麼?
第二個候選者:在C/C++中,運算次序是未指定的,對於具體的平台,由於優化的需要,編譯器可以決定運算順序,這又和執行順序有關。
這段代碼是符合C標準的。這段代碼或是輸出3,4,7或是輸出4,3,7,這個取決於編譯器。
你心裡默念:要是我的大部分同事都像你這樣理解他們所使用的語言,生活會多麼美好:)
這個時候,我們會覺得第二個候選者對於C語言的理解,明顯深刻於第一個候選者。如果你回答以上問題,你停留在什麼階段?:)
那麼,試著看看第二個候選者的潛能?看看他到底有多了解C/C++
可以考察一下相關的知識:
聲明和定義;
調用約定和活動幀;
序點;
內存模型;
優化;
不同C標準之間的區別;
這裡,我們先分享序點以及不同C標準之間的區別相關的知識。
考慮以下這段代碼,將會得到什麼輸出?
- 1.
- int a = 41;
- a++;
- printf( "%d\n" , a);
- 答案:42
- 2.
- int a = 41;
- a++ & printf( "%d\n" , a);
- 答案:未定義
- 3.
- int a = 41;
- a++ && printf( "%d\n" , a);
- 答案:42
- 4. int a = 41;
- if (a++ < 42) printf( "%d\n" ,a);
- 答案:42
- 5.
- int a = 41;
- a = a++;
- printf( "%d\n" , a);
- 答案:未定義
到底什麼時候,C/C++語言會有副作用?
序點:
什麼是序點?
簡而言之,序點就是這麼一個位置,在它之前所有的副作用已經發生,在它之後的所有副作用仍未開始,而兩個序點之間所有的表達式或者代碼執行的順序是未定義的!
序點規則1:
在前一個序點和後一個序點之前,也就是兩個序點之間,一個值最多只能被寫一次;
這裡,在兩個序點之間,a被寫了兩次,因此,這種行為屬於未定義。
序點規則2:
進一步說,先前的值應該是只讀的,以便決定要存儲什麼值。
很多開發者會覺得C語言有很多序點,事實上,C語言的序點非常少。這會給編譯器更大的優化空間。
接下來看看,各種C標準之間的差別:
現在讓我們回到開始那兩位候選者。
下面這段代碼,會輸出什麼?
- #include <stdio.h>
- struct X
- {
- int a;
- char b;
- int c;
- };
- int main( void )
- {
- printf( "%d\n" , sizeof ( int ));
- printf( "%d\n" , sizeof ( char ));
- printf( "%d\n" , sizeof ( struct X));
- }
第一個候選者:它將打印出4,1,12.
你:確實,在我的機器上得到了這個結果。
候選者:當然。因為sizeof返回字節數,在32位機器上,C語言的int類型是32位,或是4個字節。char類型是一個字節長度。在struct中,本例會以4字節來對齊。
你:好。
你心裡默念:do you want another ice cream?(不知道有什麼特別情緒)
第二個候選者:恩。首先,先完善一下代碼。sizeof的返回值類型是site_t,並不總是與int類型一樣。因此,printf中的輸出格式%d,不是一個很好的說明符。
你:好。那麼,應該使用什麼格式說明符?
候選者:這有點複雜。site_t是一個無符號整型數,在32位機器上,它通常是一個無符號的int類型的數,但是在64位機器上,它通常是一個無符號的long類型的數。然而,在C99中,針對site_t類型,指定了一個新的說明符,所以,%zu會是一個不多的選擇。
你:好。那我們先完善這個說明符的bug。你接著回答這個問題吧。
- #include <stdio.h>
- struct X
- {
- int a;
- char b;
- int c;
- };
- int main( void )
- {
- printf( "%zu\n" , sizeof ( int ));
- printf( "%zu\n" , sizeof ( char ));
- printf( "%zu\n" , sizeof ( struct X));
- }
候選者:這取決與平台,以及編譯時的選項。唯一可以確定的是,sizeof(char)是1.你要假設在64位機器上運行嗎?
你:是的。我有一台64位的機器,運行在32位兼容模式下。
候選者:那麼由於字節對齊的原因,我覺得答案應該是4,1,12.當然,這也取決於你的編譯選項參數,它可能是4,1,9.如果你在使用gcc編譯的時候,加上-fpack-struct,來明確要求編譯器壓縮struct的話。
你:在我的機器上確實得到了4,1,12。為什麼是12呢?
候選者:工作在字節不對齊的情況下,代價非常昂貴。因此編譯器會優化數據的存放,使得每一個數據域都以字邊界開始存放。struct的存放也會考慮字節對齊的情況。
你:為什麼工作在字節不對齊的情況下,代價會很昂貴?
候選者:大多數處理器的指令集都在從內存到cpu拷貝一個字長的數據方面做了優化。如果你需要改變一個橫跨字邊界的值,你需要讀取兩個字,屏蔽掉其他值,然後改變再寫回。可能慢了10不止。記住,C語言很注意運行速度。
你:如果我得struct上加一個char d,會怎麼樣?
候選者:如果你把char d加在struct的後面,我預計sizeof(struct X)會是16.因為,如果你得到一個長度為13字節的結構體,貌似不是一個很有效的長度。但是,如果你把char d加在char b的後面,那麼12會是一個更為合理的答案。
你:為什麼編譯器不重排結構體中的數據順序,以便更好的優化內存使用和運行速度?
候選者:確實有一些語言這樣做了,但是C/C++沒有這樣做。
你:如果我在結構體的後面加上char *d,會怎麼樣?
候選者:你剛才說你的運行時環境是64位,因此一個指針的長度的8個字節。也許struct的長度是20?但是另一種可能是,64位的指針需要在在效率上對齊,因此,代碼可能會輸出4,1,24?
你:不錯。我不關心在我的機器上會得到什麼結果,但是我喜歡你的觀點以及洞察力J
(未完待續)
第二位候選者表現不錯,那麼,相比大多數程序員,他還有什麼潛力沒有被挖掘呢?
可以從以下幾個角度去考察:
有關平台的問題—32位與64位的編程經驗;
內存對齊;
CPU以及內存優化;
C語言的精髓;
接下來,主要分享一下以下相關內容:
內存模型;
優化;
C語言之精髓;
內存模型:
靜態存儲區(static storage):如果一個對象的標識符被聲明為具有內部鏈接或是外部鏈接,或是存儲類型說明符是static,那麼這個對象具有靜態生存期。這個對象的生命週期是整個程序的運行週期。
PS:內部鏈接,也就是編譯單元內可見,是需要使用static來修飾的,連接程序不可見;外部鏈接,是指別的編譯單元可見,也就是鏈接程序可見。我這裡還不太清楚為什麼需要三種情況來說明。
- int * immortal( void )
- {
- static int storage = 42;
- return &storage;
- }
自動存儲區(automatic storage):如果一個對像沒有被指明是內部鏈接還是外部鏈接,並且也沒有static修飾,那麼,這個對象具有自動生存期,也稱之為本地生存期。一般使用auto說明符來修飾,只在塊內的變量聲明中允許使用,這樣是默認的情況,因此,很少看到auto說明符。簡單地說,自動存儲區的變量,在一對{}之間有效。
- int * zombie( void )
- {
- auto int storage = 42;
- return &storage;
- }
分配的存儲區域(allocated storage):調用calloc函數,malloc函數,realloc函數分配的內存,稱之為分配的存儲區域。他們的作用域(生命週期會是更好的術語嗎?)在分配和釋放之間。
- int * finite( void )
- {
- int * ptr = malloc( sizeof ( int *));
- *ptr = 42;
- return ptr;
- }
優化相關:
一般來說,編譯的時候,你都應該打開優化選項。強制編譯器更努力的去發現更多的潛在的問題。
上面,同樣地代碼,打開優化選項的編譯器得到了警告信息:a 沒有初始化。
C語言的精髓:
C語言的精髓體現在很多方面,但其本質在於一種社區情感(communitysentiment),這種社區情感建立在C語言的基本原則之上。
C語言原理簡介:
1、 相信程序員;
2、 保持語言簡單精煉;
3、 對每一種操作,僅提供一種方法;(譯者註:?)
4、 盡可能的快,但不保證兼容性;
5、 保持概念上的簡單;
6、 不阻止程序員做他們需要做的事。
現在來考察一下我們的候選者關於C++的知識:)
你:1到10分,你覺得你對C++的理解可以打幾分?
第一個候選者:我覺得我可以打8到9分。
第二個候選者:4分,最多也就5分了。我還需要多加學習C++。
這時,C++之父Bjarne Stroustrup在遠方傳來聲音:我覺得我可以打7分。(OH,MY GOD!!)
那麼,下面的代碼段,會輸出什麼?
- #include <iostream>
- struct X
- {
- int a;
- char b;
- int c;
- };
- int main( void )
- {
- std::cout << sizeof (X) << std::endl;
- }
第二個候選者:這個結構體是一個樸素的結構體(POD:plain old data),C++標准保證在使用POD的時候,和C語言沒有任何區別。因此,在你的機器上(64位機器,運行在32位兼容模式下),我覺得會輸出12.
順便說一下,使用func(void)而不是用func()顯得有點詭異,因為C++中,void是默認情況,這個相對於C語言的默認是任意多的參數,是不一樣的。這個規則同樣適用於main函數。當然,這不會帶來什麼傷害。但這樣的代碼,看起來就像是頑固的C程序員在痛苦的學習C++的時候所寫的。下面的代碼,看起來更像C++:
- #include <iostream>
- struct X
- {
- int a;
- char b;
- int c;
- };
- int main()
- {
- std::cout << sizeof (X) << std::endl;
- }
第一個候選者:這個程序會打印12.
你:好。如果我添加一個成員函數,會怎麼樣?比如:
- #include <iostream>
- struct X
- {
- int a;
- char b;
- int c;
- void set_value( int v) { a = v; }
- };
- int main()
- {
- std::cout << sizeof (X) << std::endl;
- }
第一個候選者:啊?C++中可以這樣做嗎?我覺得你應該使用類(class)。
你:C++中,class和struct有什麼區別?
候選者:在一個class中,你可以有成員函數,但是我不認為在struct中可以擁有成員函數。莫非可以?難道是默認的訪問權限不同?(Is it the default visibility that is different?)
不管怎樣,現在程序會輸出16.因為,會有一個指針指向這個成員函數。
你:真的?如果我多增加兩個函數呢?比如:
- #include <iostream>
- struct X
- {
- int a;
- char b;
- int c;
- void set_value( int v) { a = v; }
- int get_value() { return a; }
- void increase_value() { a++; }
- };
- int main()
- {
- std::cout << sizeof (X) << std::endl;
- }
第一個候選者:我覺得對打印24,多了兩個指針?
你:在我的機器上,打印的值比24小。
候選者:啊!對了,當然,這個struct有一個函數指針的表,因此他僅僅需要一個指向這個表的指針!我確實對此有一個很深的理解,我差點忘記了,呵呵。
你:事實上,在我的機器上,這段代碼輸出了12.
候選者心裡犯嘀咕:哦?可能是某些詭異的優化措施在搗鬼,可能是因為這些函數永遠不會被調用。
你對第二個候選者說:你怎麼想的?
第二個候選者:在你的機器上?我覺得還是12?
你:好,為什麼?
候選者:因為以這種方式來增加成員函數,不會增加struct的所佔內存的大小。對像對他的函數一無所知,反過來,是函數知道他具體屬於哪一個對象。如果你把這寫成C語言的形式,就會變得明朗起來了。
你:你是指這樣的?
- struct X
- {
- int a;
- char b;
- int c;
- };
- void set_value( struct X* this , int v) { this ->a = v; }
- int get_value( struct X* this ) { return this ->a; }
- void increase_value( struct X* this ) { this ->a++; }
第二個候選者:恩。就想這樣的。現在很明顯很看出,類似這樣的函數是不會增加類型和對象的內存大小的。
你:那麼現在呢?
- #include <iostream>
- struct X
- {
- int a;
- char b;
- int c;
- virtual void set_value( int v) { a = v; }
- int get_value() { return a; }
- void increase_value() { a++; }
- };
- int main()
- {
- std::cout << sizeof (X) << std::endl;
- }
//注意改變:第一個成員函數變成了虛函數。
第二個候選者:類型所佔用的內存大小很有可能會增加。C++標準沒有詳細說明虛類(virtual class)和重載(overriding)具體如何實現。但是一般都是維護一個虛函數表,因此你需要一個指針指向這個虛函數表。所以,這種情況下會增加8字節。這個程序是輸出20嗎?
你:我運行這段程序的時候,得到了24.
候選者:別擔心。極有可能是某些額外的填充,以便對齊指針類型(之前說的內存對齊問題)。
你:不錯。再改一下代碼。
- #include <iostream>
- struct X
- {
- int a;
- char b;
- int c;
- virtual void set_value( int v) { a = v; }
- virtual int get_value() { return a; }
- virtual void increase_value() { a++; }
- };
- int main()
- {
- std::cout << sizeof (X) << std::endl;
- }
現在會發生什麼?
第二個候選者:依舊打印24.每一個類,只有一個虛函數表指針的。
你:恩。什麼是虛函數表?
候選者:在C++中,一般使用虛函數表技術來支持多態性。它基本上就是函數調用的跳轉表(jump table),依靠虛函數表,在繼承體系中,你可以實現函數的重載。
讓我們來看看另一段代碼:
- #include "B.hpp"
- class A {
- public :
- A( int sz) { sz_ = sz; v = new B[sz_]; }
- ~A() { delete v; }
- //...
- private :
- //...
- B* v;
- int sz_;
- };
看看這段代碼。假設我是一名資深的C++程序員,現在要加入你的團隊。我向你提交了這麼個代碼段。請從學術的層面,盡可能詳細輕柔的給我講解這段代碼可能存在的陷阱,盡可能的跟我說說一些C++的處理事情的方式。
第一個候選者:這是一段比較差的代碼。這是你的代碼?首先,不要使用兩個空格來表示縮進。還有class A後面的大括號要另起一行。sz_?我從來沒見過如此命名的。你應該參照GoF標準_sz或且微軟標準m_sz來命名。(GoF標準?)
你:還有呢?
候選者:恩?你是不是覺得在釋放一個數組對象的時候,應該使用delete []來取代delete?說真的,我的經驗告訴我,沒必要。現代的編譯器可以很好的處理這個事情。
你:好?有考慮過C++的“rule of three“原則嗎?你需要支持或是不允許複製這一類對象嗎?
PS:
(來自維奇百科http://en.wikipedia.org/wiki/Rule_of_three_(C%2B%2B_programming))
The rule of three (also known asthe Law of The Big Three or The Big Three) is a rule of thumb in C++ that claimsthat if a class defines one of the following itshould probably explicitly define all three:
§ destructor
§ copy constructor
§ assignment operator
也就是說,在C++中,如果需要顯式定義析構函數、拷貝構造函數、賦值操作符中的一個,那麼通常也會需要顯式定義餘下的兩個。
第一個候選者:恩。無所謂了。聽都沒聽說過tree-rule。當然,如果用戶要拷貝這一類對象的話,會出現問題。但是,這也許就是C++的本質,給程序員無窮盡的噩夢。
順便說一下,我想你應該知道哎C++中所有的析構函數都應該定義為virtual函數。我在一些書上看到過這個原則,這主要是為了防止在析構子類對象時候出現內存洩露。
你心裡嘀咕:或是類似的玩意。Another ice cream perhaps?(我還是沒搞明白這到底哪門情感)
令人愉悅的第二個候選者登場了:)
候選者:哦,我該從何說起呢?先關註一些比較重要的東西吧。
首先是析構函數。如果你使用了操作符new[],那麼你就應該使用操作符delete[]進行析構。使用操作符delete[]的話,在數組中的每一個對象的析構函數被調用以後,所佔用的內存會被釋放。例如,如果像上面的代碼那樣寫的話,B類的構造函數會被執行sz次,但是析構函數僅僅被調用1次。這個時候,如果B類的構造函數動態分配了內存,那麼就是造成內存洩漏。
接下類,會談到“rule of three”。如果你需要析構函數,那麼你可能要么實現要么顯式禁止拷貝構造函數和賦值操作符。由編譯器生成的這兩者中任何一個,很大可能不能正常工作。
還有一個小問題,但是也很重要。通常使用成員初始化列表來初始化一個對象。在上面的例子中,還體現不出來這樣做的重要性。但是當成員對像比較複雜的時候,相比讓對象隱式地使用默認值來初始化成員,然後在進行賦值操作來說,使用初始化列表顯式初始化成員更為合理。
先把代碼修改一下:)然後再進一步闡述問題。
你改善了一下代碼,如下:
- #include "B.hpp"
- class A
- {
- public :
- A( int sz) { sz_ = sz; v = new B[sz_]; }
- ~A() { delete [] v; }
- //...
- private :
- A( const A&);
- A& operator=( const A&);
- //...
- B* v;
- int sz_;
- };
這個時候,這位候選者(第二個)說:好多了。
你進一步改進,如下:
- #include "B.hpp"
- class A
- {
- public :
- A( int sz) { sz_ = sz; v = new B[sz_]; }
- virtual ~A() { delete [] v; }
- //...
- private :
- A( const A&);
- A& operator=( const A&);
- //...
- B* v;
- int sz_;
- };
第二位候選者忙說道:別著急,耐心點。
接著他說:在這樣的一個類中,定義一個virtual的析構函數,有什麼意義?這裡沒有虛函數,因此,如果以此作為基類,派生出一個類,有點不可理喻。我知道是有一些程序員把非虛類作為基類來設計繼承體系,但是我真的覺得他們誤解了面向對象技術的一個關鍵點。我建議你析構函數的virtual說明符去掉。virtual這個關鍵字,用在析構函數上的時候,他有這麼個作用:指示這個class是否被設計成一個基類。存在virtual,那麼表明這個class應該作為一個基類,那麼這個class應該是一個virtual class。
還是改一下初始化列表的問題吧:)
於是代碼被你修改為如下:
- #include "B.hpp"
- class A
- {
- public :
- A( int sz):sz_(sz), v( new B[sz_]) { }
- ~A() { delete [] v; }
- //...
- private :
- A( const A&);
- A& operator=( const A&);
- //...
- B* v;
- int sz_;
- };
第二個候選者說:恩,有了初始化列表。但是,你有沒有註意到由此有產生了新的問題?
你編譯的時候使用了-Wall選項嗎?你應該使用-Wextra、-pedantic還有-Weffc++選項。如果沒有警告出現,你可能沒有註意到這裡發生的錯誤。但是如果你提高了警告級別,你會發現問題不少。
一個不錯的經驗法則是:總是按照成員被定義的順序來書寫初始化列表,也就是說,成員按照自己被定義的順序來唄初始化。在這個例子中,當v(new B[sz_])執行的時候,sz_還沒有被定義。然後,sz_被初始化為sz。
事實上,C++代碼中,類似的事情太常見了。
你於是把代碼修改為:
- #include "B.hpp"
- class A
- {
- public :
- A( int sz):v( new B[sz]), sz_(sz) { }
- ~A() { delete [] v; }
- //...
- private :
- A( const A&);
- A& operator=( const A&);
- //...
- B* v;
- int sz_;
- };
第二個候選者:現在好多了。還有什麼需要改進的嗎?接下來我會提到一些小問題。。。
在C++代碼中,看到一個光禿禿的指針,不是一個好的跡象。很多好的C++程序員都會盡可能的避免這樣使用指針。當然,例子中的v看起來有點像STL中的vector,或且差不多類似於此的東西。
對於你的私有變量,你貌似使用了一些不同的命名約定。在此,我的看法是,只要這些變量是私有的,你愛怎麼命名就怎麼命名。你可以使得你的變量全部以_作為後綴,或且遵循微軟命名規範,m_作為前綴。但是,請你不要使用_作為前綴來命名你的變量,以免和C語言保留的命名規範、Posix以及編譯器的命名規則相混淆:)
(未完待續)
總結一下第三講,我們可以知道,相對於第一位候選者,第二位候選者在以下幾個方面有更深的認識:
1、 C與C++的聯繫;
2、 多態方面的技術;
3、 如何正確的初始化一個對象;
4、 Rule of three;
5、 操作符new[]與操作符delete[]方面的知識;
6、 常用的命名約定。
接下來,我們將分享一下幾個方面的知識:
1、 對象的生命週期;
2、 Rule of three;
3、 虛函數表。
先來看,恰當地進行對像初始化。賦值與初始化是不一樣的。來看這段代碼的輸出:
- struct A
- {
- A() { puts( "A()" ); }
- A( int v) { puts( "A(int)" ); }
- ~A() { puts( "~A()" ); }
- };
- struct X
- {
- X( int v) { a = v; }
- X( long v):a(v) { }
- A a;
- };
- int main()
- {
- puts( "bad style" );
- {
- X slow( int (2));
- }
- puts( "good style" );
- {
- X fast( long (2));
- }
- }
代碼輸出為:
- bad style
- A()
- A( int )
- ~A()
- ~A()
- good style
- A( int )
- ~A()
再看看對象的生命週期:
C++的一個基本原理是:對象消亡時候需要採取的操作,正好是對象創建時候所採取操作的逆操作。
看下面的代碼:
- struct A
- {
- A() { puts( "A()" ); }
- ~A() { puts( "~A()" ); }
- };
- struct B
- {
- B() { puts( "B()" ); }
- ~B() { puts( "~B()" ); }
- };
- struct C
- {
- A a;
- B b;
- };
- int main()
- {
- C obj;
- }
程序的輸出是:
- A()
- B()
- ~B()
- ~A()
再看:
- struct A
- {
- A():id(count++)
- {
- printf( "A(%d)\n" , id);
- }
- ~A()
- {
- printf( "~A(%d)\n" , id);
- }
- int id;
- static int count;
- };
- //原文是沒有這句的,不過根據C++規範,static數據成員必須在類定義體外定義。
- //謝謝yuxq100指出。
- int A::count = 0;
- int main()
- {
- A array[4];
- }
程序輸出:
- A(0)
- A(1)
- A(2)
- A(3)
- ~A(3)
- ~A(2)
- ~A(1)
- ~A(0)
仔細看著張圖,也會有所收穫:
接下來看看:the rule of three:
If a class defines a copy constructor, acopy assignment operator, or a destructor, then it should define all three.
如果一個類定義了拷貝構造函數、賦值操作符、析構函數中的一個,那麼通常需要全部定義這仨函數。
如圖示:
接下類看看虛函數表:
看一下這段代碼,虛函數表的結構大概如何呢?
- struct base
- {
- virtual void f();
- virtual void g();
- int a,b;
- };
- struct derived:base
- {
- virtual void g();
- virtual void h();
- int c;
- };
- void poly(base* ptr)
- {
- ptr->f();
- ptr->g();
- }
- int main()
- {
- poly(&base());
- poly(&derived());
- }
虛函數表結構如何呢?看圖:
簡單說明:派生類沒有重載f函數,它繼承了基類的f函數,因此,派生類的虛函數表的f函數指向基類的f函數。但是,因為派生類重載了g函數,因此,其虛函數表中的g指向自身的g函數。
那麼這段代碼呢?
- struct base
- {
- void f();
- virtual void g();
- int a,b;
- };
- struct derived:base
- {
- virtual void g();
- virtual void h();
- int c;
- };
- void poly(base* ptr)
- {
- ptr->f();
- ptr->g();
- }
- int main()
- {
- poly(&base());
- poly(&derived());
- }
基類的f函數不是虛函數了,這個時候的虛函數表結構又如何呢?
越多的同事對他們所使用的語言有深入的認識,這對你有什麼好處嗎?我們不建議(也不實際)要求公司裡所有的C/C++程序員都深入理解C/C++。但是你確實需要絕大部分的程序員真的在意他們的專業度,他們需要求知若渴,不斷努力,爭取不斷的加深對語言本身的理解。正所謂:stay hungry,stay foolish:)
現在回過頭了看著這兩名開發者,也就是我們之前所一直說的候選者。
親,你覺得這兩名開發者之間最大的差別在哪?
關於語言的現有知識嗎?不是!!
是他們對於學習的態度!!
你最後一次上編程方面的課程是什麼時候?
第一個候選者這樣回答:你什麼意思?我在大學裡學習編程,現在我通過實踐來學習。你想知道什麼?
你:那麼,你現在在閱讀哪些書?
候選者:書?哦,我不需要書。在我需要的時候,我會在網上查詢手冊。
你:你會跟你的同事談論編程方面的東西嗎?
候選者:我覺得沒有必要!!我比他們強多了,從他們身上學不到任何玩意!!
你貌似對C/C++了解的更多,怎麼做到的?
第二個候選者:我每天都會學習一些新東西,我真的樂在其中:)
我偶爾也會在stackoverflow.com、comp.lang.c還有comp.lang.c++跟進一些討論。
我還參加了一個當地的C/C++用戶組,我們定期會舉行一些討論會,交流心得。
我看了很多的書,很多很多。你知道嗎?James Grenning剛剛寫了一本很不錯的書:《Test-Driven Development in C》,很值得一看:)
[PS:貌似是:Test-DrivenDevelopment for Embedded C]
我偶爾會被允許拜訪WG14W以及G21。
[PS:
ISO WG14:ISO C委員會,具體指JTC1/SC22/WG14 C語言技術工作小組,通常簡寫為WG14。ISO WG21:ISO C++委員會,具體指JTC1/SC22/WG21 C++技術工作小組,通常簡寫成WG21。
此人很牛逼呀:)]
我還是ACCU的會員,這裡的人對於編程都有專業精神。我訂閱了Overload,CVu及accu的一些綜述文章。
[PS:移步看看ACCU的網站,確實應該去看看:
ACCU is an organisation of programmers whocare about professionalism in programming and are dedicated to raising thestandard of programming.
]
候選者接著說:無論何時只要有有機會,我都會參加C/C++課程,倒不是因為跟老師能學到什麼東西,而是因為通過和其他同學的討論,能擴展我的知識面。
但也許最好的知識來源於密切地配合我的同事們工作,與他們交流,分享自己所知的同時,從他們身上學到更多的知識。
(我表示從第二個候選者那學到了很多東西:)
最後,概述:
l 編譯器和鏈接器(連接程序)
l 聲明和定義
l 活動幀
l 內存段
l 內存對齊
l 序點
l 求值順序
l 未定義和未指定
l 優化
l 關於C++的一些玩意
l 對象的恰當初始化
l 對象的生命週期
l 虛函數表
l 以及一些關於專業精神和學習態度的話題
這個時候第一個候選者貌似有所領悟:
第一個候選者:啊?
你:有什麼問題嗎?
候選者:我真的熱愛編程,但是我現在認識到我真的還遠遠說不上專業。對於如何更好的學習C/C++,您能給我一些建議嗎?
你:首先,你必須認識到編程是一件持續學習的的過程。不管你掌握了多少,總有很多知識需要你去學習。其次,你還必須認識到,專業編程最重要的一點是,你必須和你的同事親密合作。想想體育比賽中,沒有人可以做到單憑個人就能贏得比賽。
候選者:好的,我需要好好反省。。。
你:但是話說回來,養成這麼個習慣,偶爾去關註一下代碼所生成的彙編語句。你會發現很多有意思的東西。使用debugger,一步步的跟踪你的代碼,看看內存的使用情況,同時看看處理器到底在執行什麼指令。
候選者:有什麼關於C/C++的書、網站、課程或是會議值得推薦嗎?
你:要學習更多的現代軟件的開發方式,我推薦James Grenning寫的Test-Driven Development for Embedded C(貌似還沒有中文版)。想要更深入的學習C語言,可以參考Peter van/Der Linden的Expert C Programming(C專家編程),這本書雖然成作已經20多年了,但是書上的觀點依然管用。對於C++,我推薦你從Scott Meyers的Effective C++(國內侯捷老師翻譯了此書)以及Herb Sutter和Andrei Alexandrescu的C++ coding standards(C++編程規範)。
此外,如果你有機會參加任何於此有關的課程,不要猶豫,參加!只要態度正確,你就可以從老師和其他學生那裡學到很多東西。
最後,我建議加入一些C/C++的用戶組織,投身於社區當中。具體來說,我非常推薦ACCU,他們很專注於C/C++編程。你知道嗎?他們每年的春季都會在牛津大學舉行為期一周的與此相關的會議,與會者是來自全世界專業程序員:)或許明年4月份我會在那遇見你?
候選者:謝謝:)
你:祝你好運:)
全文完。
其他參考: