編譯器自動識別添加頭文件應該不是太難實現吧?為啥要手工維護這個頭文件呢


我不清楚你問的是,頭文件存在的意義,還是質疑 C/C++ 的 IDE 不能幫你自動 include 頭文件?我一開始是按後者去答的,看了別人的回答,發現理解成前者的居多。


關於頭文件的作用:

傳統的編譯模型里,一個源文件構成一個編譯單元。各個編譯單元之間互不知道任何信息,只能靠頭文件統一各個介面的調用規則。

這種編譯模型有很多優點。

首先,可以支持你閉源發布你的庫。只要你提供頭文件和一個二進位的庫文件給你庫的使用者就夠了。和 Java 里同樣做了一定編譯處理的 jar 包不同,C/C++ 庫的二進位里是沒有任何對類(結構體)的排布信息和對函數的調用規則描述的,這個描述的工作就是頭文件做了。好處就是二進位庫文件以及鏈接了庫的程序里不需要記錄任何的元信息,體積小,而且被逆向的難度相對困難。另外呢,程序發布的時候,也不需要發布頭文件。

其次,如果一個頭文件對應的源文件內部的實現有更改,只要調用介面保持不變的話,那隻要把 callee 所屬的編譯單元重新編譯,再把新目標文件重新鏈接進二進位就行了,caller 所屬的編譯單元都不需要重新編譯。

還有呢,各個編譯單元之間相互獨立,有利於對各個編譯單元並行編譯、做增量編譯,因為之間沒有互相依賴嘛。這個可以提高編譯速度。

後來,隨著 C++ 中模板的興起,以及 inline 語義的變化,C++ 的頭文件中就不止有傳統的聲明語句,也可以給出函數的實現了。這就出現了不同於傳統的靜態庫和動態庫的第三種庫 —— head-only library(唯頭文件庫),特點是使用方便,還有利於編譯器做優化。另外也利於解決 ABI 衝突問題。


以下是原答案:

不,你太想當然了。要提供一個識別準確的而且識別速度還快的實現挺難。

我當時才學 Java 時,對 Java IDE 的自動 import 印象很深。只要敲出些啥,Eclipse 就在下面提示個下劃線。只要滑鼠移過去,點擊氣泡里的自動 import 就好了。當時確實覺得挺高級,挺智能。

但是這個問題放在 C/C++ 里就完全不一樣了。C/C++ 里有宏,而宏會決定頭文件里實際生效的代碼。如果是 IDE 比較好掌握的條件宏,比如 C++ 里通過 -std= 選項控制標註版本的 __cplusplus 宏,問題還比較容易處理。但如果是通過 -D 選項由用戶指定的宏呢?比如有下面這兩個頭文件,PLATFORM 這個宏是通過 -D 參數定義的,你在源文件里敲個 someAPI,你告訴我該 include 哪個文件?

// posix.h
#ifndef POSIX_H
#define POSIX_H

#if PLATFORM == UNIX

inline void someAPI()
{
// implement under UNIX
}

#endif

#endif // POSIX_H

// windows.h
#define WINDOWS_H
#define WINDOWS_H

#if PLATFORM == WINDOWS

void someAPI()
{
// implement under WINDOWS
}

#endif

#endif // WINDOWS_H

對於有些 IDE,比如 vs 可以從它的解決方案的屬性里,又比如 CLion 可以從 CMakeLists.txt 里提前掌握到編譯時會傳哪些宏,那麼這些宏還相對來說好處理一些。但這也不是絕對的。假如我要用 __TIME__ 宏(一個定義編譯時間的宏)搞事情呢?如果項目是早上編譯的那我啟用某段代碼,如果是下午編譯的我啟用另一段,那你怎麼搞?

有的人會說,那你別管這些條件宏了,把所有分支都分析一遍不就好了么?那對不起,也不行。如果有些分支啟用了,會導致整個文件多了或者少了括弧怎麼辦?或者有些分支里有使用了其他編譯器提供的擴展的內容,你的語法分析器不認識的東西怎麼辦?你這個分析程序還得支持模糊分析啊!比編譯器難做多了啊。

