作者:starwing83網友
C語言如此流行,有一個很重要的原因是C語言志向於“抽象、泛化”概念而不是為了一個應用就加一個特殊的概念。這樣的思想使得C語言的概念通用而有生命力。然而,正是這個原因,也導致了C語言極易被誤用。因此教材就應該在這方面盡一本教材的引導的責任——告訴你什么是應該做的,什么是容易出錯的。而至少在預處理這個方面,譚老並沒有盡到自己該進的責任。
C語言的預處理是很通用的,因為它在文本上操作,進行簡單的文本替換。所以它和C的語法並不在一個邏輯層次上。其中有一種預處理操作就是這里我們要討論的包含:#include 預處理符。#include 預處理符並不一定非要出現在程序的開頭,事實上它可以出現在程序文本的任何地方,只要它出現在行首即可。包含本質上就是把一個文件原封不動地“粘貼”到 #include 預處理符所在的地方。包含提供了強大的功能,比如可以在文件間共享一系列的信息。因此譚老就“靈活運用”了這個概念:
圖11.2(a)為文件file1.c,該文件中有一個#include <file2.c>指令
——譚浩強 ,《C程序設計(第四版)學習輔導》,清華大學出版社,2010年7月,p186
我們知道,C語言有個約定俗成的慣例——被包含的文件應該以 .h 后綴名結尾,這些文件被稱為頭文件。而譚老在這里卻在教材里面堂而皇之地包含了一個通常我們認為是被直接編譯(而不是用作包含目的)的源代碼文件。在一本面向初學者的教材中這樣不顧約定肆意書寫代碼正確合適嗎?
首先,我們要知道,為什么C語言會有“.h文件用於包含,而.c文件用於編譯”這么一個約定俗成的規定。這里我們需要介紹一個基本的概念:翻譯單元(translation unit),並了解C程序到底是怎么被“編譯”出來的。
C語言的程序是以翻譯單元為單位編譯的。通常一個翻譯單元就是一個源程序文件。這個源程序文件可以利用包含的方法去包含其他的文件。那些被包含的文件就被直接“復制”到包含的地方了。你可以認為是一堆的文件,比如 stdio.h 啦,stdlib.h 啦,這里的 file2.c 啦“拼接”成了一個非常非常大的文件,這個過程是編譯系統中的“預處理器”完成的。然后系統中的“編譯器”就在這個預處理器生成的非常大的文件上工作了,並將其翻譯成一個“目標文件”。
在這個目標文件中,會有一些C語言的對象(如函數,全局變量等等,他們在C語言中統稱“對象”,注意和C++對象不同)是“有名字的”,你也會用到一些有名字卻不知道在哪兒的對象,比如 printf ,那么為了整個程序最后能執行,C編譯器就需要去查找哪兒有這個 printf ,它按照這個名字去一些預定的地方,以及你提供給它的地方去找各種做好的“目標文件”,然后在這些目標文件中查找有沒有一個東西叫做 printf ,一旦找到了,系統中的“鏈接器”就會用這個目標文件中導出的 printf 的接口在你自己的目標文件里面替換,最后把所有的目標文件合起來做成一個大文件,這個文件就是可執行程序了。
C程序都是通過預處理(前面說的拼接)、編譯(將拼接后的翻譯單元變成目標文件)、鏈接(在各個目標文件中根據名字查找需要的對象)三個過程,利用預處理器,編譯器和鏈接器三個工具才會變成最終可以被執行的程序的【注】。
這里我們可以把一個又一個的目標文件看做是一個個的黑盒子,把有名字的對象當作這個黑盒子上的“接口”,我們將不同的黑盒子接在一起,然后整個黑盒子組合就能工作了,這就是鏈接過程。而編譯就是從被處理好的程序文本中生成黑盒子的過程,當然我們這里提到的包含就是處理程序文本的其中一個過程。
在C語言里面,什么樣的對象有資格可以在目標文件間有一個名字呢?答案是“外部鏈接性對象”,C語言的對象都是有鏈接性的。這又是一個大話題。我們現在只需要知道有辦法讓C的函數或者全局變量等等這些對象的名字被其他目標文件“看到”即可。這種讓其他目標文件看到自己的名字的對象所具有的鏈接性,就叫做“外部鏈接性”。C語言的函數默認具有外部鏈接性。而特殊的靜態函數不具有外部鏈接性。
從上面的介紹中,大家可以看到,如果你在 .c 文件中寫了一些函數定義,而將其include 到其他的 .c 文件中去了,那么如果同時編譯這兩個 .c 文件。那么就在兩個目標文件中有相同的名字被所有人知道。編譯器不知道該選擇那個對象有資格擁有這個名字,於是鏈接器拒絕進行鏈接操作,這就是鏈接錯誤。
也就是說,譚老的這種寫法是很容易出問題的。如果你只編譯鏈接 file2.c,那么一切都沒有問題,但是如果你同時編譯這兩個C文件,那么就會導致一個鏈接錯誤。這也就是為什么前面提到的,被包含的文件必須是 .h 文件的原因。因為我們約定俗成不會在.h 文件里面書寫定義,我們只是在 .h 文件里面告訴編譯器:在某個目標文件中有一個對象,它的名字叫做 printf 或者 rand ,所以即使多個 .c 文件包含了同一個 .h 文件,也不會造成鏈接錯誤。最重要的是,約定俗成地,我們默認一個翻譯單元就是一個 .c文件,而 .h 文件除非被主動包含否則是不參與編譯過程的。這樣就很安全了。
我們來總結一下,在C語言的世界里,我們約定俗成:
- .c 文件約定俗成地是作為一個獨立翻譯單元存在的。它的功能就是真正的生成一系列對象,讓鏈接器“有米可炊”。
- .h 文件是用於向其他模塊導出,告訴別人“我這里有這么一個對象,快來用吧”的。它存在的目的是告訴編譯器“這個就是米”- 而至於“米在哪兒”的問題,則是鏈接器的工作,由鏈接器自行尋找,我們只是偶爾提供一些啟示,告訴它在哪兒尋找而已。
譚老完全無視掉了這種約定俗成的規則,這會對初學者造成很大的誤導。這實際上是飲鴆止渴,譚老自己是自圓其說了,但是會給初學者造成很大的困擾和危害,甚至會導致初學者走很多的彎路而不自知。
那么,這種約定俗成是不是就是金科玉律呢?我們可不可以包含其他的非 .h 文件呢?特別的,我們可不可以包含 .c 文件呢?
雖然我們不提倡初學者這么做,但事實是,在很多情況下,這種現象是會出現的。很多實際的項目都用到了這個技術。這個技術的出現是為了解決一些C語言在實際開發中的固有問題。
一種很通常的情況出現在一些小項目里,為了方便編譯,這樣的小項目通常會提供一個所謂的 amalgamation 文件,在這個文件中,將所有的 .c 源代碼包含進來,然后要編譯這個項目,只需要編譯這個單個的 amalgamation 文件即可。這種技術的出現或者是為了方便小項目被嵌入其他工程中,或者簡化編譯過程,或者方便編譯器優化,或者加快編譯速度等等原因。但是我們要知道,這種做法和一個一個地編譯 .c 文件在語法上是完全等價的。它更多地是一種為了方便考慮而出現的解決方案。
還有一種情況是某個實現需要跨平台,或者支持多種不同的實現。這個時候,我們會將各種不同的實現或者針對不同操作系統的部分寫成一個 .c 文件,並在主文件里面進行選擇性的包含。這種情況其實是可以通過編譯指令來完成的,不過如果結合下面的這種情況后,包含 .c 文件的方案能獲得額外的好處。另外的好處是,如果程序需要同時支持多個后端,則可以通過聲明預處理符來改變一個 .c 文件的行為,並通過多次包含而獲得多個后端。Lua 的 lmd5 模塊就是這么做的。
另外一種情況是項目很復雜,而由很多很多的函數組成,我們知道C的函數默認是具有外部鏈接性的。但是項目維護者恰巧不願意這些小函數被外界知道了——也就是說,項目維護者不希望鏈接器“看得到”有這些函數存在,於是維護者將其聲明為靜態函數,即不允許它們被其他翻譯單元(目標文件)“看見”。然而我們知道C語言的編譯是以翻譯單元為單位的,既然不允許被其他文件所看到,那么這些靜態函數就不得不被寫在同一個文件里面了。當項目很大時,這可能會造成一個文件就有幾十萬行代碼。為了避免這個問題,項目維護者將這些內部的小函數分別寫在獨立的文件中,將其聲明為靜態的,然后利用包含讓他們實際上屬於同一個翻譯單元。這樣就能同時解決上述的兩個問題了。
但是,即便是這種情況,起一個不容易被誤解的后綴名,比如 .inc 也是很有益的。
如果想要文件能夠自動被編輯器認出是C文件,那么在后綴名非末尾的部分帶一個 .c 就行了(比如 .c.inc 這種),大多數的編輯器都會讀取所有的后綴並選擇一個最合適的。
雖然只是改一個后綴名,這樣還是會給項目維護帶來好處。而直接包含 .c 文件仍然是被認為很不自然,也很違反直覺的。
這里要提到一點,這里修改后綴名只是為了習慣考慮。如果不改后綴名,這些 .c 文件即使被編譯也不會造成惡劣的影響,因為這些文件中的函數都是隱藏的。實際上最后編譯出來的目標文件根本不會具有任何的接口,也不可能被鏈接器選中鏈接的。
對於后面提出的情況,這里仍然有一個很不錯的解決方案,它比直接包含 .c 文件更好,比包含 .inc 文件要清晰要約定俗成得多。這個方案就是將這些小函數寫成靜態函數,並放到私有頭文件中,然后由需要的文件去包含這些私有的頭文件。因為私有的頭文件比私有的源代碼文件要安全得多,也好管理得多,也不容易造成上面提到的編輯器不認的問題。最后,這些私有頭文件可以起很明顯的名字,有助於項目維護,甚至這些私有頭文件可以和公開頭文件放在不同的地方,更不容易造成誤解。
我們知道,C語言是很自由的一門語言,那么為了更好地合作和溝通,我們在C語言之外就會有很多約定俗成的規則。所謂“規則的出現就是為了被打破的”,我們並不是要讀者去墨守成規。但是,讀者在做開發的時候,千萬千萬記住自己到底要做什么,不要為了圖個新鮮,或者為了打破規則而去打破規則。如果做得每件事情都有自己的道理,都經過了自己獨立的思考,那么打破規則也是可以的。
然而,教科書要有教科書自己的考慮。教科書要告訴讀者這些規則是什么,為什么會產生這些規則,不遵守這些規則會怎么樣,什么情況下可以打破這些規則。譚老的書里卻完全沒有提到這些。我想是因為譚老自己都不知道有這些規則吧。譚書的這種做法,實在不是一本稍微入流的教科書的做法。讀者如果沒有自我清晰的認識,是很容易被誤導的。
最后再強調一次,奇技淫巧是可以的,但是讀者一定要非常明確“自己要做什么,除此以外有沒有更好的辦法做這件事,有沒有標准和自然的方法做這件事”。C語言如此自由,所以需要開發者更清醒地去駕馭它。
【注】這里要提到的是,大多數現代的編譯系統中的“編譯器”的部分,實際上是由兩個部分組成的:一個叫做編譯器,功能是將預處理器處理后的結果變成匯編語言代碼,一個叫做匯編器,功能是將匯編代碼變成最后的目標文件。這部分內容和主題無關,這里就省略掉了。