1、C語言模塊化編程中的頭文件
實際開發中一般是將函數和變量的聲明放到頭文件,再在當前源文件中 #include 進來。如果變量的值是固定的,最好使用宏來代替。
.c和.h文件都是源文件,除了后綴不一樣便於區分外和管理外,其他的都是相同的,在.c中編寫的代碼同樣也可以寫在.h中,包括函數定義、變量定義、預處理等。
但是,.h 和 .c 在項目中承擔的角色不一樣:.c 文件主要負責實現,也就是定義函數和變量;.h 文件主要負責聲明(包括變量聲明和函數聲明)、宏定義、類型定義等。這些不是C語法規定的內容,而是約定成俗的規范,或者說是長期形成的事實標准。
根據這份規范,頭文件可以包含如下的內容:
- 可以聲明函數,但不可以定義函數。
- 可以聲明變量,但不可以定義變量。
- 可以定義宏,包括帶參的宏和不帶參的宏。
- 結構體的定義、自定義數據類型一般也放在頭文件中。
在項目開發中,我們可以將一組相關的變量和函數定義在一個 .c 文件中,並用一個同名的 .h 文件(頭文件)進行聲明,其他模塊如果需要使用某個變量或函數,那么引入這個頭文件就可以。
這樣做的另外一個好處是可以保護版權,我們在發布相關模塊之前,可以將它們都編譯成目標文件,或者打包成靜態庫,只要向用戶提供頭文件,用戶就可以將這些模塊鏈接到自己的程序中。
2、C語言標准庫以及標准頭文件
源文件通過編譯可以生成目標文件(例如 GCC 下的 .o 和 Visual Studio 下的 .obj),並提供一個頭文件向外暴露接口,除了保護版權,還可以將散亂的文件打包,便於發布和使用。
實際上我們一般不直接向用戶提供目標文件,而是將多個相關的目標文件打包成一個靜態鏈接庫(Static Link Library),例如 Linux 下的 .a 和 Windows 下的 .lib。
打包靜態庫的過程很容易理解,就是將多個目標文件捆綁在一起形成一個新的文件,然后再加上一些索引,方便鏈接器找到,這和壓縮文件的過程非常類似。
C語言在發布的時候已經將標准庫打包到了靜態庫,並提供了相應的頭文件,例如 stdio.h、stdlib.h、string.h 等。
Linux 一般將靜態庫和頭文件放在/lib和/user/lib目錄下,C語言標准庫的名字是libc.a,大家可以通過locate命令來查找它的路徑:
$ locate libc.a /usr/lib/x86_64-redhat-linux6E/lib64/libc.a $ locate stdio.h /usr/include/stdio.h /usr/include/bits/stdio.h /usr/include/c++/4.8.2/tr1/stdio.h /usr/lib/x86_64-redhat-linux6E/include/stdio.h /usr/lib/x86_64-redhat-linux6E/include/bits/stdio.h
在 Windows 下,標准庫由 IDE 攜帶,如果你使用的是 Visual Studio,那么在安裝目錄下的\VC\include文件夾中會看到很多頭文件,包括我們常用的 stdio.h、stdlib.h 等;在\VC\lib文件夾中有很多 .lib 文件,這就是鏈接器要用到的靜態庫。
大家也可以在當前工程的屬性面板(在工程名處單擊鼠標右鍵選擇“屬性”)中查看路徑:
ANSI C 標准共定義了 15 個頭文件,稱為“C標准庫”,所有的編譯器都必須支持,如何正確並熟練的使用這些標准庫,可以反映出一個程序員的水平:
- 合格程序員:<stdio.h>、<ctype.h>、<stdlib.h>、<string.h>
- 熟練程序員:<assert.h>、<limits.h>、<stddef.h>、<time.h>
- 優秀程序員:<float.h>、<math.h>、<error.h>、<locale.h>、<setjmp.h>、<signal.h>、<stdarg.h>
C語言共有兩套標准,也就是 ANSI C 和 C99。ANSI C 是較早的標准,各種編譯器都能很好的支持,C99 是后來的標准,編譯器對它的支持不盡相同,請大家閱讀《 C語言的三套標准:C89、C99和C11》了解更多細節。
除了C標准庫,編譯器一般也會附帶自己的庫,以增加功能,方便用戶開發,爭奪市場份額。這些庫中的每一個函數都在對應的頭文件中聲明,可以通過 #include 預處理命令導入,編譯時會被合並到當前文件。
3、細說C語言頭文件的路徑
我們常說,引入編譯器自帶的頭文件(包括標准頭文件)用尖括號,引入程序自定義的頭文件用雙引號,例如:
#include <stdio.h> //引入標准頭文件 #include "myFile.h" //引入自定義的頭文件
使用尖括號< >,編譯器會到系統路徑下查找頭文件;而使用雙引號" ",編譯器首先在當前目錄下查找頭文件,如果沒有找到,再到系統路徑下查找。也就是說,使用雙引號比使用尖括號多了一個查找路徑,它的功能更為強大,我們完全可以使用雙引號來包含標准頭文件,例如:
#include "stdio.h" #include "stdlib.h"
那么,這里所說的“系統路徑”和“當前路徑”是什么意思呢?
3.1、絕對路徑和相對路徑
理論上講,我們可以將頭文件放在磁盤上的任意位置,只要帶路徑包含進來就可以。以 Windows 為例,在 D 盤下創建一個自定義的文件夾,名字為abc,它里面有一個頭文件叫做xyz.h,那么在程序開頭使用#include "D:\\abc\xyz.h"就能夠引入該頭文件。
現在不妨假設 xyz.h 中有一個宏定義和一個變量:
#define NAME "C語言中文網" int age = 5;
我們不鼓勵在頭文件中定義變量,否則多次引入后會出現重復定義錯誤,這里僅是一個演示案例,並不規范。
下面的代碼會輸出頭文件中的宏和變量:
#include<stdio.h> #include "D:\\abc\xyz.h" int main() { printf("%s已經 %d 歲了!\n", NAME, age); return 0; }
運行結果:
C語言中文網已經 5 歲了!
(1)絕對路徑
像D:\\abc\xyz.h這種從盤符開始、完整地描述文件位置的路徑就是絕對路徑(Absolute Path)。
絕對路徑從文件系統的“根部”開始查找文件:
1) 在 Windows 下,根部就是 C、D、E 這樣的盤符,例如D:\\a.h、E:\images\123.jpg、E:/videos/me.mp4、D://abc/xyz.h等,分隔符可以是正斜杠/也可以是反斜杠\,盤符后面的斜杠可以有一個也可以有兩個。
2) Linux 沒有盤符,根部就是/,例如/home/xxx/abc.h、/user/include/module.h等,分隔符只能是正斜杠/,比 Windows 簡潔很多。
為了增強代碼的可移植性,引入頭文件時請盡量使用正斜杠/。
(2)相對路徑
相對路徑(relative path)是從當前目錄(文件夾)開始查找文件;當前目錄是指需要引入頭文件的源文件所在的目錄,這也是本文開頭提到的“當前路徑”。
以 Windows 為例,假設在E:/cDemo/中有源文件 main.c 和頭文件 xyz.h,那么在 main.c 中使用#include "./xyz.h"語句就可以引入 xyz.h,其中./表示當前目錄,也即E:/cDemo/。
如果將 xyz.h 移動到E:/cDemo/include/(main.c 所在目錄的下級目錄),那么包含語句就應該修改為#include "./include/xyz.h";對於 main.c 來說,此時的“當前目錄”依然是E:/cDemo/。
如果將 xyz.h 移動到E:/(main.c 所在目錄的上級目錄),那么包含語句就應該修改為#include "./../xyz.h",其中../表示上級目錄。./../xyz.h的意思是,在當前目錄的上級目錄中查找 xyz.h 文件。
如果將 xyz.h 移動到E:/include目錄,那么包含語句就應該修改為#include "./../include/xyz.h"。
需要注意的是,我們可以將./省略,此時默認從當前目錄開始查找,例如#include "xyz.h"、#include "include/xyz.h"、#include "../xyz.h"、#include "../include/xyz.h"。
上面介紹的相對路徑的寫法同樣適用於 Linux,請大家親自測試,這里不再贅述。
在實際開發中,我們都是將頭文件放在當前工程目錄下,非常建議大家使用相對路徑,這樣即使后來改變了工程所在目錄,也無需修改包含語句,因為源文件的相對位置沒有改變。
3.2、系統路徑
Windows 下的C語言標准庫由 IDE 自己攜帶,Linux 下的C語言標准庫一般在固定的路徑下,總起來說,標准庫不在工程目錄下,要使用絕對路徑才能引入頭文件,這樣每次切換平台或者 IDE 都要修改包含路徑,非常不方便。
為了讓頭文件更加具有實踐意義,Windows 下的 IDE 都可以為靜態庫和頭文件設置默認目錄。以 Visual Studio 為例,在當前工程名處單擊鼠標右鍵,選擇“屬性”,在彈出的對話框中就可以看到已經設置好的路徑,如下圖所示:
這些已經設置好的路徑就是本文開頭提到的“系統路徑”。
當使用相對路徑的方式引入頭文件時,如果使用< >,那么“相對”的就是系統路徑,也就是說,編譯器會直接在這些系統路徑下查找頭文件;如果使用" ",那么首先“相對”的是當前路徑,然后“相對”的才是系統路徑,也就是說,編譯器首先在當前路徑下查找頭文件,找不到的話才會繼續在系統路徑下查找。
而使用絕對路徑的方式引入頭文件時,< >和" "沒有任何區別,因為頭文件路徑已經寫死了(從根部開始查找),不需要“相對”任何路徑。
總起來說,相對路徑要有“相對”的目標,這個目標可以是當前路徑,也可以是系統路徑,< >和" "決定了到底相對哪個目標。
4、防止C語言頭文件被重復包含
頭文件包含命令 #include 的效果與直接復制粘貼頭文件內容的效果是一樣的,預處理器實際上也是這樣做的,它會讀取頭文件的內容,然后輸出到 #include 命令所在的位置。
頭文件包含是一個遞歸(循環)的過程,如果被包含的頭文件中還包含了其他的頭文件,預處理器會繼續將它們也包含進來;這個過程會一直持續下去,直到不再包含任何頭文件,這與遞歸的過程頗為相似。
遞歸包含會導致一個問題,就是重復引入同一個源文件。例如在某個自定義頭文件 xyz.h 中聲明了一個 FILE 類型的指針,以使得所有的模塊都能使用它,如下所示:
extern FILE *fp;
FILE 是在 stdio.h 中自定義的一個類型(本質上是一個結構體),要想使用它,必須包含 stdio.h,因此 xyz.h 中完整的代碼應該是這樣的:
#include <stdio.h>
extern FILE *fp;
現在假設程序的主模塊 main.c 中需要使用 fp 變量和 printf() 函數,那么就需要同時引入 xyz.h 和 stdio.h:
#include <stdio.h> #include "xyz.h" int main()
{ if( (fp = fopen("demo.txt", "r")) == NULL )
{ printf("File open failed!\n"); } //TODO: return 0; }
這樣一來,對於 main.c 這個模塊,stdio.h 就被包含了兩次。stdio.h 中除了有函數聲明,還有宏定義、類型定義、結構體定義等,它們都會出現兩次,如果不做任何處理,不僅會出現重復定義錯誤,而且不符合編程規范。
有人說,既然已經知道 xyz.h 中包含了 stdio.h,那么在 main.c 中不再包含 stdio.h 不就可以了嗎?是的,確實如此,這樣做就不會出現任何問題!
現在我們不妨換一種場景,假設 xyz1.h 中定義了類型 RYPE1,xyz2.h 中定義了類型 TYPE2,並且它們都包含了 stdio.h,如果主模塊需要同時使用 TYPE1 和 TYPE2,就必須將 xyz1.h 和 xyz2.h 都包含進來,這樣也會導致 stdio.h 被重復包含,並且無法回避,上面的方案解決不了問題。
實際上,頭文件的交叉包含是非常普遍的現象,不僅我們自己創建的頭文件是這樣,標准頭文件也是如此。例如,標准頭文件 limits.h 中定義了一些與數據類型相關的宏(最大值、最小值、一個字節所包含的比特位等),stdlib.h 就包含了它。
我們必須找到一種行之有效的方案,使得頭文件可以被包含多次,但效果與只包含一次相同。
在實際開發中,我們往往使用宏保護來解決這個問題。例如,在 xyz.h 中可以添加如下的宏定義:
#ifndef _XYZ_H #define _XYZ_H /* 頭文件內容 */ #endif
第一次包含頭文件,會定義宏 _XYZ_H,並執行“頭文件內容”部分的代碼;第二次包含時因為已經定義了宏 _XYZ_H,不會重復執行“頭文件內容”部分的代碼。也就是說,頭文件只在第一次包含時起作用,再次包含無效。
標准頭文件也是這樣做的,例如在 Visual Studio 2010 中,stdio.h 就有如下的宏定義:
#ifndef _INC_STDIO #define _INC_STDIO /* 頭文件內容 */ #endif
這種宏保護方案使得程序員可以“任性”地引入當前模塊需要的所有頭文件,不用操心這些頭文件中是否包含了其他的頭文件。