另外呢,你可能對 C++ 的語法複雜度毫無概念。

class MyIntAllocator;

namespace std
{
template &
class vector;
}

extern std::vector& v1, v2;

void f()
{
std::swap(v1, v2);
}

比如這個例子,可能稍熟悉點 C++ 的都知道 std::swap(T , T) 是 & 中提供的模板函數。那對於上一段代碼,應該提示缺少 & 頭文件嗎?如果不假思索說 yes 的那你就錯了。為什麼?因為 & 中有針對 std::vector 的 swap 特化啊!根據模板中的最佳匹配原則,應該優先適用特化的版本。如果只 include 了 std::swap(T , T) 所在的文件,致使編譯器在編譯時沒「看到」特化版本,而只「看到」並用通用的 std::swap(T , T) 版本去編譯,那生成的程序就 tm 有 bug 了呀。而且這種 bug 還很難去查,做這個自動 include 的人就得被罵死了啊。

如果給你再加點難度。假設這個模板特化啟不啟用是有利用 SFINAE 法則的呢?如果這個 SFINAE 的條件計算很耗時間呢?你可能都沒見過一個 .cpp 單文件編譯 15 分鐘,編譯器佔了 10G 內存是個什麼美妙的情景。

另外,不同命名空間可能有同名的東西。比如你寫個 vector,該 include 標準庫的 & 呢?還是 & 呢?那你是不是得要把包含目錄下的所有頭文件都分析一遍,最後才只能得到個「先生,您希望 include & 還是 include & 」的建議?

向你介紹下,我本機的 /usr/include 文件夾可是有 94M 的呦,/usr/local/include 文件夾可是有 192M 的呦。哦對了,還差點忘說了,編譯的時候用戶還可以通過 -I 再加自定義包含目錄的。


實際上,自動 include CLion 就有做,但是我的感覺是還是不太好用,因為經常導入錯的頭文件。另外就是做分析時太耗資源了,經常出現 12 核的 CPU 跑滿。。。

C++20 帶來了 module。或許以後,在 C++ 里也將拋棄頭文件,改用 import 來導入了。module 與頭文件最大的不同就是對宏的使用有一定的限制(比如宏隻影響本模塊內部,而不會影響到其他模塊)。這對解決我開頭說的宏給自動 import 帶來的難點有些幫助。但,因 C++ 的複雜語法規則給自動 import 帶來的難處,仍很難解決。


頭文件才是面相介面編程的終極形態


大致看了一下幾個回答,感覺說得太深奧。

首先如果一個程序稍微有點規模,我們就不可能把它的所有代碼都寫在一個文件里、甚至是一個函數里,那是難以閱讀和維護的;所以我們分而治之,將代碼寫在多個文件里,這就是最基本的模塊化思想。那麼,不用頭文件是否可以?當然可以!

假設現在我們有兩個 C 語言代碼文件,第一個是 bar.c:

typedef struct
{
const char* name;
int age;
const char* address;
const char* postalCode;
} people;

void initPeople(people* p)
{
p-&>name = "John Smith";
p-&>age = 27;
p-&>address = "21 2nd Street, NY";
p-&>postalCode = "10021-3100";
}

第二個是 foo.c:

typedef struct
{
const char* name;
int age;
const char* address;
const char* postalCode;
} people;

void initPeople(people* p);

int main()
{
people p;

initPeople(p);

return 0;
}

這完全是可以工作的:foo.c 調用了 bar.c 中的 initPeople 函數,將自己的 people 結構體的實例 p 初始化了。

我們看到,people 在兩個文件中都有定義,並且是一模一樣的;編譯器並不覺得這有任何問題,對於它來說,這就是同一個 people。但我們在維護代碼的時候卻發現了一件麻煩事:修改 people 的時候(比如加一個 phoneNumber 欄位)需要同時修改兩個文件;假設忘了其中一個,程序在運行的時候可能會出現莫名其妙的問題。於是我們便想,能不能只維護一份 people?

我們試著把 people 單獨放在一個 people.h 文件里:

typedef struct
{
const char* name;
int age;
const char* address;
const char* postalCode;
} people;

