在上一節我們把兩個程序檔案放在一起編譯連結,main.c
用到的函數push
、pop
和is_empty
由stack.c
提供,其實有一點小問題,我們用-Wall
選項編譯main.c
可以看到:
$ gcc -c main.c -Wall main.c: In function ‘main’: main.c:8: warning: implicit declaration of function ‘push’ main.c:12: warning: implicit declaration of function ‘is_empty’ main.c:13: warning: implicit declaration of function ‘pop’
這個問題我們在第 2 節 “自定義函數”討論過,由於編譯器在處理函數調用代碼時沒有找到函數原型,只好根據函數調用代碼做隱式聲明,把這三個函數聲明為:
int push(char); int pop(void); int is_empty(void);
現在你應該比學第 2 節 “自定義函數”的時候更容易理解這條規則了。為什麼編譯器在處理函數調用代碼時需要有函數原型?因為必須知道參數的類型和個數以及返回值的類型才知道生成什麼樣的指令。為什麼隱式聲明靠不住呢?因為隱式聲明是從函數調用代碼推導而來的,而事實上函數定義的形參類型可能跟函數調用代碼傳的實參類型並不一致,如果函數定義帶有可變參數(例如printf
),那麼從函數調用代碼也看不出來這個函數帶有可變參數,另外,從函數調用代碼也看不出來返回值應該是什麼類型,所以隱式聲明只能規定返回值都是int
型的。既然隱式聲明靠不住,那編譯器為什麼不自己去找函數定義,而非要讓我們在調用之前寫函數原型呢?因為編譯器往往不知道去哪裡找函數定義,像上面的例子,我讓編譯器編譯main.c
,而這幾個函數的定義卻在stack.c
裡,編譯器又怎麼會知道呢?所以編譯器只能通過隱式聲明來猜測函數原型,這種猜測往往會出錯,但在比較簡單的情況下還算可用,比如上一節的例子這麼編譯過去了也能得到正確結果。
現在我們在main.c
中聲明這幾個函數的原型:
/* main.c */ #include <stdio.h> extern void push(char); extern char pop(void); extern int is_empty(void); int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; }
這樣編譯器就不會報警告了。在這裡extern
關鍵字表示這個標識符具有External Linkage。External Linkage的定義在上一章講過,但現在應該更容易理解了,push
這個標識符具有External Linkage指的是:如果把main.c
和stack.c
連結在一起,如果push
在main.c
和stack.c
中都有聲明(在stack.c
中的聲明同時也是定義),那麼這些聲明指的是同一個函數,連結之後是同一個GLOBAL
符號,代表同一個地址。函數聲明中的extern
也可以省略不寫,不寫extern
的函數聲明也表示這個函數具有External Linkage。
如果用static
關鍵字修飾一個函數聲明,則表示該標識符具有Internal Linkage,例如有以下兩個程序檔案:
/* foo.c */ static void foo(void) {}
/* main.c */ void foo(void); int main(void) { foo(); return 0; }
編譯連結在一起會出錯:
$ gcc foo.c main.c /tmp/ccRC2Yjn.o: In function `main': main.c:(.text+0x12): undefined reference to `foo' collect2: ld returned 1 exit status
雖然在foo.c
中定義了函數foo
,但這個函數隻具有Internal Linkage,只有在foo.c
中多次聲明才表示同一個函數,而在main.c
中聲明就不表示它了。如果把foo.c
編譯成目標檔案,函數名foo
在其中是一個LOCAL
的符號,不參與連結過程,所以在連結時,main.c
中用到一個External Linkage的foo
函數,連結器卻找不到它的定義在哪兒,無法確定它的地址,也就無法做符號解析,只好報錯。凡是被多次聲明的變數或函數,必須有且只有一個聲明是定義,如果有多個定義,或者一個定義都沒有,連結器就無法完成連結。
以上講了用static
和extern
修飾函數聲明的情況。現在來看用它們修飾變數聲明的情況。仍然用stack.c
和main.c
的例子,如果我想在main.c
中直接訪問stack.c
中定義的變數top
,則可以用extern
聲明它:
/* main.c */ #include <stdio.h> void push(char); char pop(void); int is_empty(void); extern int top; int main(void) { push('a'); push('b'); push('c'); printf("%d\n", top); while(!is_empty()) putchar(pop()); putchar('\n'); printf("%d\n", top); return 0; }
變數top
具有External Linkage,它的存儲空間是在stack.c
中分配的,所以main.c
中的變數聲明extern int top;
不是變數定義,因為它不分配存儲空間。以上函數和變數聲明也可以寫在main
函數體裡面,使所聲明的標識符具有塊作用域:
int main(void) { void push(char); char pop(void); int is_empty(void); extern int top; push('a'); push('b'); push('c'); printf("%d\n", top); while(!is_empty()) putchar(pop()); putchar('\n'); printf("%d\n", top); return 0; }
注意,變數聲明和函數聲明有一點不同,函數聲明的extern
可寫可不寫,而變數聲明如果不寫extern
意思就完全變了,如果上面的例子不寫extern
就表示在main
函數中定義一個局部變數top
。另外要注意,stack.c
中的定義是int top = -1;
,而main.c
中的聲明不能加Initializer,如果上面的例子寫成extern int top = -1;
則編譯器會報錯。
在main.c
中可以通過變數聲明來訪問stack.c
中的變數top
,但是從實現stack.c
這個模組的角度來看,top
這個變數是不希望被外界訪問到的,變數top
和stack
都屬於這個模組的內部狀態,外界應該只允許通過push
和pop
函數來改變模組的內部狀態,這樣才能保證堆棧的LIFO特性,如果外界可以隨機訪問stack
或者隨便修改top
,那麼堆棧的狀態就亂了。那怎麼才能阻止外界訪問top
和stack
呢?答案就是用static
關鍵字把它們聲明為Internal Linkage的:
/* stack.c */ static char stack[512]; static int top = -1; void push(char c) { stack[++top] = c; } char pop(void) { return stack[top--]; } int is_empty(void) { return top == -1; }
這樣,即使在main.c
中用extern
聲明也訪問不到stack.c
的變數top
和stack
。從而保護了stack.c
模組的內部狀態,這也是一種封裝(Encapsulation)的思想。
用static
關鍵字聲明具有Internal Linkage的函數也是出於這個目的。在一個模組中,有些函數是提供給外界使用的,也稱為導出(Export)給外界使用,這些函數聲明為External Linkage的。有些函數隻在模組內部使用而不希望被外界訪問到,則聲明為Internal Linkage的。
我們繼續前面關於stack.c
和main.c
的討論。stack.c
這個模組封裝了top
和stack
兩個變數,導出了push
、pop
、is_empty
三個函數介面,已經設計得比較完善了。但是使用這個模組的每個程序檔案都要寫三個函數聲明也是很麻煩的,假設又有一個foo.c
也使用這個模組,main.c
和foo.c
中各自要寫三個函數聲明。重複的代碼總是應該儘量避免的,以前我們通過各種辦法把重複的代碼提取出來,比如在第 2 節 “數組應用實例:統計隨機數”講過用宏定義避免硬編碼的問題,這次有什麼辦法呢?答案就是可以自己寫一個標頭檔stack.h
:
/* stack.h */ #ifndef STACK_H #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #endif
這樣在main.c
中只需包含這個標頭檔就可以了,而不需要寫三個函數聲明:
/* main.c */ #include <stdio.h> #include "stack.h" int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; }
首先說為什麼#include <stdio.h>
用角括號,而#include "stack.h"
用引號。對於用角括號包含的標頭檔,gcc
首先查找-I
選項指定的目錄,然後查找系統的標頭檔目錄(通常是/usr/include
,在我的系統上還包括/usr/lib/gcc/i486-linux-gnu/4.3.2/include
);而對於用引號包含的標頭檔,gcc
首先查找包含標頭檔的.c
檔案所在的目錄,然後查找-I
選項指定的目錄,然後查找系統的標頭檔目錄。
假如三個代碼檔案都放在當前目錄下:
$ tree . |-- main.c |-- stack.c `-- stack.h 0 directories, 3 files
則可以用gcc -c main.c
編譯,gcc
會自動在main.c
所在的目錄中找到stack.h
。假如把stack.h
移到一個子目錄下:
$ tree . |-- main.c `-- stack |-- stack.c `-- stack.h 1 directory, 3 files
則需要用gcc -c main.c -Istack
編譯。用-I
選項告訴gcc
標頭檔要到子目錄stack
裡找。
在#include
預處理指示中可以使用相對路徑,例如把上面的代碼改成#include "stack/stack.h"
,那麼編譯時就不需要加-Istack
選項了,因為gcc
會自動在main.c
所在的目錄中查找,而標頭檔相對於main.c
所在目錄的相對路徑正是stack/stack.h
。
在stack.h
中我們又看到兩個新的預處理指示#ifndef STACK_H
和#endif
,意思是說,如果STACK_H
這個宏沒有定義過,那麼從#ifndef
到#endif
之間的代碼就包含在預處理的輸出結果中,否則這一段代碼就不出現在預處理的輸出結果中。stack.h
這個標頭檔的內容整個被#ifndef
和#endif
括起來了,如果在包含這個標頭檔時STACK_H
這個宏已經定義過了,則相當於這個標頭檔裡什麼都沒有,包含了一個空檔案。這有什麼用呢?假如main.c
包含了兩次stack.h
:
... #include "stack.h" #include "stack.h" int main(void) { ...
則第一次包含stack.h
時並沒有定義STACK_H
這個宏,因此標頭檔的內容包含在預處理的輸出結果中:
... #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #include "stack.h" int main(void) { ...
其中已經定義了STACK_H
這個宏,因此第二次再包含stack.h
就相當於包含了一個空檔案,這就避免了標頭檔的內容被重複包含。這種保護標頭檔的寫法稱為Header Guard,以後我們每寫一個標頭檔都要加上Header Guard,宏定義名就用標頭檔名的大寫形式,這是規範的做法。
那為什麼需要防止重複包含呢?誰會把一個標頭檔包含兩次呢?像上面那麼明顯的錯誤沒人會犯,但有時候重複包含的錯誤並不是那麼明顯的。比如:
#include "stack.h" #include "foo.h"
然而foo.h
裡又包含了bar.h
,bar.h
裡又包含了stack.h
。在規模較大的項目中標頭檔包含標頭檔的情況很常見,經常會包含四五層,這時候重複包含的問題就很難發現了。比如在我的系統標頭檔目錄/usr/include
中,errno.h
包含了bits/errno.h
,後者又包含了linux/errno.h
,後者又包含了asm/errno.h
,後者又包含了asm-generic/errno.h
。
另外一個問題是,就算我是重複包含了標頭檔,那有什麼危害麼?像上面的三個函數聲明,在程序中聲明兩次也沒有問題,對於具有External Linkage的函數,聲明任意多次也都代表同一個函數。重複包含標頭檔有以下問題:
一是使預處理的速度變慢了,要處理很多本來不需要處理的標頭檔。
二是如果有foo.h
包含bar.h
,bar.h
又包含foo.h
的情況,預處理器就陷入死循環了(其實編譯器都會規定一個包含層數的上限)。
三是標頭檔裡有些代碼不允許重複出現,雖然變數和函數允許多次聲明(只要不是多次定義就行),但標頭檔裡有些代碼是不允許多次出現的,比如typedef
類型定義和結構體Tag定義等,在一個程序檔案中只允許出現一次。
還有一個問題,既然要#include
標頭檔,那我不如直接在main.c
中#include "stack.c"
得了。這樣把stack.c
和main.c
合併為同一個程序檔案,相當於又回到最初的例 12.1 “用堆棧實現倒序打印”了。當然這樣也能編譯通過,但是在一個規模較大的項目中不能這麼做,假如又有一個foo.c
也要使用stack.c
這個模組怎麼辦呢?如果在foo.c
裡面也#include "stack.c"
,就相當於push
、pop
、is_empty
這三個函數在main.c
和foo.c
中都有定義,那麼main.c
和foo.c
就不能連結在一起了。如果採用包含標頭檔的辦法,那麼這三個函數隻在stack.c
中定義了一次,最後可以把main.c
、stack.c
、foo.c
連結在一起。如下圖所示:
同樣道理,標頭檔中的變數和函數聲明一定不能是定義。如果標頭檔中出現變數或函數定義,這個標頭檔又被多個.c
檔案包含,那麼這些.c
檔案就不能連結在一起了。
以上兩節關於定義和聲明只介紹了最基本的規則,在寫代碼時掌握這些基本規則就夠用了,但其實C語言關於定義和聲明還有很多複雜的規則,在分析錯誤原因或者維護規模較大的項目時需要瞭解這些規則。本節的兩個表格出自[Standard C]。
首先看關於函數聲明的規則。
表 20.1. Storage Class關鍵字對函數聲明的作用
Storage Class | File Scope Declaration | Block Scope Declaration |
---|---|---|
none | previous linkage | previous linkage |
extern | previous linkage | previous linkage |
static | internal linkage | N/A |
以前我們說“extern
關鍵字表示這個標識符具有External Linkage”其實是不准確的,準確地說應該是Previous Linkage。Previous Linkage的定義是:這次聲明的標識符具有什麼樣的Linkage取決於前一次聲明,這前一次聲明具有相同的標識符名,而且必須是檔案作用域的聲明,如果在程序檔案中找不到前一次聲明(這次聲明是第一次聲明),那麼這個標識符具有External Linkage。例如在一個程序檔案中在檔案作用域兩次聲明同一個函數:
static int f(void); /* internal linkage */ extern int f(void); /* previous linkage */
則這裡的extern
修飾的標識符具有Interanl Linkage而不是External Linkage。從上表的前兩行可以總結出我們先前所說的規則“函數聲明加不加extern
關鍵字都一樣”。上表也說明了在檔案作用域允許定義函數,在塊作用域不允許定義函數,或者說函數定義不能嵌套。另外,在塊作用域中不允許用static
關鍵字聲明函數。
關於變數聲明的規則要複雜一些:
表 20.2. Storage Class關鍵字對變數聲明的作用
Storage Class | File Scope Declaration | Block Scope Declaration |
---|---|---|
none | external linkage | no linkage |
extern | previous linkage | previous linkage |
static | internal linkage | no linkage |
上表的每個單元格里分成四行,分別描述變數的連結屬性、生存期,以及這種變數如何初始化,是否算變數定義。連結屬性有External Linkage、Internal Linkage、No Linkage和Previous Linkage四種情況,生存期有Static Duration和Automatic Duration兩種情況,請參考本章和上一章的定義。初始化有Static Initializer和Dynamic Initializer兩種情況,前者表示Initializer中只能使用常量表達式,表達式的值必須在編譯時就能確定,後者表示Initializer中可以使用任意的右值表達式,表達式的值可以在運行時計算。是否算變數定義有三種情況,Definition(算變數定義)、Not a Definition(不算變數定義)和Tentative Definition(暫定的變數定義)。什麼叫“暫定的變數定義”呢?一個變數聲明具有檔案作用域,沒有Storage Class關鍵字修飾,或者用static
關鍵字修飾,那麼如果它有Initializer則編譯器認為它就是一個變數定義,如果它沒有Initializer則編譯器暫定它是變數定義,如果程序檔案中有這個變數的明確定義就用明確定義,如果程序檔案沒有這個變數的明確定義,就用這個暫定的變數定義[32],這種情況下變數以0初始化。在[C99]中有一個例子:
int i1 = 1; // definition, external linkage static int i2 = 2; // definition, internal linkage extern int i3 = 3; // definition, external linkage int i4; // tentative definition, external linkage static int i5; // tentative definition, internal linkage int i1; // valid tentative definition, refers to previous int i2; // 6.2.2 renders undefined, linkage disagreement int i3; // valid tentative definition, refers to previous int i4; // valid tentative definition, refers to previous int i5; // 6.2.2 renders undefined, linkage disagreement extern int i1; // refers to previous, whose linkage is external extern int i2; // refers to previous, whose linkage is internal extern int i3; // refers to previous, whose linkage is external extern int i4; // refers to previous, whose linkage is external extern int i5; // refers to previous, whose linkage is internal
變數i2
和i5
第一次聲明為Internal Linkage,第二次又聲明為External Linkage,這是不允許的,編譯器會報錯。注意上表中標有[*]
的單元格,對於檔案作用域的extern
變數聲明,C99是允許帶Initializer的,並且認為它是一個定義,但是gcc
對於這種寫法會報警告,為了兼容性應避免這種寫法。
[32] 由於本書沒有提及將不完全類型進行組合的問題,所以這條規則被我簡化了,真正的規則還要複雜一些。讀者可以參考C99中有關Incomplete Type和Composite Type的條款。Tentative Definition的完整定義在C99的6.9.2節條款2。