# 第?3?章?簡單函數
**目錄**
+ [1\. 數學函數](ch03s01.html)
+ [2\. 自定義函數](ch03s02.html)
+ [3\. 形參和實參](ch03s03.html)
+ [4\. 全局變量、局部變量和作用域](ch03s04.html)
## 1.?數學函數
在數學中我們用過sin和ln這樣的函數,例如sin(π/2)=1,ln1=0等等,在C語言中也可以使用這些函數(ln函數在C標準庫中叫做`log`):
**例?3.1.?在C語言中使用數學函數**
```
#include <math.h>
#include <stdio.h>
int main(void)
{
double pi = 3.1416;
printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), log(1.0));
return 0;
}
```
編譯運行這個程序,結果如下:
```
$ gcc main.c -lm
$ ./a.out
sin(pi/2)=1.000000
ln1=0.000000
```
在數學中寫一個函數有時候可以省略括號,而C語言要求一定要加上括號,例如`log(1.0)`。在C語言的術語中,`1.0`是參數(Argument),`log`是函數(Function),`log(1.0)`是函數調用(Function Call)。`sin(pi/2)`和`log(1.0)`這兩個函數調用在我們的`printf`語句中處于什么位置呢?在上一章講過,這應該是寫表達式的位置。因此函數調用也是一種表達式,這個表達式由函數調用運算符(()括號)和兩個操作數組成,操作數`log`是一個函數名(Function Designator),它的類型是一種函數類型(Function Type),操作數`1.0`是`double`型的。`log(1.0)`這個表達式的值就是對數運算的結果,也是`double`型的,在C語言中函數調用表達式的值稱為函數的返回值(Return Value)。總結一下我們新學的語法規則:
表達式?→?函數名
表達式?→?表達式(參數列表)
參數列表?→?表達式,?表達式,?...
現在我們可以完全理解`printf`語句了:原來`printf`也是一個函數,上例中的`printf("sin(pi/2)=%f\nln1=%f\n", sin(pi/2), log(1.0))`是帶三個參數的函數調用,而函數調用也是一種表達式,因此`printf`語句也是表達式語句的一種。但是`printf`感覺不像一個數學函數,為什么呢?因為像`log`這種函數,我們傳進去一個參數會得到一個返回值,我們調用`log`函數就是為了得到它的返回值,至于`printf`,我們并不關心它的返回值(事實上它也有返回值,表示實際打印的字符數),我們調用`printf`不是為了得到它的返回值,而是為了利用它所產生的副作用(Side Effect)--打印。_C語言的函數可以有Side Effect,這一點是它和數學函數在概念上的根本區別_。
Side Effect這個概念也適用于運算符組成的表達式。比如`a + b`這個表達式也可以看成一個函數調用,把運算符`+`看作函數,它的兩個參數是`a`和`b`,返回值是兩個參數的和,傳入兩個參數,得到一個返回值,并沒有產生任何Side Effect。而賦值運算符是有Side Effect的,如果把`a = b`這個表達式看成函數調用,返回值就是所賦的值,既是`b`的值也是`a`的值,但除此之外還產生了Side Effect,就是變量`a`被改變了,改變計算機存儲單元里的數據或者做輸入輸出操作都算Side Effect。
回想一下我們的學習過程,一開始我們說賦值是一種語句,后來學了表達式,我們說賦值語句是表達式語句的一種;一開始我們說`printf`是一種語句,現在學了函數,我們又說`printf`也是表達式語句的一種。隨著我們一步步的學習,把原來看似不同類型的語句統一成一種語句了。學習的過程總是這樣,初學者一開始接觸的很多概念從嚴格意義上說是錯的,但是很容易理解,隨著一步步學習,在理解原有概念的基礎上不斷糾正,不斷泛化(Generalize)。比如一年級老師說小數不能減大數,其實這個概念是錯的,后來引入了負數就可以減了,后來引入了分數,原來的正數和負數的概念就泛化為整數,上初中學了無理數,原來的整數和分數的概念就泛化為有理數,再上高中學了復數,有理數和無理數的概念就泛化為實數。坦白說,到目前為止本書的很多說法都是不完全正確的,但這是學習理解的必經階段,到后面的章節都會逐步糾正的。
程序第一行的#號(Pound Sign,Number Sign或Hash Sign)和`include`表示包含一個頭文件(Header File),后面尖括號(Angel Bracket)中就是文件名(這些頭文件通常位于`/usr/include`目錄下)。頭文件中聲明了我們程序中使用的庫函數,根據先聲明后使用的原則,要使用`printf`函數必須包含`stdio.h`,要使用數學函數必須包含`math.h`,如果什么庫函數都不使用就不必包含任何頭文件,例如寫一個程序`int main(void){int a;a=2;return 0;}`,不需要包含頭文件就可以編譯通過,當然這個程序什么也做不了。
使用`math.h`中聲明的庫函數還有一點特殊之處,`gcc`命令行必須加`-lm`選項,因為數學函數位于`libm.so`庫文件中(這些庫文件通常位于`/lib`目錄下),`-lm`選項告訴編譯器,我們程序中用到的數學函數要到這個庫文件里找。本書用到的大部分庫函數(例如`printf`)位于`libc.so`庫文件中,使用`libc.so`中的庫函數在編譯時不需要加`-lc`選項,當然加了也不算錯,因為這個選項是`gcc`的默認選項。關于頭文件和庫函數目前理解這么多就可以了,到[第?20?章 _鏈接詳解_](ch20.html#link)再詳細解釋。
### C標準庫和glibc
C標準主要由兩部分組成,一部分描述C的語法,另一部分描述C標準庫。C標準庫定義了一組標準頭文件,每個頭文件中包含一些相關的函數、變量、類型聲明和宏定義。要在一個平臺上支持C語言,不僅要實現C編譯器,還要實現C標準庫,這樣的實現才算符合C標準。不符合C標準的實現也是存在的,例如很多單片機的C語言開發工具中只有C編譯器而沒有完整的C標準庫。
在Linux平臺上最廣泛使用的C函數庫是`glibc`,其中包括C標準庫的實現,也包括本書第三部分介紹的所有系統函數。幾乎所有C程序都要調用`glibc`的庫函數,所以`glibc`是Linux平臺C程序運行的基礎。`glibc`提供一組頭文件和一組庫文件,最基本、最常用的C標準庫函數和系統函數在`libc.so`庫文件中,幾乎所有C程序的運行都依賴于`libc.so`,有些做數學計算的C程序依賴于`libm.so`,以后我們還會看到多線程的C程序依賴于`libpthread.so`。以后我說`libc`時專指`libc.so`這個庫文件,而說`glibc`時指的是`glibc`提供的所有庫文件。
`glibc`并不是Linux平臺唯一的基礎C函數庫,也有人在開發別的C函數庫,比如適用于嵌入式系統的`uClibc`。
## 2.?自定義函數
我們不僅可以調用C標準庫提供的函數,也可以定義自己的函數,事實上我們已經這么做了:我們定義了`main`函數。例如:
```
int main(void)
{
int hour = 11;
int minute = 59;
printf("%d and %d hours\n", hour, minute / 60);
return 0;
}
```
`main`函數的特殊之處在于執行程序時它自動被操作系統調用,操作系統就認準了`main`這個名字,除了名字特殊之外,`main`函數和別的函數沒有區別。我們對照著`main`函數的定義來看語法規則:
函數定義?→?返回值類型?函數名(參數列表)?函數體
函數體?→?{?語句列表?}
語句列表?→?語句列表項?語句列表項?...
語句列表項?→?語句
語句列表項?→?變量聲明、類型聲明或非定義的函數聲明
非定義的函數聲明?→?返回值類型?函數名(參數列表);
我們稍后再詳細解釋“函數定義”和“非定義的函數聲明”的區別。從[第?7?章 _結構體_](ch07.html#struct)開始我們才會看到類型聲明,所以現在暫不討論。
給函數命名也要遵循上一章講過的標識符命名規則。由于我們定義的`main`函數不帶任何參數,參數列表應寫成`void`。函數體可以由若干條語句和聲明組成,C89要求所有聲明寫在所有語句之前(本書的示例代碼都遵循這一規定),而C99的新特性允許語句和聲明按任意順序排列,只要每個標識符都遵循先聲明后使用的原則就行。`main`函數的返回值是`int`型的,`return 0;`這個語句表示返回值是0,`main`函數的返回值是返回給操作系統看的,因為`main`函數是被操作系統調用的,通常程序執行成功就返回0,在執行過程中出錯就返回一個非零值。比如我們將`main`函數中的`return`語句改為`return 4;`再執行它,執行結束后可以在Shell中看到它的退出狀態(Exit Status):
```
$ ./a.out
11 and 0 hours
$ echo $?
4
```
`$?`是Shell中的一個特殊變量,表示上一條命令的退出狀態。關于`main`函數需要注意兩點:
1. [[K&R]](bi01.html#bibli.kr "The C Programming Language")書上的`main`函數定義寫成`main(){...}`的形式,不寫返回值類型也不寫參數列表,這是Old Style C的風格。Old Style C規定不寫返回值類型就表示返回`int`型,不寫參數列表就表示參數類型和個數沒有明確指出。這種寬松的規定使編譯器無法檢查程序中可能存在的Bug,增加了調試難度,不幸的是現在的C標準為了兼容舊的代碼仍然保留了這種語法,但讀者絕不應該繼續使用這種語法。
2. 其實操作系統在調用`main`函數時是傳參數的,`main`函數最標準的形式應該是`int main(int argc, char *argv[])`,在[第?6?節 “指向指針的指針與指針數組”](ch23s06.html#pointer.parray)詳細介紹。C標準也允許`int main(void)`這種寫法,如果不使用系統傳進來的兩個參數也可以寫成這種形式。但除了這兩種形式之外,定義`main`函數的其它寫法都是錯誤的或不可移植的。
關于返回值和`return`語句我們將在[第?1?節 “return語句”](ch05s01.html#func2.return)詳細討論,我們先從不帶參數也沒有返回值的函數開始學習定義和使用函數:
**例?3.2.?最簡單的自定義函數**
```
#include <stdio.h>
void newline(void)
{
printf("\n");
}
int main(void)
{
printf("First Line.\n");
newline();
printf("Second Line.\n");
return 0;
}
```
執行結果是:
```
First Line.
Second Line.
```
我們定義了一個`newline`函數給`main`函數調用,它的作用是打印一個換行,所以執行結果中間多了一個空行。`newline`函數不僅不帶參數,也沒有返回值,返回值類型為`void`表示沒有返回值<sup>[[4](#ftn.id2713286)]</sup>,這說明我們調用這個函數完全是為了利用它的Side Effect。如果我們想要多次插入空行就可以多次調用`newline`函數:
```
int main(void)
{
printf("First Line.\n");
newline();
newline();
newline();
printf("Second Line.\n");
return 0;
}
```
如果我們總需要三個三個地插入空行,我們可以再定義一個`threeline`函數每次插入三個空行:
**例?3.3.?較簡單的自定義函數**
```
#include <stdio.h>
void newline(void)
{
printf("\n");
}
void threeline(void)
{
newline();
newline();
newline();
}
int main(void)
{
printf("Three lines:\n");
threeline();
printf("Another three lines.\n");
threeline();
return 0;
}
```
通過這個簡單的例子可以體會到:
1. 同一個函數可以被多次調用。
2. 可以用一個函數調用另一個函數,后者再去調第三個函數。
3. 通過自定義函數可以給一組復雜的操作起一個簡單的名字,例如`threeline`。對于`main`函數來說,只需要通過`threeline`這個簡單的名字來調用就行了,不必知道打印三個空行具體怎么做,所有的復雜操作都被隱藏在`threeline`這個名字后面。
4. 使用自定義函數可以使代碼更簡潔,`main`函數在任何地方想打印三個空行只需調用一個簡單的`threeline()`,而不必每次都寫三個`printf("\n")`。
讀代碼和讀文章不一樣,按從上到下從左到右的順序讀代碼未必是最好的。比如上面的例子,按源文件的順序應該是先看`newline`再看`threeline`再看`main`。如果你換一個角度,按代碼的執行順序來讀也許會更好:首先執行的是`main`函數中的語句,在一條`printf`之后調用了`threeline`,這時再去看`threeline`的定義,其中又調用了`newline`,這時再去看`newline`的定義,`newline`里面有一條`printf`,執行完成后返回`threeline`,這里還剩下兩次`newline`調用,效果也都一樣,執行完之后返回`main`,接下來又是一條`printf`和一條`threeline`。如下圖所示:
**圖?3.1.?函數調用的執行順序**

讀代碼的過程就是模仿計算機執行程序的過程,我們不僅要記住當前讀到了哪一行代碼,還要記住現在讀的代碼是被哪個函數調用的,這段代碼返回后應該從上一個函數的什么地方接著往下讀。
現在澄清一下函數聲明、函數定義、函數原型(Prototype)這幾個概念。比如`void threeline(void)`這一行,聲明了一個函數的名字、參數類型和個數、返回值類型,這稱為函數原型。在代碼中可以單獨寫一個函數原型,后面加`;`號結束,而不寫函數體,例如:
```
void threeline(void);
```
這種寫法只能叫函數聲明而不能叫函數定義,只有帶函數體的聲明才叫定義。上一章講過,只有分配存儲空間的變量聲明才叫變量定義,其實函數也是一樣,編譯器只有見到函數定義才會生成指令,而指令在程序運行時當然也要占存儲空間。那么沒有函數體的函數聲明有什么用呢?它為編譯器提供了有用的信息,編譯器在翻譯代碼的過程中,只有見到函數原型(不管帶不帶函數體)之后才知道這個函數的名字、參數類型和返回值,這樣碰到函數調用時才知道怎么生成相應的指令,所以函數原型必須出現在函數調用之前,這也是遵循“先聲明后使用”的原則。
在上面的例子中,`main`調用`threeline`,`threeline`再調用`newline`,要保證每個函數的原型出現在調用之前,就只能按先`newline`再`threeline`再`main`的順序定義了。如果使用不帶函數體的聲明,則可以改變函數的定義順序:
```
#include <stdio.h>
void newline(void);
void threeline(void);
int main(void)
{
...
}
void newline(void)
{
...
}
void threeline(void)
{
...
}
```
這樣仍然遵循了先聲明后使用的原則。
由于有Old Style C語法的存在,并非所有函數聲明都包含完整的函數原型,例如`void threeline();`這個聲明并沒有明確指出參數類型和個數,所以不算函數原型,這個聲明提供給編譯器的信息只有函數名和返回值類型。如果在這樣的聲明之后調用函數,編譯器不知道參數的類型和個數,就不會做語法檢查,所以很容易引入Bug。讀者需要了解這個知識點以便維護別人用Old Style C風格寫的代碼,但絕不應該按這種風格寫新的代碼。
如果在調用函數之前沒有聲明會怎么樣呢?有的讀者也許碰到過這種情況,我可以解釋一下,但絕不推薦這種寫法。比如按上面的順序定義這三個函數,但是把開頭的兩行聲明去掉:
```
#include <stdio.h>
int main(void)
{
printf("Three lines:\n");
threeline();
printf("Another three lines.\n");
threeline();
return 0;
}
void newline(void)
{
printf("\n");
}
void threeline(void)
{
newline();
newline();
newline();
}
```
編譯時會報警告:
```
$ gcc main.c
main.c:17: warning: conflicting types for ‘threeline’
main.c:6: warning: previous implicit declaration of ‘threeline’ was here
```
但仍然能編譯通過,運行結果也對。這里涉及到的規則稱為函數的隱式聲明(Implicit Declaration),在`main`函數中調用`threeline`時并沒有聲明它,編譯器認為此處隱式聲明了`int threeline(void);`,隱式聲明的函數返回值類型都是`int`,由于我們調用這個函數時沒有傳任何參數,所以編譯器認為這個隱式聲明的參數類型是`void`,這樣函數的參數和返回值類型都確定下來了,編譯器根據這些信息為函數調用生成相應的指令。然后編譯器接著往下看,看到`threeline`函數的原型是`void threeline(void)`,和先前的隱式聲明的返回值類型不符,所以報警告。好在我們也沒用到這個函數的返回值,所以執行結果仍然正確。
* * *
<sup>[[4](#id2713286)]</sup> 敏銳的讀者可能會發現一個矛盾:如果函數`newline`沒有返回值,那么表達式`newline()`不就沒有值了嗎?然而上一章講過任何表達式都有值和類型兩個基本屬性。其實這正是設計`void`這么一個關鍵字的原因:首先從語法上規定沒有返回值的函數調用表達式有一個`void`類型的值,這樣任何表達式都有值,不必考慮特殊情況,編譯器的語法解析比較容易實現;然后從語義上規定`void`類型的表達式不能參與運算,因此`newline() + 1`這樣的表達式不能通過語義檢查,從而兼顧了語法上的一致和語義上的不矛盾。
## 3.?形參和實參
下面我們定義一個帶參數的函數,我們需要在函數定義中指明參數的個數和每個參數的類型,定義參數就像定義變量一樣,需要為每個參數指明類型,參數的命名也要遵循標識符命名規則。例如:
**例?3.4.?帶參數的自定義函數**
```
#include <stdio.h>
void print_time(int hour, int minute)
{
printf("%d:%d\n", hour, minute);
}
int main(void)
{
print_time(23, 59);
return 0;
}
```
需要注意的是,定義變量時可以把相同類型的變量列在一起,而定義參數卻不可以,例如下面這樣的定義是錯的:
```
void print_time(int hour, minute)
{
printf("%d:%d\n", hour, minute);
}
```
學習C語言的人肯定都樂意看到這句話:“變量是這樣定義的,參數也是這樣定義的,一模一樣”,這意味著不用專門去記住參數應該怎么定義了。誰也不愿意看到這句話:“定義變量可以這樣寫,而定義參數卻不可以”。C語言的設計者也不希望自己設計的語法規則里到處都是例外,一個容易被用戶接受的設計應該遵循最少例外原則(Rule of Least Surprise)。其實關于參數的這條規定也不算十分例外,也是可以理解的,請讀者想想為什么要這么規定。學習編程語言不應該死記各種語法規定,如果能夠想清楚設計者這么規定的原因(Rationale),不僅有助于記憶,而且會有更多收獲。本書在必要的地方會解釋一些Rationale,或者啟發讀者自己去思考,例如上一節在腳注中解釋了`void`關鍵字的Rationale。[[C99 Rationale]](bi01.html#bibli.rationale "Rationale for International Standard - Programming Languages - C")是隨C99標準一起發布的,值得參考。
總的來說,C語言的設計是非常優美的,只要理解了少數基本概念和基本原則就可以根據組合規則寫出任意復雜的程序,很少有例外的規定說這樣組合是不允許的,或者那樣類推是錯誤的。相反,C++的設計就非常復雜,充滿了例外,全世界沒幾個人能把C++的所有規則都牢記于心,因而C++的設計一直飽受爭議,這個觀點在[[UNIX編程藝術]](bi01.html#bibli.taoup "The Art of UNIX Programming")中有詳細闡述。
在本書中,凡是提醒讀者注意的地方都是多少有些Surprise的地方,初學者如果按常理來想很可能要想錯,所以需要特別提醒一下。而初學者容易犯的另外一些錯誤,完全是因為沒有掌握好基本概念和基本原理,或者根本無視組合規則而全憑自己主觀臆斷所致,對這一類問題本書不會做特別的提醒,例如有的初學者看完[第?2?章 _常量、變量和表達式_](ch02.html#expr)之后會這樣打印π的值:
```
double pi=3.1416;
printf("pi\n");
```
之所以會犯這種錯誤,一是不理解Literal的含義,二是自己想當然地把變量名組合到字符串里去,而事實上根本沒有這條語法規則。如果連這樣的錯誤都需要在書上專門提醒,就好比提醒小孩吃飯一定要吃到嘴里,不要吃到鼻子里,更不要吃到耳朵里一樣。
回到正題。我們調用`print_time(23, 59)`時,函數中的參數`hour`就代表`23`,參數`minute`就代表`59`。確切地說,當我們討論函數中的`hour`這個參數時,我們所說的“參數”是指形參(Parameter),當我們討論傳一個參數`23`給函數時,我們所說的“參數”是指實參(Argument),但我習慣都叫參數而不習慣總把形參、實參這兩個文縐縐的詞掛在嘴邊(事實上大多數人都不習慣),讀者可以根據上下文判斷我說的到底是形參還是實參。記住這條基本原理:_形參相當于函數中定義的變量,調用函數傳遞參數的過程相當于定義形參變量并且用實參的值來初始化_。例如這樣調用:
```
void print_time(int hour, int minute)
{
printf("%d:%d\n", hour, minute);
}
int main(void)
{
int h = 23, m = 59;
print_time(h, m);
return 0;
}
```
相當于在函數`print_time`中執行了這樣一些語句:
```
int hour = h;
int minute = m;
printf("%d:%d\n", hour, minute);
```
`main`函數的變量`h`和`print_time`函數的參數`hour`是兩個不同的變量,只不過它們的存儲空間中都保存了相同的值23,因為變量`h`的值賦給了參數`hour`。同理,變量`m`的值賦給了參數`minute`。C語言的這種傳遞參數的方式稱為Call by Value。在調用函數時,每個參數都需要得到一個值,函數定義中有幾個形參,在調用時就要傳幾個實參,不能多也不能少,每個參數的類型也必須對應上。
肯定有讀者注意到了,為什么我們每次調用`printf`傳的實參個數都不一樣呢?因為C語言規定了一種特殊的參數列表格式,用命令`man 3 printf`可以查看到`printf`函數的原型:
```
int printf(const char *format, ...);
```
第一個參數是`const char *`類型的,后面的...可以代表0個或任意多個參數,這些參數的類型也是不確定的,這稱為可變參數(Variable Argument),[第?6?節 “可變參數”](ch24s06.html#interface.va)將會詳細討論這種格式。總之,每個函數的原型都明確規定了返回值類型以及參數的類型和個數,即使像`printf`這樣規定為“不確定”也是一種明確的規定,調用函數時要嚴格遵守這些規定,有時候我們把函數叫做接口(Interface),調用函數就是使用這個接口,使用接口的前提是必須和接口保持一致。
### Man Page
Man Page是Linux開發最常用的參考手冊,由很多頁面組成,每個頁面描述一個主題,這些頁面被組織成若干個Section。FHS(Filesystem Hierarchy Standard)標準規定了Man Page各Section的含義如下:
**表?3.1.?Man Page的Section**
| Section | 描述 |
| --- | --- |
| 1 | 用戶命令,例如`ls(1)` |
| 2 | 系統調用,例如`_exit(2)` |
| 3 | 庫函數,例如`printf(3)` |
| 4 | 特殊文件,例如`null(4)`描述了設備文件`/dev/null`、`/dev/zero`的作用 |
| 5 | 系統配置文件的格式,例如`passwd(5)`描述了系統配置文件`/etc/passwd`的格式 |
| 6 | 游戲 |
| 7 | 其它雜項,例如`bash-builtins(7)`描述了`bash`的各種內建命令 |
| 8 | 系統管理命令,例如`ifconfig(8)` |
注意區分用戶命令和系統管理命令,用戶命令通常位于`/bin`和`/usr/bin`目錄,系統管理命令通常位于`/sbin`和`/usr/sbin`目錄,一般用戶可以執行用戶命令,而執行系統管理命令經常需要`root`權限。系統調用和庫函數的區別將在[第?2?節 “`main`函數和啟動例程”](ch19s02.html#asmc.main)說明。
Man Page中有些頁面有重名,比如敲`man printf`命令看到的并不是C函數`printf`,而是位于第1個Section的系統命令`printf`,要查看位于第3個Section的`printf`函數應該敲`man 3 printf`,也可以敲`man -k printf`命令搜索哪些頁面的主題包含`printf`關鍵字。本書會經常出現類似`printf(3)`這樣的寫法,括號中的3表示Man Page的第3個Section,或者表示“我這里想說的是`printf`庫函數而不是`printf`命令”。
### 習題
1、定義一個函數`increment`,它的作用是把傳進來的參數加1。例如:
```
void increment(int x)
{
x = x + 1;
}
int main(void)
{
int i = 1, j = 2;
increment(i); /* i now becomes 2 */
increment(j); /* j now becomes 3 */
return 0;
}
```
我們在`main`函數中調用`increment`增加變量`i`和`j`的值,這樣能奏效嗎?為什么?
2、如果在一個程序中調用了`printf`函數卻不包含頭文件,例如`int main(void) { printf("\n"); }`,編譯時會報警告:`warning: incompatible implicit declaration of built-in function ‘printf’`。請分析錯誤原因。
## 4.?全局變量、局部變量和作用域
我們把函數中定義的變量稱為局部變量(Local Variable),由于形參相當于函數中定義的變量,所以形參也是一種局部變量。在這里“局部”有兩層含義:
1、一個函數中定義的變量不能被另一個函數使用。例如`print_time`中的`hour`和`minute`在`main`函數中沒有定義,不能使用,同樣`main`函數中的局部變量也不能被`print_time`函數使用。如果這樣定義:
```
void print_time(int hour, int minute)
{
printf("%d:%d\n", hour, minute);
}
int main(void)
{
int hour = 23, minute = 59;
print_time(hour, minute);
return 0;
}
```
`main`函數中定義了局部變量`hour`,`print_time`函數中也有參數`hour`,雖然它們名稱相同,但仍然是兩個不同的變量,代表不同的存儲單元。`main`函數的局部變量`minute`和`print_time`函數的參數`minute`也是如此。
2、每次調用函數時局部變量都表示不同的存儲空間。局部變量在每次函數調用時分配存儲空間,在每次函數返回時釋放存儲空間,例如調用`print_time(23, 59)`時分配`hour`和`minute`兩個變量的存儲空間,在里面分別存上23和59,函數返回時釋放它們的存儲空間,下次再調用`print_time(12, 20)`時又分配`hour`和`minute`的存儲空間,在里面分別存上12和20。
與局部變量的概念相對的是全局變量(Global Variable),全局變量定義在所有的函數體之外,它們在程序開始運行時分配存儲空間,在程序結束時釋放存儲空間,在任何函數中都可以訪問全局變量,例如:
**例?3.5.?全局變量**
```
#include <stdio.h>
int hour = 23, minute = 59;
void print_time(void)
{
printf("%d:%d in print_time\n", hour, minute);
}
int main(void)
{
print_time();
printf("%d:%d in main\n", hour, minute);
return 0;
}
```
正因為全局變量在任何函數中都可以訪問,所以在程序運行過程中全局變量被讀寫的順序從源代碼中是看不出來的,源代碼的書寫順序并不能反映函數的調用順序。程序出現了Bug往往就是因為在某個不起眼的地方對全局變量的讀寫順序不正確,如果代碼規模很大,這種錯誤是很難找到的。而對局部變量的訪問不僅局限在一個函數內部,而且局限在一次函數調用之中,從函數的源代碼很容易看出訪問的先后順序是怎樣的,所以比較容易找到Bug。因此,_雖然全局變量用起來很方便,但一定要慎用,能用函數傳參代替的就不要用全局變量_。
如果全局變量和局部變量重名了會怎么樣呢?如果上面的例子改為:
**例?3.6.?作用域**

則第一次調用`print_time`打印的是全局變量的值,第二次直接調用`printf`打印的則是`main`函數局部變量的值。在C語言中每個標識符都有特定的作用域,全局變量是定義在所有函數體之外的標識符,它的作用域從定義的位置開始直到源文件結束,而`main`函數局部變量的作用域僅限于`main`函數之中。如上圖所示,設想整個源文件是一張大紙,也就是全局變量的作用域,而`main`函數是蓋在這張大紙上的一張小紙,也就是`main`函數局部變量的作用域。在小紙上用到標識符`hour`和`minute`時應該參考小紙上的定義,因為大紙(全局變量的作用域)被蓋住了,如果在小紙上用到某個標識符卻沒有找到它的定義,那么再去翻看下面的大紙上有沒有定義,例如上圖中的變量`x`。
到目前為止我們在初始化一個變量時都是用常量做Initializer,其實也可以用表達式做Initializer,但要注意一點:_局部變量可以用類型相符的任意表達式來初始化,而全局變量只能用常量表達式(Constant Expression)初始化_。例如,全局變量`pi`這樣初始化是合法的:
```
double pi = 3.14 + 0.0016;
```
但這樣初始化是不合法的:
```
double pi = acos(-1.0);
```
然而局部變量這樣初始化卻是可以的。程序開始運行時要用適當的值來初始化全局變量,所以初始值必須保存在編譯生成的可執行文件中,因此初始值在_編譯時_就要計算出來,然而上面第二種Initializer的值必須在程序_運行時_調用`acos`函數才能得到,所以不能用來初始化全局變量。請注意區分編譯時和運行時這兩個概念。為了簡化編譯器的實現,C語言從語法上規定全局變量只能用常量表達式來初始化,因此下面這種全局變量初始化是不合法的:
```
int minute = 360;
int hour = minute / 60;
```
雖然在編譯時計算出`hour`的初始值是可能的,但是`minute / 60`不是常量表達式,不符合語法規定,所以編譯器不必想辦法去算這個初始值。
如果全局變量在定義時不初始化則初始值是0,如果局部變量在定義時不初始化則初始值是不確定的。所以,_局部變量在使用之前一定要先賦值_,如果基于一個不確定的值做后續計算肯定會引入Bug。
如何證明“局部變量的存儲空間在每次函數調用時分配,在函數返回時釋放”?當我們想要確認某些語法規則時,可以查教材,也可以查C99,但最快捷的辦法就是編個小程序驗證一下:
**例?3.7.?驗證局部變量存儲空間的分配和釋放**
```
#include <stdio.h>
void foo(void)
{
int i;
printf("%d\n", i);
i = 777;
}
int main(void)
{
foo();
foo();
return 0;
}
```
第一次調用`foo`函數,分配變量`i`的存儲空間,然后打印`i`的值,由于`i`未初始化,打印的應該是一個不確定的值,然后把`i`賦值為777,函數返回,釋放`i`的存儲空間。第二次調用`foo`函數,分配變量`i`的存儲空間,然后打印`i`的值,由于`i`未初始化,如果打印的又是一個不確定的值,就證明了“局部變量的存儲空間在每次函數調用時分配,在函數返回時釋放”。分析完了,我們運行程序看看是不是像我們分析的這樣:
```
134518128
777
```
結果出乎意料,第二次調用打印的`i`值正是第一次調用末尾賦給`i`的值777。有一種初學者是這樣,原本就沒有把這條語法規則記牢,或者對自己的記憶力沒信心,看到這個結果就會想:哦那肯定是我記錯了,改過來記吧,應該是“函數中的局部變量具有一直存在的固定的存儲空間,每次函數調用時使用它,返回時也不釋放,再次調用函數時它應該還能保持上次的值”。還有一種初學者是懷疑論者或不可知論者,看到這個結果就會想:教材上明明說“局部變量的存儲空間在每次函數調用時分配,在函數返回時釋放”,那一定是教材寫錯了,教材也是人寫的,是人寫的就難免出錯,哦,連C99也這么寫的啊,C99也是人寫的,也難免出錯,或者C99也許沒錯,但是反正運行結果就是錯了,計算機這東西真靠不住,太容易受電磁干擾和宇宙射線影響了,我的程序寫得再正確也有可能被干擾得不能正確運行。
這是初學者最常見的兩種心態。不從客觀事實和邏輯推理出發分析問題的真正原因,而僅憑主觀臆斷胡亂給問題定性,“說你有罪你就有罪”。先不要胡亂懷疑,我們再做一次實驗,在兩次`foo`函數調用之間插一個別的函數調用,結果就大不相同了:
```
int main(void)
{
foo();
printf("hello\n");
foo();
return 0;
}
```
結果是:
```
134518200
hello
0
```
這一回,第二次調用`foo`打印的`i`值又不是777了而是0,“局部變量的存儲空間在每次函數調用時分配,在函數返回時釋放”這個結論似乎對了,但另一個結論又不對了:全局變量不初始化才是0啊,不是說“局部變量不初始化則初值不確定”嗎?
關鍵的一點是,我說“初值不確定”,有沒有說這個不確定值不能是0?有沒有說這個不確定值不能是上次調用賦的值?在這里“不確定”的準確含義是:每次調用這個函數時局部變量的初值可能不一樣,運行環境不同,函數的調用次序不同,都會影響到局部變量的初值。在運用邏輯推理時一定要注意,_不要把必要條件(Necessary Condition)當充分條件(Sufficient Condition)_,這一點在Debug時尤其重要,看到錯誤現象不要輕易斷定原因是什么,一定要考慮再三,找出它的真正原因。例如,不要看到第二次調用打印777就下結論“函數中的局部變量具有一直存在的固定的存儲空間,每次函數調用時使用它,返回時也不釋放,再次調用函數時它應該還能保持上次的值”,這個結論倒是能推出777這個結果,但反過來由777這個結果卻不能推出這樣的結論。所以說777這個結果是該結論的必要條件,但不是充分條件。也不要看到第二次調用打印0就斷定“局部變量未初始化則初值為0”,0這個結果是該結論的必要條件,但也不是充分條件。至于為什么會有這些現象,為什么這個不確定的值剛好是777,或者剛好是0,等學到[例?19.1 “研究函數的調用過程”](ch19s01.html#asmc.func)就能解釋這些現象了。
從[第?2?節 “自定義函數”](ch03s02.html#func.ourfirstfunc)介紹的語法規則可以看出,非定義的函數聲明也可以寫在局部作用域中,例如:
```
int main(void)
{
void print_time(int, int);
print_time(23, 59);
return 0;
}
```
這樣聲明的標識符`print_time`具有局部作域,只在`main`函數中是有效的函數名,出了`main`函數就不存在`print_time`這個標識符了。
寫非定義的函數聲明時參數可以只寫類型而不起名,例如上面代碼中的`void print_time(int, int);`,只要告訴編譯器參數類型是什么,編譯器就能為`print_time(23, 59)`函數調用生成正確的指令。另外注意,雖然在一個函數體中可以聲明另一個函數,但不能定義另一個函數,C語言不允許嵌套定義函數<sup>[[5](#ftn.id2715167)]</sup>。
* * *
<sup>[[5](#id2715167)]</sup> 但`gcc`的擴展特性允許嵌套定義函數,本書不做詳細討論。
- Linux C編程一站式學習
- 歷史
- 前言
- 部分?I.?C語言入門
- 第?1?章?程序的基本概念
- 第?2?章?常量、變量和表達式
- 第?3?章?簡單函數
- 第?4?章?分支語句
- 第?5?章?深入理解函數
- 第?6?章?循環語句
- 第?7?章?結構體
- 第?8?章?數組
- 第?9?章?編碼風格
- 第?10?章?gdb
- 第?11?章?排序與查找
- 第?12?章?棧與隊列
- 第?13?章?本階段總結
- 部分?II.?C語言本質
- 第?14?章?計算機中數的表示
- 第?15?章?數據類型詳解
- 第?16?章?運算符詳解
- 第?17?章?計算機體系結構基礎
- 第?18?章?x86匯編程序基礎
- 第?19?章?匯編與C之間的關系
- 第?20?章?鏈接詳解
- 第?21?章?預處理
- 第?22?章?Makefile基礎
- 第?23?章?指針
- 第?24?章?函數接口
- 第?25?章?C標準庫
- 第?26?章?鏈表、二叉樹和哈希表
- 第?27?章?本階段總結
- 部分?III.?Linux系統編程
- 第?28?章?文件與I/O
- 第?29?章?文件系統
- 第?30?章?進程
- 第?31?章?Shell腳本
- 第?32?章?正則表達式
- 第?33?章?信號
- 第?34?章?終端、作業控制與守護進程
- 第?35?章?線程
- 第?36?章?TCP/IP協議基礎
- 第?37?章?socket編程
- 附錄?A.?字符編碼
- 附錄?B.?GNU Free Documentation License Version 1.3, 3 November 2008
- 參考書目
- 索引