我們不僅可以調用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 章 結構體開始我們才會看到類型聲明,所以現在暫不討論。
給函數命名也要遵循上一章講過的標識符命名規則。由於我們定義的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
函數需要注意兩點:
[K&R]書上的main
函數定義寫成main(){...}
的形式,不寫返回值類型也不寫參數列表,這是Old Style C的風格。Old Style C規定不寫返回值類型就表示返回int
型,不寫參數列表就表示參數類型和個數沒有明確指出。這種寬鬆的規定使編譯器無法檢查程序中可能存在的Bug,增加了調試難度,不幸的是現在的C標準為了兼容舊的代碼仍然保留了這種語法,但讀者絶不應該繼續使用這種語法。
其實操作系統在調用main
函數時是傳參數的,main
函數最標準的形式應該是int main(int argc, char *argv[])
,在第 6 節 “指向指針的指針與指針數組”詳細介紹。C標準也允許int main(void)
這種寫法,如果不使用系統傳進來的兩個參數也可以寫成這種形式。但除了這兩種形式之外,定義main
函數的其它寫法都是錯誤的或不可移植的。
關於返回值和return
語句我們將在第 1 節 “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
表示沒有返回值[4],這說明我們調用這個函數完全是為了利用它的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; }
通過這個簡單的例子可以體會到:
同一個函數可以被多次調用。
可以用一個函數調用另一個函數,後者再去調第三個函數。
通過自定義函數可以給一組複雜的操作起一個簡單的名字,例如threeline
。對於main
函數來說,只需要通過threeline
這個簡單的名字來調用就行了,不必知道打印三個空行具體怎麼做,所有的複雜操作都被隱藏在threeline
這個名字後面。
使用自定義函數可以使代碼更簡潔,main
函數在任何地方想打印三個空行只需調用一個簡單的threeline()
,而不必每次都寫三個printf("\n")
。
讀代碼和讀文章不一樣,按從上到下從左到右的順序讀代碼未必是最好的。比如上面的例子,按源檔案的順序應該是先看newline
再看threeline
再看main
。如果你換一個角度,按代碼的執行順序來讀也許會更好:首先執行的是main
函數中的語句,在一條printf
之後調用了threeline
,這時再去看threeline
的定義,其中又調用了newline
,這時再去看newline
的定義,newline
裡面有一條printf
,執行完成後返回threeline
,這裡還剩下兩次newline
調用,效果也都一樣,執行完之後返回main
,接下來又是一條printf
和一條threeline
。如下圖所示:
讀代碼的過程就是模仿計算機執行程序的過程,我們不僅要記住當前讀到了哪一行代碼,還要記住現在讀的代碼是被哪個函數調用的,這段代碼返回後應該從上一個函數的什麼地方接着往下讀。
現在澄清一下函數聲明、函數定義、函數原型(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)
,和先前的隱式聲明的返回值類型不符,所以報警告。好在我們也沒用到這個函數的返回值,所以執行結果仍然正確。
[4] 敏鋭的讀者可能會發現一個矛盾:如果函數newline
沒有返回值,那麼表達式newline()
不就沒有值了嗎?然而上一章講過任何表達式都有值和類型兩個基本屬性。其實這正是設計void
這麼一個關鍵字的原因:首先從語法上規定沒有返回值的函數調用表達式有一個void
類型的值,這樣任何表達式都有值,不必考慮特殊情況,編譯器的語法解析比較容易實現;然後從語義上規定void
類型的表達式不能參與運算,因此newline() + 1
這樣的表達式不能通過語義檢查,從而兼顧了語法上的一致和語義上的不矛盾。