C語言的變數作用域及標頭檔案
關於C語言的變數作用域和標頭檔案的問題都是比較基礎的問題,但是這些問題在實際使用過程中的概念不清和混亂會對一個多檔案的專案的組織結構及檔案結構造成很大的影響,使得專案本身的脈絡也變的很模糊。在專案中,多人相互協作完成的專案,這個問題就更加突出。所以也就有了我寫(總結)這個文件。
一.C 語言的變數作用域及相關
1.作用域:
作用域描述了程式中可以訪問一個識別符號的一個或多個區域。即變數的可見性。一個C變數的作用域可以是程式碼塊作用域、函式原型作用域,和檔案作用域。
函式作用域(Function Scope),識別符號在整個函式中都有效。只有語句標號屬於函式作用域。標號在函式中不需要先宣告後使用,在前面用一個goto語句也可以跳轉到後面的某個標號,但僅限於同一個函式之中。
檔案作用域(File Scope),識別符號從它宣告的位置開始直到這個程式檔案的末尾都有效。例如下例中main函式外面的sum,add,還有main也算,printf其實是在stdio.h中宣告的,被包含到這個程式檔案中了,所以也算檔案作用域的。
塊作用域(Block Scope),識別符號位於一對{}括號中(函式體或語句塊),從它宣告的位置開始到右}括號之間有效。例如上例中main函式裡的num。此外,函式定義中的形參也算塊作用域的,從宣告的位置開始到函式末尾之間有效。
函式原型作用域(Function Prototype Scope),識別符號出現在函式原型中,這個函式原型只是一個宣告而不是定義(沒有函式體),那麼識別符號從宣告的位置開始到在這個原型末尾之間有效。例如void add(int num);中的num。
下面再介紹另一種分類形式:它分為程式碼塊作用域和檔案作用域。程式碼塊作用域和檔案作用域也有另一種分類方法,區域性作用域和全域性作用域。
程式碼塊作用域:程式碼塊是指一對花括號之間的程式碼,函式的形參雖然是在花括號前定義但也屬於程式碼作用域。在C99中把程式碼塊的概念擴大到包括由for迴圈、while迴圈、do while迴圈、if語句所控制的程式碼。在程式碼塊作用域中,從該變數被定義到程式碼塊末尾該變數都可見。 檔案作用域:一個在所有函式之外定義的變數具有檔案作用域。具有檔案作用域的變數從它的定義處到包含該定義的檔案結尾都是可見的。
2.連結
一個C語言變數具有下列連結之一:外部連結(external linkage),內部連結(internal linkage)或空連結(no linkage)。
空連結:具有程式碼塊作用域或者函式原型作用域的變數就具有空連結,這意味著他們是由其定義所在的程式碼塊或函式原型所私有。 內部連結:具有檔案作用域的變數可能有內部或外部連結,一個具有檔案作用域的變數前使用了static識別符號標識時,即為具有內部連結的變數。一個具有內部連結的變數可以在一個檔案的任何地方使用。 外部連結:一個具有檔案作用域的變數預設是具有外部連結的。但當起前面用static標識後即轉變為內部連結。一個具有外部連結的連結的變數可以在一個多檔案程式的任何地方使用。
例:
static int a;(在所有函式外定義)內部連結變數 int b; (在所有函式外定義) 外部連結變數 main() {
int b;//空連結,僅為main函式私有。
..
}
3.儲存時期
一個C語言變數有以下兩種儲存時期之一:(未包括動態記憶體分配malloc和free等) 靜態儲存時期(static storage duration)和自動儲存時期(automatic storage duration)和動態儲存時期。 靜態儲存時期:如果一個變數具有靜態儲存時期,他在程式執行期間將一直存在。具有檔案作用域的變數具有靜態儲存時期。這裡注意一點:對於具有檔案作用域的變數,關鍵詞static表明連結型別,而不是儲存時期。一個使用了static宣告瞭的檔案作用域的變數具有內部連結,而所有的檔案作用域變數,無論他具有內部連結,是具有外部連結,都具有靜態儲存時期。
自動儲存時期:具有程式碼塊作用域的變數一般情況下具有自動儲存時期。在程式進入定義這些變數的程式碼塊時,將為這些變數分配記憶體,當退出這個程式碼塊時,分配的記憶體將被釋放。
舉例如下:
//example_1.c
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
#include <stdio.h> #include <stdlib.h> void add(int num);//檔案作用域,外部連結, void chang_sum();//檔案作用域,外部連結 int sum=1; //檔案作用域 外部連結,靜態儲存時期 int main(int argc, char *argv[]) { int num = 5;//函式作用域,空連結 add(num); printf("main num=%d/n",num); /*輸出5*/ //內層定義覆蓋原則,當內層程式碼塊定義了一個與外層程式碼塊相同時名字的變數時, //執行到內層程式碼塊時,使用內層定義的變數,離開內層程式碼塊時,外層變數恢復 //此時sum為for中定義的sum,而不是全域性的sum for(int sum=0, num=0;num<5;num++)//程式碼塊作用域,空連結,自動儲存時期 { sum+=num; printf("====================/n"); printf("for num=%d/n",num);//輸出0-5 printf("for sum=%d/n",sum);//輸出0-5的疊加和 } printf("====================/n"); { int i;//程式碼作用域。僅在該大括號內可見。空連結,自動儲存時期 for(i=0;i<10;i++); printf("i=%d/n",i); } // printf("i=%d/n",i);//編譯通不過 printf("main sum=%d/n",sum);//輸出0。 printf("main num=%d/n",num);// 輸出5 chang_sum(); printf("file sum=%d/n",sum);//輸出1。全域性的sum。內層定義覆蓋原則。 system("PAUSE"); return 0; } void add(int num)//程式碼作用域 { num++; printf("add num= %d/n",num); /*輸出6*/ } void chang_sum() { sum++; printf("chang_sum = %d/n",sum); /*輸出1*/ } |
以上示例須在在C99標準下編譯。(gcc支援c99的方法,編譯時加入引數 –std=C99)。從上例中可以比較清楚明白程式碼作用域和檔案作用域的概念。另外注意檔案作用域不僅限於變數也包括函式。在檔案作用域中函式也是以其宣告開始到檔案結尾結束。而且當擁有檔案作用域與擁有程式碼作用域變數同名時,不會發生衝突,而是以最小作用域的變數可見。
4.儲存類修飾符(Storage Class Specifier)
有以下幾種關鍵字,可以修飾變數或函式宣告:
static,用它修飾的變數的儲存空間是靜態分配的,用它修飾的檔案作用域的變數或函式具有Internal Linkage(內部連結)。 auto,用它修飾的變數在函式呼叫時自動在棧上分配儲存空間,函式返回時自動釋放,例如上例中main函式裡的num其實就是用auto修飾的,只不過auto可以省略不寫(此處與編譯器有關,參照編譯器不同而有所變動),auto不能修飾檔案作用域的變數。
register,編譯器對於用register修飾的變數會盡可能分配一個專門的暫存器來儲存,但如果實在分配不開暫存器,編譯器就把它當auto變數處理了,register不能修飾檔案作用域的變數。現在一般編譯器的優化都做得很好了,它自己會想辦法有效地利用CPU的暫存器,所以現在register關鍵字也用得比較少了。 extern,上面講過,連結屬性是根據一個識別符號多次宣告時是不是代表同一個變數或函式來分類的,extern關鍵字就用於多次宣告同一個識別符號。
c語言使用作用域,連結和儲存時期來定義了5種儲存類:自動,暫存器,具有程式碼塊的作用域的靜態、具有外部連結的靜態,以及具有內部連結的靜態。
五種儲存類
儲存類 | 時期 | 作用域 | 連結 | 宣告方式 | ||||
自動 | 自動 | 程式碼塊 | 空 | 程式碼塊內 | ||||
暫存器 | 自動 | 程式碼塊 | 空 | 程式碼塊內,使用register | ||||
具有外部連結的靜態 | 靜態 | 檔案之間 | 外部 | 所有函式之外 | ||||
具有內部連結的靜態 | 靜態 | 檔案之內 | 內部 | 所有函式之外使用關鍵字static | ||||
空連結的靜態 | 靜態 | 程式碼塊 | 空 | 程式碼塊內,使用關鍵字static |
二.標頭檔案的處理和書寫
很多人對C語言中的 “檔案包含”都不陌生了,檔案包含處理在程式開發中會給我們的模組化程式設計帶來很大的好處,通過檔案包含的方法把程式中的各個功能模組聯絡起來是模組化程式設計中的一種非常有利的手段。 標頭檔案的功能:
(1)通過標頭檔案來呼叫庫功能。在很多場合,原始碼不便(或不準)向使用者公佈,只要向使用者提供標頭檔案和二進位制的庫即可。使用者只需要按照標頭檔案中的介面宣告來呼叫庫功能,而不必關心介面怎麼實現的。編譯器會從庫中提取相應的程式碼。
(2)標頭檔案能加強型別安全檢查。如果某個介面被實現或被使用時,其方式與標頭檔案中的宣告不一致,編譯器就會指出錯誤,這一簡單的規則能大大減輕程式設計師除錯、改錯的負擔。
檔案包含處理是指在一個原始檔中,通過檔案包含命令將另一個原始檔的內容全部包含在此檔案中。在原始檔編譯時,連同被包含進來的檔案一同編譯,生成目標目標檔案。怎麼寫檔案件? 怎麼包含才能避免重定義? 其實這個只要瞭解了檔案包含的基本處理方法就可以對檔案包含有一個很好的理解與應用了: 檔案包含的處理方法:
(1) 處理時間:檔案包含也是以”#”開頭來寫的(#include ), 那麼它就是寫給前處理器來看了, 也就是說檔案包含是會在編譯預處理階段進行處理的。
(2) 處理方法:在預處理階段,系統自動對#include命令進行處理,具體做法是:降包含檔案的內容複製到包含語句(#include )處,得到新的檔案,然後再對這個新的檔案進行編譯。
抓住這兩點,那麼這個就沒有什麼難的了。。。
首先,先對#include指令的作用和實際驗證一下。 #include指令是預處理指令,它僅僅是將#incluce “A.h”中的A.h的內容替換它自己所在位置,和C語言中的巨集的使用類似。而且A.h並不是說必須是.h的檔案,只要是文字檔案都可以的。下面我給出兩個例子證明一下。
例1:有以下兩個檔案,main.c和main.n
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
//file1 main.c #include<stdio.h> #include<stdlib.h> #include "main.n"//包含了main.n的文字檔案。 int main() { n = 2; printf("n=%d/n",n); return 1; } //file2 main.n int n; |
這時我們對main.c進行編譯 gcc main.c -o main.exe(我在windows系統下),你會發現能編譯通過並列印出n的值。如果你使用預編譯引數-E,會在預編譯後的檔案中發現其原因所在。使用 gcc -E main.c -o main.cpp。開啟main.cpp後在檔案最後會有如下內容。
1 2 3 4 5 6 7 8 9 10 11 12 |
# 3 "main.c" 2 # 1 "main.n" 1 int n; # 5 "main.c" 2 int main() { printf("n=%d/n",n); system("pause"); return 1; } |
以上的示例應該能比較明顯解釋#include的作用,和使用方法了。但是在實際開發中,這種使用方式是嚴重的不規範行為,強烈建議不要使用。同樣下邊的例子也是一樣的建議。
例2:
(1)包含.c檔案:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
//file1: main.c #include <stdio.h> #include <stdlib.h> #include "test.c" int main(int argc, char *argv[]) { m=5; for(int i=0;i<5;i++) { add(); m++; test(); } system("PAUSE"); return 0; } 12: //end of file1 //file2:test.c static int n; int m; int add(); void test() { int t_sum; printf("m = %d/n",m); printf("n = %d/n",n++); t_sum = add(); printf("add = %d/n",t_sum); } int add() { static int sum; sum++; return sum; } //end of file2 |
這個例子是採用 包含.c檔案 的方法實現的。 在編譯時,直接去編譯main.c檔案,前處理器會先把test.c檔案中的內容複製到main.c中來,然後再對新的main.c進行編譯。
編譯命令:
- gcc main.c -o main
可以看到,這裡並沒有對test.c進行編譯,但還是生成了最終的main可執行程式。
也可以通過命令來觀察一下預處理的結果, 編譯命令:
- gcc -E main.c -o main.cpp(僅預處理)
在main.cpp檔案末尾可以看來下面一段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
# 3 "main.c" 2 # 1 "test.c" 1 static int n;//此處是test.c的內容 int m; int add(); void test() { int t_sum; printf("m = %d/n",m); printf("n = %d/n",n++); t_sum = add(); printf("add = %d/n",t_sum); } int add() { static int sum; sum++; return sum; } # 4 "main.c" 2//此處是main.c的內容 int main(int argc, char *argv[]) { m=5; for(int i=0;i<5;i++) { add(); m++; test(); } |
可見,其實就是將test.c檔案中的內容新增到了main函式之前,然後對新的檔案進行編譯,生成最終的可執行程式。
這次如果還是按照上面的方法只編譯main.c的話就會出錯,因為變數m和函式add並沒有在main.c中定義,所以編譯時需要將test.c一起編譯,
編譯命令:
- gcc -c main.c -o main.o #編譯main.c
- gcc -c fun.c -o fun.o #編譯fun.c
- gcc main.o fun.o -o main #用main.o fun.o生成main
到這裡大家應該已經理解包含#include檔案和多檔案程式的本質區別了。
包含檔案僅僅是在c預編譯時進行再次整合,最終的還是合併成一個檔案編譯,生成執行檔案。
而多檔案的編譯,是多個檔案分別編譯,(也可能是在編譯時新增必須的標識),然後通過連結器將各個檔案連結後載入形成可執行檔案。
這種方式會使得我們的定義和宣告分開,不容易產生重定義。而且也利於模組化,僅通過標頭檔案來給出介面,而隱藏具體的實現。
預處理時會把標頭檔案中的內容複製到包含它的檔案中去,而複製的這些內容只是聲名,不是定義,所以它被複制再多份也不會出現”重定義”的錯誤。。。
前面說了標頭檔案的方法也是模組化程式設計中的一種非常有利的手段。把同一類功能寫到一個.c檔案中,這樣可以把他們劃為一個模組,另外再對應的寫上一
個.h檔案做它的宣告。這樣以後再使用這個模組時只需要把這兩個檔案新增進工程,同時在要使用模組內函式或變數的檔案中包含.h檔案就可以了。
舉個很實際的例子,在微控制器、ARM或其他嵌入式開發中,每一個平臺可能本身都有多種不同的硬體模組,使用時需要去寫相應的驅動程式,
這樣就可以把各個硬 件模組的驅動程式作為一個模組(比如lcd驅動對對應lcd.c和lcd.h,IIC驅動對應I2C.c和I2C.h等),當具體使用到某個模組時,
只需 要在將對應的.c和.h檔案新增進工程,並在檔案中包含對就的.h檔案即可。
根據以上的原理理解和實際中使用的一些問題及模組化的原則,對標頭檔案寫法給出以下幾點個人建議作為基礎:
(1) 按相同功能或相關性組織.c和.h檔案,同一檔案內的聚合度要高,不同檔案中的耦合度要低。介面通過.h檔案給出。
(2) 對應的.c檔案中寫變數、函式的定義,並指定連結範圍。對於變數和函式的定義時,僅本檔案使用的變數和函式,要用static限定為內部連結防止外部呼叫。
(3) 對應的.h檔案中寫變數、函式的宣告。僅宣告外部需要的函式,和必須給出變數。有時可以通過使用設定和修改變數函式宣告,來減少變數外部宣告。
(4) 如果有資料型別的宣告 和 巨集定義 ,請寫的標頭檔案(.h)中,這時也要注意模組化問題,如果資料型別僅本檔案使用則不必在寫標頭檔案中,而寫在原始檔(.c)中,會提高聚合度。減少不必要的格式外漏。
(5) 標頭檔案中一定加上#ifndef…#define….#endif之類的防止重包含的語句
(6) 標頭檔案中不要包含其他的標頭檔案,標頭檔案的互相包含使的程式組織結構和檔案組織變得混亂,同時給會造成潛在的錯誤,同時給錯誤查詢造成麻煩。如果出現,標頭檔案中型別定義需要其他標頭檔案時,將其提出來,單獨形成全域性的一個原始檔和標頭檔案。
(7)模組的.c檔案中別忘包含自己的.h檔案
以上幾點僅是個人觀點,供大家討論,如果有意見或是認為不合理或是有更合理的方式請討論指出。
補充1:
按照c語言的規則,變數和函式必須是先宣告再使用。可以多次宣告,但不可以多次定義。
補充2:變數的定義和宣告。
“宣告”僅僅是告訴編譯器某個識別符號是:變數(什麼型別)還是函式(引數和返回值是什麼)。要是在後面的程式碼中出現該識別符號,編譯器就知道如何處理。記住最重要的一點:宣告變數不會導致編譯器為這個變數分配儲存空間。 C語言專門有一個關鍵字(keyword)用於宣告變數或函式:extern。帶有extern的語句出現時,編譯器將只是認為你要告訴它某個識別符號是什麼,除此之外什麼也不會做(直接變數初始化除外)。
編譯器在什麼情況下將語句認為是定義,什麼情況下認為是宣告。這裡給出若干原則:
#1 帶有初始化的語句是定義
例如:
int a = 1; //定義
#2 帶有extern的語句是宣告(除非對變數進行初始化)
例如:
extern int a; //宣告
extern int b = 2; //定義
#3既沒有初始化又沒有extern的語句是“暫時定義”(tentative definition) C語言中,外部變數只能被(正式)定義一次:
int a = 0; int a = 0; //錯誤!重複定義
又或者:
int a = 0; double a = 0.1; //錯誤!識別符號a已經被使用
暫時定義有點特殊,因為它是暫時的,我們不妨這樣看: 暫時定義可以出現無數次,如果在連結時系統全域性空間沒有相同名字的變數定義,則暫時定義“自動升級”為(正式的)定義,這時系統會為暫時定義的變數分配儲存空間,此後,這些相同的暫時定義(加起來)仍然只算作是一個(正式)定義。
例如:
1 2 3 4 5 6 7 8 |
/*Example C code*/ int a; //暫時定義 int a; //暫時定義 int main(void) { a = 1; return 0; } int a; //暫時定義 |