程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 變量和函數的定義和聲明

變量和函數的定義和聲明

編輯:C++入門知識

2. 定義和聲明
2.1. extern和static關鍵字
在上一節我們把兩個程序文件放在一起編譯鏈接,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的。

2.2. 頭文件
我們繼續前面關於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鏈接在一起。如下圖所示:

圖 20.2. 為什麼要包含頭文件而不是.c文件

 

為什麼要包含頭文件而不是.c文件



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

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

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

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

Storage Class File Scope Declaration Block 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 Class File Scope Declaration Block 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變量i2和i5第一次聲明為Internal Linkage,第二次又聲明為External Linkage,這是不允許的,編譯器會報錯。注意上表中標有[*]的單元格,對於文件作用域的extern變量聲明,C99是允許帶Initializer的,並且認為它是一個定義,但是gcc對於這種寫法會報警告,為了兼容性應避免這種寫法。

 

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved