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