深入理解C/C++ [Deep C (and C++)]

作者: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)
{
    int a = 42;
    printf("%d\n", a);
}

然後,有點炫耀的意思,這位候選人接着往下說:

         如果你允許我有點點書生氣,那麼,這個程序也並不完全的符合C標準,因爲C標準指出源代碼必須要以新的一行結束。像這樣:


int main()
{
    int a = 42;
    printf("%d\n", a);
}

同時別忘了顯式的聲明函數printf():


#include <stdio.h>

int main(void)
{
    int a = 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月份我會在那遇見你?

 

候選者:謝謝:)

 

你:祝你好運:)

 

全文完。



其他參考:

英文原文:http://www.slideshare.net/olvemaudal/deep-c


發佈了40 篇原創文章 · 獲贊 4 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章