解析C語言與C++的編譯模型
首先簡要介紹一下C的編譯模型:
限于當(dāng)時的硬件條件,C編譯器不能夠在內(nèi)存里一次性地裝載所有程序代碼,而需要將代碼分為多個源文件,并且分別編譯。并且由于內(nèi)存限制,編譯器本身也不能太大,因此需要分為多個可執(zhí)行文件,進行分階段的編譯。在早期一共包括7個可執(zhí)行文件:cc(調(diào)用其它可執(zhí)行文件),cpp(預(yù)處理器),c0(生成中間文件),c1(生成匯編文件),c2(優(yōu)化,可選),as(匯編器,生成目標(biāo)文件),ld(鏈接器)。
1. 隱式函數(shù)聲明
為了在減少內(nèi)存使用的情況下實現(xiàn)分離編譯,C語言還支持”隱式函數(shù)聲明”,即代碼在使用前文未定義的函數(shù)時,編譯器不會檢查函數(shù)原型,編譯器假定該函數(shù)存在并且被正確調(diào)用,還假定該函數(shù)返回int,并且為該函數(shù)生成匯編代碼。此時唯一不確定的,只是該函數(shù)的函數(shù)地址。這由鏈接器來完成。如:
int main() { printf("ok\n"); return 0; }
在gcc上會給出隱式函數(shù)聲明的警告,但能編譯運行通過。因為在鏈接時,鏈接器在libc中找到了printf符號的定義,并將其地址填到編譯階段留下的空白中。PS:用g++編譯則會生成錯誤:use of undeclared identifier 'printf'。而如果使用的是未經(jīng)定義的函數(shù),如上面的printf函數(shù)改為print,得到的將是鏈接錯誤,而不是編譯錯誤。
2. 頭文件
有了隱式函數(shù)聲明,編譯器在編譯時應(yīng)該就不需要頭文件了,編譯器可以按函數(shù)調(diào)用時的代碼生成匯編代碼,并且假定函數(shù)返回int。而C頭文件的最初目的是用于方便文件之間共享數(shù)據(jù)結(jié)構(gòu)定義,外部變量,常量宏。早期的頭文件里,也只包含這三樣?xùn)|西。注意,沒有提到函數(shù)聲明。
而如今在引入將函數(shù)聲明放入頭文件這一做法后,帶來了哪些便利和缺陷:
優(yōu)點:
項目不同的文件之間共享接口。
頭文件為第三方庫提供了接口說明。
缺點:
效率性:為了使用一個簡單的庫函數(shù),編譯器可能要parse成千上萬行預(yù)處理之后的頭文件源碼。
傳遞性:頭文件具有傳遞性。在頭文件傳遞鏈中任一頭文件變動,都將導(dǎo)致包含該頭文件的所有源文件重新編譯。哪怕改動無關(guān)緊要(沒有源文件使用被改動的接口)。
差異性:頭文件在編譯時使用,動態(tài)庫在運行時使用,二者有可能因為版本不一致造成二進制兼容問題。
一致性:頭文件函數(shù)聲明和源文件函數(shù)實現(xiàn)的參數(shù)名無需一致。這將可能導(dǎo)致函數(shù)聲明的意思,和函數(shù)具體實現(xiàn)不一致。如聲明為 void draw(int height, int width) 實現(xiàn)為 void draw(int width, int height)。
3. 單遍編譯( One Pass )
由于當(dāng)時的編譯器并不能將整個源文件的語法樹保存在內(nèi)存中,因此編譯器實際上是”單遍編譯”。即編譯器從頭到尾地編譯源文件,一邊解析,一邊即刻生成目標(biāo)代碼,在單遍編譯時,編譯器只能看到已經(jīng)解析過的部分。 意味著:
C語言結(jié)構(gòu)體需要先定義,才能訪問。因為編譯器需要知道結(jié)構(gòu)體定義,才知道結(jié)構(gòu)體成員類型和偏移量,并生成目標(biāo)代碼。
局部變量必須先定義,再使用。編譯器需要知道局部變量的類型和在棧中的位置。
外部變量(全局變量),編譯器只需要知道它的類型和名字,不需要知道它的地址,就能生成目標(biāo)代碼。而外部變量的地址將留給連接器去填。
對于函數(shù),根據(jù)隱式函數(shù)聲明,編譯器可以立即生成目標(biāo)代碼,并假定函數(shù)返回int,留下空白函數(shù)地址交給連接器去填。
C語言早期的頭文件就是用來提供結(jié)構(gòu)體定義和外部變量聲明的,而外部符號(函數(shù)或外部變量)的決議則交給鏈接器去做。
單遍編譯結(jié)合隱式函數(shù)聲明,將引出一個有趣的例子:
void bar() { foo('a'); } int foo(char a) { printf("foobar\n"); return 0; } int main() { bar(); return 0; }
gcc編譯上面的代碼,得到如下錯誤:
test.c:16:6: error: conflicting types for 'foo' void foo(char a) ^ test.c:12:2: note: previous implicit declaration is here foo('a');
這是因為當(dāng)編譯器在bar()中遇到foo調(diào)用時,編譯器并不能看到后面近在咫尺的foo函數(shù)定義。它只能根據(jù)隱式函數(shù)聲明,生成int foo(int)的函數(shù)調(diào)用代碼,注意隱式生成的函數(shù)參數(shù)為int而不是char,這應(yīng)該是編譯器做的一個向上轉(zhuǎn)換,向int靠齊。在編譯器解析到更為適合的int foo(char)時,它可不會認錯,它會認為foo定義和編譯器隱式生成的foo聲明不一致,得到編譯錯誤。將上面的foo函數(shù)替換為 void foo(int a)也會得到類似的編譯錯誤,C語言嚴格要求一個符號只能有一種定義,包括函數(shù)返回值也要一致。
而將foo定義放于bar之前,就編譯運行OK了。
C++ 編譯模型
到目前為止,我們提到的3點關(guān)于C編譯模型的特性,對C語言來說,都是利多于弊的,因為C語言足夠簡單。而當(dāng)C++試圖兼容這些特性時(C++沒有隱式函數(shù)聲明),加之C++本身獨有的重載,類,模板等特性,使得C++更加難以理解。
1. 單遍編譯
C++沒有隱式函數(shù)聲明,但它仍然遵循單遍編譯,至少看起來是這樣,單遍編譯語義給C++帶來的影響主要是重載決議和名字解析。
1.1 重載決議
#include<stdio.h> void foo(int a) { printf("foo(int)\n"); } void bar() { foo('a'); } void foo(char a) { printf("foo(char)\n"); } int main() { bar(); return 0; }
以上代碼通過g++編譯運行結(jié)果為:foo(int)。盡管后面有更合適的函數(shù)原型,但C++在解析bar()時,只看到了void foo(int)。
這是C++重載結(jié)合單遍編譯造成的困惑之一,即使現(xiàn)在C++并非真的單遍編譯(想一下前向聲明),但它要和C兼容語義,因此不得不”裝傻”。對于C++類是個例外,編譯器會先掃描類的定義,再解析成員函數(shù),因此類中所有同名函數(shù)都能參加重載決議。
關(guān)于重載還有一點就是C的隱式類型轉(zhuǎn)換也給重載帶來了麻煩:
// Case 1 void f(int){} void f(unsigned int){} void test() { f(5); } // call f(int) // Case 2 void f(int){} void f(long){} void test() { f(5); } // call f(int) // Case 3 void f(unsigned int){} void f(long){} void test() { f(5); } // error. 編譯器也不知道你要干啥 // Case 4 void f(unsigned int){} void test{ f(5); } // call f(unsigned int)... void f(long){}
再加上C++子類到父類的隱式轉(zhuǎn)換,轉(zhuǎn)換運算符的重載… 你必須費勁心思,才能確保編譯器按你預(yù)想的去做。
1.2 名字查找
單遍編譯給C++造成的另一個影響是名字查找,C++只能通過源碼來了解名字的含義,比如 AA BB(CC),這句話即可以是聲明函數(shù),也可以是定義變量。編譯器需要結(jié)合它解析過的所有源代碼,來判斷這句話的確切含義。當(dāng)結(jié)合了C++ template之后,這種難度幾何攀升。因此不經(jīng)意地改動頭文件,或修改頭文件包含順序,都可能改變語句語義和代碼的含義。
2. 頭文件
在初學(xué)C++時,函數(shù)聲明放在.h文件,函數(shù)實現(xiàn)放在.cpp文件,似乎已經(jīng)成了共識。C++沒有C的隱式函數(shù)聲明,也沒有其它高級語言的包機制,因此,同一個項目中,頭文件已經(jīng)成了模塊與模塊之間,類與類之間,共享接口的主要方式。
C中的效率性,傳遞性,差異性,一致性,C++都一個不落地繼承了。除此之外,C++頭文件還帶來如下麻煩:
2.1 順序性
由于C++頭文件包含更多的內(nèi)容:template, typedef, #define, #pragma, class,等等,不同的頭文件包含順序,將可能導(dǎo)致完全不同的語義?;蛘咧苯訉?dǎo)致編譯錯誤。
2.2 又見重載
由于C++支持重載,因此如果頭文件中的函數(shù)聲明和源文件中函數(shù)實現(xiàn)不一致(如參數(shù)個數(shù),const屬性等),將可能構(gòu)成重載,這個時候”聰明”的C++編譯器不錯報錯,它將該函數(shù)的調(diào)用地址交給鏈接器去填,而源文件中寫錯了的實現(xiàn)將被認定為一個全新的重載。從而到鏈接階段才報錯。這一點在C中會得到編譯錯誤,因為C沒有重載,也就沒有名字改編(name mangling),將會在編譯時得到符號沖突。
2.3 重復(fù)包含
由于頭文件的傳遞性,有可能造成某上層頭文件的重復(fù)包含。重復(fù)包含的頭文件在展開后,將可能導(dǎo)致符號重定義,如:
// common.h class Common { // ... }; // h1.h #include "common.h" // h2.h #include "common.h" // test.cpp #include "h1.h" #include "h2.h" int main() { return 0; }
如果common.h中,有函數(shù)定義,結(jié)構(gòu)體定義,類聲明,外部變量定義等等。test.cpp中將展開兩份common.h,編譯時得到符號重定義的錯誤。而如果common.h中只有外部函數(shù)聲明,則OK,因為函數(shù)可在多處聲明,但只能在一處定義。關(guān)于類聲明,C++類保持了C結(jié)構(gòu)體語義,因此叫做”類定義”更為適合。始終記得,頭文件只是一個公共代碼的整合,這些代碼會在預(yù)編譯期替換到源文件中。
為了解決重復(fù)包含,C++頭文件常用 #ifndef #define #endif或#pragma once來保證頭文件不被重復(fù)包含。
2.4 交叉包含
C++中的類出現(xiàn)相互引用時,就會出現(xiàn)交叉包含的情況。如Parent包含一個Child對象,而Child類包含Parent的引用。因此相互包含對方的頭文件,編譯器展開Child.h需要展開Parent.h,展開Parent.h又要展開Child.h,如此無限循環(huán),最終g++給出:error: #include nested too deeply的編譯錯誤。
解決這個問題的方案是前向聲明,在Child類定義前面加上 class Parent; 聲明Parent類,而無需包含其頭文件。前向聲明不止可以用于類,還可以用于函數(shù)(即顯式的函數(shù)聲明)。前向聲明應(yīng)該被大量使用,它可以解決頭文件帶來的絕大多數(shù)問題,如效率性,傳遞性,重復(fù)包含,交叉包含等等。這一點有點像包(package)機制,需要什么,就聲明(導(dǎo)入)什么。前向聲明也有局限:僅當(dāng)編譯器無需知道目標(biāo)類完整定義時。如下情形,類A可使用 class B;:
類A中使用B聲明引用或指針;
類A使用B作為函數(shù)參數(shù)類型或返回類型,而不使用該對象,即無需知道其構(gòu)造函數(shù)和析構(gòu)函數(shù)或成員函數(shù);
2.5 如何使用頭文件
關(guān)于頭文件使用的建議:
降低將文件間的編譯依賴(如使用前向聲明);
將頭文件歸類,按照特定順序包含,如C語言系統(tǒng)頭文件,C++系統(tǒng)頭文件,項目基礎(chǔ)頭文件,項目頭文件;
防止頭文件重復(fù)編譯(#ifndef or #pragma);
確保頭文件和源文件的一致;
3.總結(jié)
C語言本身一些比較簡單的特性,放在C++中卻引起了很多麻煩,主要是因為C++復(fù)雜的語言特性:類,模板,各種宏… 舉個例子來說,對于一個類A,它有一個私有函數(shù),需要用到類B,而這個私有函數(shù)必須出現(xiàn)在類定義即頭文件中,因此就增加了A頭文件對B的不必要引用。這是因為C++類遵循C結(jié)構(gòu)體的語義,所有類成員都必須出現(xiàn)在類定義中,”屬于這個類的一部分”。這不僅在定義上造成不便,也在容易在語義上造成誤解,事實上,C++類的成員函數(shù)不屬于對象,它更像普通函數(shù)(虛函數(shù)除外)。
而在C中,沒有”類的捆綁”,實現(xiàn)起來就要簡單多了,將該函數(shù)放在A.c中,函數(shù)不在A.h中聲明。由A.c包含B.h,解除了A.h和B.h之間的關(guān)聯(lián),這也是C將數(shù)據(jù)和操作分離的優(yōu)勢之一。
最后,看看其它語言是如何避免這些”坑”的:
對于解釋型語言,import的時候直接將對應(yīng)模塊的源文件解析一遍,而不是將文件包含進來;
對于編譯型語言,編譯后的目標(biāo)文件中包含了足夠的元數(shù)據(jù),不需要讀取源文件(也就沒有頭文件一說了);
它們都避免了定義和聲明不一致的問題,并且在這些語言里面,定義和聲明是一體的。import機制可以確保只到處必要的名字符號,不會有多余的符號加進來。
相關(guān)文章
C++中g(shù)etline()、gets()等函數(shù)的用法詳解
這篇文章主要介紹了C++中g(shù)etline()、gets()等函數(shù)的用法,本文給大家介紹的非常詳細,具有一定的參考借鑒價值,需要的朋友可以參考下2020-02-02C/C++使用socket實現(xiàn)判斷ip是否能連通
這篇文章主要為大家詳細介紹了C/C++如何使用socket實現(xiàn)判斷ip是否能連通,文中的示例代碼講解詳細,具有一定的學(xué)習(xí)價值,感興趣的小伙伴可以了解一下2023-07-07用C/C++實現(xiàn)linux下檢測網(wǎng)絡(luò)接口狀態(tài)
這篇文章主要為大家詳細介紹了用c/c++實現(xiàn)linux下檢測網(wǎng)絡(luò)接口狀態(tài),具有一定的參考價值,感興趣的小伙伴們可以參考一下2018-06-06WM_CLOSE、WM_DESTROY、WM_QUIT及各種消息投遞函數(shù)詳解
這篇文章主要介紹了WM_CLOSE、WM_DESTROY、WM_QUIT及各種消息投遞函數(shù),有助于讀者更好的理解windows程序的消息機制,需要的朋友可以參考下2014-07-07