然後把 bar.c 改成:

#include "people.h"

void initPeople(people* p)
{
p-&>name = "John Smith";
p-&>age = 27;
p-&>address = "21 2nd Street, NY";
p-&>postalCode = "10021-3100";
}

把 foo.c 改成:

#include "people.h"

void initPeople(people* p);

int main()
{
people p;

initPeople(p);

return 0;
}

這樣在預處理階段,預處理器看到 #include 命令後,就會自動把 people.h 的內容插入進來;現在我們只需要維護一份 people 結構體的定義了。簡直完美!

可是這還是沒有解釋為什麼兩個 .c 文件都要 people 的定義?

這就不得不提及 C/C++ 語言的編譯模型了。C++ 基本上延續了 C 的編譯模型,C 在當時選擇的是一種「小編譯模型」,這種編譯模型在當時是明智的,即便到了今天也有它的優勢。

在這種編譯模型里,整個程序的編譯是分開到各個 translation unit 即編譯單元的;對應到 C/C++ 里,每一個源文件就是一個編譯單元。由於各個編譯單元分開編譯,編譯器同一時刻只需要處理一個源文件,不需要把整個程序全部載入到內存中。在當時內存是十分有限的,所以節省內存十分重要(設想一下因為內存不足而導致你的程序永遠無法通過編譯)。在今天,CPU 是多核的,內存也大了很多,所以現在我們經常使用並行編譯——即同時開多個編譯器分別編譯不同的源文件,編譯速度得到了很大提升,而這也得益於這種編譯模型的分而治之的思想(編譯速度很重要,它甚至能影響一個項目的開發成本)。

另外,這種編譯模型是 single pass 即單遍的——每個源文件都只是從頭到尾過一遍,就能立即得到目標代碼。這就意味著,結構體和變數必須先有定義才能使用——否則編譯器就不知道應該分配多大一塊內存、每個變數的準確位置。而在不需要頭文件的語言里,編譯模型是 multiple passes 也就是多遍的,編譯器必須多次掃描文件或者將整個程序載入到內存里才能找到定義、從而確定對象的內存布局。single pass 與 multiple passes 兩者各有優劣,例如 single pass 往往編譯速度更快、佔用內存更小,而 multiple passes 則相反,它更慢,更占內存,但有可能生成更好的代碼(因為一些問題沒有立即決定,而是綜合考慮)。

所以,最後,編譯器為什麼要自動生成、添加頭文件?對於編譯器來說,根本就不存在頭文件(頭文件只是一種預處理命令,由預處理器執行)。那麼,藉助 IDE 工具幫助我們生成一些頭文件是否可以?我認為對於部分需求來說,完全可以。但畢竟是人類在開發,也只有人類才了解需求是什麼;隨著經驗的增加,你會發現,頭文件很重要,能做很多有實際意義的事情


C和C++可以在頭文件上玩的花樣可太多了……因為選擇多,導致IDE並不能確定你打算在頭文件里玩什麼花樣。

基本上來說,頭文件是以文件組織的代碼片段,一般是不會直接編譯的;相對的,.c/.cpp源碼則會編譯為符號集(.o文件)。交付使用時頭文件還是源碼片段,各類經編譯的.c/cpp已經成了binary。

於是你可以在頭文件里做純虛類、宏定義、inline函數、模板類……各種花式應用。IDE也保證不了你是打算在頭文件里寫定義還是寫純虛介面寫inline還是做別的什麼奇怪的事情。

對於C++和C而言,header都是一個編譯期(還包括你自己做個庫然後交付給別人用於編譯)的重要組成。缺module(等c++20普及)是缺,但並不能替代頭文件……


module目的就是去掉這個

頭文件是為了在古代機器上 實現編譯器,減少內存佔用的

明明可以編譯器分析,但是還是讓開發人員把需要使用的聲明單獨列出來,就是為了減少編譯器內存消耗

後來模板,宏,header only 都是誤入歧途,積重難返了

現代不需要方便機器了,也應該逐漸廢掉頭文件概念,宏 模板 也作為源文件來看待


推薦閱讀:
相关文章