2. 定義和聲明

2.1. externstatic關鍵字

在上一節我們把兩個程序檔案放在一起編譯連結,main.c用到的函數pushpopis_emptystack.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.cstack.c連結在一起,如果pushmain.cstack.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函數,連結器卻找不到它的定義在哪兒,無法確定它的地址,也就無法做符號解析,只好報錯。凡是被多次聲明的變數或函數,必須有且只有一個聲明是定義,如果有多個定義,或者一個定義都沒有,連結器就無法完成連結。

以上講了用staticextern修飾函數聲明的情況。現在來看用它們修飾變數聲明的情況。仍然用stack.cmain.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這個變數是不希望被外界訪問到的,變數topstack都屬於這個模組的內部狀態,外界應該只允許通過pushpop函數來改變模組的內部狀態,這樣才能保證堆棧的LIFO特性,如果外界可以隨機訪問stack或者隨便修改top,那麼堆棧的狀態就亂了。那怎麼才能阻止外界訪問topstack呢?答案就是用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的變數topstack。從而保護了stack.c模組的內部狀態,這也是一種封裝(Encapsulation)的思想。

static關鍵字聲明具有Internal Linkage的函數也是出於這個目的。在一個模組中,有些函數是提供給外界使用的,也稱為導出(Export)給外界使用,這些函數聲明為External Linkage的。有些函數隻在模組內部使用而不希望被外界訪問到,則聲明為Internal Linkage的。

2.2. 標頭檔

我們繼續前面關於stack.cmain.c的討論。stack.c這個模組封裝了topstack兩個變數,導出了pushpopis_empty三個函數介面,已經設計得比較完善了。但是使用這個模組的每個程序檔案都要寫三個函數聲明也是很麻煩的,假設又有一個foo.c也使用這個模組,main.cfoo.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.hbar.h裡又包含了stack.h。在規模較大的項目中標頭檔包含標頭檔的情況很常見,經常會包含四五層,這時候重複包含的問題就很難發現了。比如在我的系統標頭檔目錄/usr/include中,errno.h包含了bits/errno.h,後者又包含了linux/errno.h,後者又包含了asm/errno.h,後者又包含了asm-generic/errno.h

另外一個問題是,就算我是重複包含了標頭檔,那有什麼危害麼?像上面的三個函數聲明,在程序中聲明兩次也沒有問題,對於具有External Linkage的函數,聲明任意多次也都代表同一個函數。重複包含標頭檔有以下問題:

  1. 一是使預處理的速度變慢了,要處理很多本來不需要處理的標頭檔。

  2. 二是如果有foo.h包含bar.hbar.h又包含foo.h的情況,預處理器就陷入死循環了(其實編譯器都會規定一個包含層數的上限)。

  3. 三是標頭檔裡有些代碼不允許重複出現,雖然變數和函數允許多次聲明(只要不是多次定義就行),但標頭檔裡有些代碼是不允許多次出現的,比如typedef類型定義和結構體Tag定義等,在一個程序檔案中只允許出現一次。

還有一個問題,既然要#include標頭檔,那我不如直接在main.c#include "stack.c"得了。這樣把stack.cmain.c合併為同一個程序檔案,相當於又回到最初的例 12.1 “用堆棧實現倒序打印”了。當然這樣也能編譯通過,但是在一個規模較大的項目中不能這麼做,假如又有一個foo.c也要使用stack.c這個模組怎麼辦呢?如果在foo.c裡面也#include "stack.c",就相當於pushpopis_empty這三個函數在main.cfoo.c中都有定義,那麼main.cfoo.c就不能連結在一起了。如果採用包含標頭檔的辦法,那麼這三個函數隻在stack.c中定義了一次,最後可以把main.cstack.cfoo.c連結在一起。如下圖所示:

圖 20.2. 為什麼要包含標頭檔而不是.c檔案

為什麼要包含標頭檔而不是.c檔案

同樣道理,標頭檔中的變數和函數聲明一定不能是定義。如果標頭檔中出現變數或函數定義,這個標頭檔又被多個.c檔案包含,那麼這些.c檔案就不能連結在一起了。

2.3. 定義和聲明的詳細規則

以上兩節關於定義和聲明只介紹了最基本的規則,在寫代碼時掌握這些基本規則就夠用了,但其實C語言關於定義和聲明還有很多複雜的規則,在分析錯誤原因或者維護規模較大的項目時需要瞭解這些規則。本節的兩個表格出自[Standard C]

首先看關於函數聲明的規則。

表 20.1. Storage Class關鍵字對函數聲明的作用

Storage ClassFile Scope DeclarationBlock Scope Declaration
none

previous linkage
can define

previous linkage
cannot define

extern

previous linkage
can define

previous linkage
cannot define

static

internal linkage
can define

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 ClassFile Scope DeclarationBlock Scope Declaration
none

external linkage
static duration
static initializer
tentative definition

no linkage
automatic duration
dynamic initializer
definition

extern

previous linkage
static duration
no initializer[*]
not a definition

previous linkage
static duration
no initializer
not a definition

static

internal linkage
static duration
static initializer
tentative definition

no linkage
static duration
static initializer
definition


上表的每個單元格里分成四行,分別描述變數的連結屬性、生存期,以及這種變數如何初始化,是否算變數定義。連結屬性有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

變數i2i5第一次聲明為Internal Linkage,第二次又聲明為External Linkage,這是不允許的,編譯器會報錯。注意上表中標有[*]的單元格,對於檔案作用域的extern變數聲明,C99是允許帶Initializer的,並且認為它是一個定義,但是gcc對於這種寫法會報警告,為了兼容性應避免這種寫法。



[32] 由於本書沒有提及將不完全類型進行組合的問題,所以這條規則被我簡化了,真正的規則還要複雜一些。讀者可以參考C99中有關Incomplete Type和Composite Type的條款。Tentative Definition的完整定義在C99的6.9.2節條款2。