這份講義整理 Vol. 23 直播的內容:函式。第一堂課開始,我們一直說「這個之後再講」:為什麼開頭要寫 #include <stdio.h>、main 後面為什麼有括號、為什麼要 return 0、scanf 的變數前面為什麼要加 &。今天講完函式,這些伏筆就能全部收回來。沒看過前面幾堂的話,建議先讀 #1 到 #5 的講義,尤其是上一堂的指標,今天會用到。
這是 2021 年 9 月的內容。工具一樣用 Dev-C++,換成其他 IDE 也可以,這裡教的語法跟工具沒有關係。
整場分成幾個部分:
- 函式:把流程抽出來、引數和回傳值、函式的宣告
- 變數活在哪裡:區域變數和全域變數
- 指標當引數:寫一個真的能交換的
swap、把陣列傳給函式 - 拆成多個檔案:標頭檔、編譯的流程、Dev-C++ 的專案
- 回收伏筆:回頭看第一堂的程式
一、把流程抽出來:函式
從 1 加到 n 開始
今天一樣先從程式碼開始。這個程式之前寫過:輸入一個整數 n,算出 1 加到 n 的結果。先跟記憶體要一塊整數空間 sum 存總和,從 0 開始;用 for 迴圈讓 i 從 1 跑到 n,每次把 i 加進 sum。輸入 10 會得到 55,輸入 100 會得到 5050。
現在想做一件事:輸入兩個整數 a 和 b,分別算出 1 加到 a、1 加到 b,再把兩個結果拿來加減乘除。照以前的做法,就是把同一段流程寫兩次:
#include <stdio.h>
int main(){
int a, b, i;
int sumA = 0, sumB = 0;
scanf("%d%d", &a, &b);
for (i = 1; i <= a; i++) {
sumA += i;
}
for (i = 1; i <= b; i++) {
sumB += i;
}
printf("sumA = %d, sumB = %d\n", sumA, sumB);
printf("%d %d %d %d\n", sumA + sumB, sumA - sumB, sumA * sumB, sumA / sumB);
return 0;
}
輸入 5 10:
sumA = 15, sumB = 55
70 -40 825 0
15 除以 55 連 1 都不到,整數的除法只留下整數的部分,所以是 0,這很合理。
這個範例只是要示範重複的流程,輸入請用不大的正整數。b 輸入 0 的話,
sumB是 0,sumA / sumB會除以 0,程式可能直接當掉;數字太大的話,sumA * sumB也會超過int能存的範圍,印出奇怪的值。
問題是:如果還要算第三次、第四次、第五次呢?變數很多可以用陣列,但流程也一直重複,有沒有辦法讓它更好用一點?
寫一個函式
做法是把這段流程抽出來,取一個名字,這就是函式。
這個函式要算 1 加到多少的總和,就叫它 sum。它需要知道一件事:到底要加到多少,也就是一個整數 n。算完之後,它會給回一個結果:1 加 2 加 3 一直加到 n,也是一個整數。所以這樣寫:
- 名字後面的小括號,放它需要的資料:
int n。 - 名字前面寫它最後給回的資料是什麼型態:
int。 - 後面的大括號是函式的內容,就是剛剛那段流程。
- 最後用
return把結果回傳回去。
#include <stdio.h>
int sum(int n){
int total = 0, i;
for (i = 1; i <= n; i++) {
total += i;
}
return total;
}
int main(){
int a, b;
int sumA, sumB;
scanf("%d%d", &a, &b);
sumA = sum(a);
sumB = sum(b);
printf("sumA = %d, sumB = %d\n", sumA, sumB);
printf("%d %d %d %d\n", sumA + sumB, sumA - sumB, sumA * sumB, sumA / sumB);
return 0;
}
輸入 5 10,結果跟剛剛一模一樣。
存總和的變數原本也想叫 sum,但函式已經叫 sum 了。同名的話,你會分不清楚程式裡的 sum 指的是函式還是變數,所以函式裡的變數換個名字,叫 total。
呼叫函式:回傳值會蓋掉呼叫的地方
要用這個函式,就呼叫它:寫函式的名字,括號裡放你要給 n 的值。sum(a) 就是把 a 的值交給 n,算 1 加到 a。
當 a 是 5 時,sumA = sum(a); 就像是 sumA = sum(5);。函式用 5 去算,算出 15,return 回來之後,回傳值會蓋掉呼叫的地方,這行就變成 sumA = 15;,15 就放進 sumA 了。sumB 也一樣,依樣畫葫蘆寫成 sum(b)。
你會發現 main 清爽很多,而且沒有重複的流程了。這就是函式的基本目的:為一個流程命名,讓會重複用到的地方可以簡化,不用一直複製貼上。
其實我們一直都在做這種事:不想宣告一堆變數,就用陣列;不想重複寫一樣的指令,就用迴圈。重複的東西一多,有問題時就要改好幾遍,每個貼上的地方都要改;抽出來之後,只要改抽出來的那一份,用到它的地方就一次全部改好了。
main 也是一個函式
仔細看會發現,main 跟我們寫的 sum 長得一樣,語法根本相同。main 本身就是一個函式,只是比較特別:名字固定叫 main,通常叫它主函式。
一個程式可能由三個、四個、一堆函式組成,有些還是別人寫好、放在函式庫裡的(例如 printf)。這麼多函式裡,誰先執行?就是叫 main 的那個。它的特殊任務是:程式一執行,它就是第一個被執行的函式。
main 後面的括號是空的,代表我們沒有要給它任何值。你可能看過別人的 main 括號裡有東西,那是用命令提示字元執行程式時,可以從外面傳進來的引數。今天先不談。
函式的宣告(prototype)
既然 main 第一個執行,能不能把 main 寫在最前面、sum 寫在後面?
理論上,編譯器是由上往下編譯的。如果 sum 寫在 main 後面,編譯器看到 main 裡用了 sum,會說:「sum 是什麼?我又沒看過。」然後編譯錯誤。(直播上交換順序後竟然還能跑,原因在後面的補充說明。)
想把 sum 寫在後面,又要在前面用到它,做法是:把 sum 的開頭那一行先抄到前面,加一個分號。 這叫函式的宣告,英文是 prototype。它的意思是:「等一下會有一個叫 sum 的函式,給它一個整數,它會回傳一個整數。」
#include <stdio.h>
int sum(int n); // 函式的宣告(prototype):先告訴編譯器 sum 長什麼樣子
int main(){
int a, b;
scanf("%d%d", &a, &b);
printf("sumA = %d, sumB = %d\n", sum(a), sum(b));
return 0;
}
int sum(int n){ // 函式的定義:真正的內容
int total = 0, i;
for (i = 1; i <= n; i++) {
total += i;
}
return total;
}
輸入 5 10,一樣印出 sumA = 15, sumB = 55。
編譯器看到 main 裡用了 sum,就去對照宣告:「喔,是給一個整數、回傳一個整數的函式。」你給的是不是整數、回傳的值是不是用整數接,它都能先檢查,所以就讓你編譯過去。真正的內容寫在後面也沒關係,執行時自然會跳過去執行。
宣告只需要告訴編譯器「要什麼、回傳什麼」,所以連引數的名字都可以省略,寫成 int sum(int); 也是對的。
補充:為什麼直播上沒有宣告也能跑
直播上把 sum 移到 main 後面、不寫宣告,編譯器只給了警告,程式照樣能跑。聊天室查到原因:舊的 C 標準(C89)有個規則,呼叫沒宣告過的函式時,編譯器會假設它回傳 int。sum 剛好就是回傳 int、引數也是 int,所以剛好猜對了。
但這個規則在 C99 就拿掉了。gcc 13 編譯時會警告:
warning: implicit declaration of function 'sum' [-Wimplicit-function-declaration]
而 gcc 14 以後的版本預設會直接當成錯誤。函式回傳的不是 int 時,這樣猜還會猜錯,結果就是錯的。所以不要依賴它:用到函式之前,前面一定要有它的宣告或定義。 看到這個警告,就把它當成錯誤處理。
二、簡報整理:引數、回傳值與 void
名詞
#include <stdio.h>
int sum(int a, int b); // 宣告:最後有分號,沒有內容
int main(){
printf("%d\n", sum(3, 5));
return 0;
}
int sum(int a, int b){ // 定義:分號拿掉,接上大括號
return a + b;
}
印出 8。
sum是函式名稱。- 括號裡的
int a、int b叫引數。它們是函式自己的變數,呼叫時你給的值會放進這些變數裡,讓函式在裡面運作。 - 最前面的
int是回傳值的型態。 - 宣告最後有分號、沒有內容。要寫函式的內容(定義)時,把分號拿掉,接上大括號。
return 回傳的值要符合寫在前面的型態。上面這個例子跟剛剛的 sum 意思不太一樣,它是算兩個值的總和,所以要兩個整數。
多個引數,但只能回傳一個值
引數可以有很多個。簡報的例子是 f(x, y) = 2x + y:
#include <stdio.h>
int f(int x, int y);
int main(){
int answer;
answer = f(3, 4);
printf("%d\n", answer);
return 0;
}
int f(int x, int y){
return 2 * x + y;
}
印出 10。
要注意的是:回傳值只能有一個。 這是為了讓 answer = f(3, 4) 這種寫法成立:回傳的值要蓋回呼叫的地方,再用等號存進一個變數。後來有些語言拿掉了這個限制,可以回傳好幾個值;但在 C 裡,想傳回很多個值就要用其他手段,等一下的 swap 就是一個例子。
void:沒有回傳值的函式
有時候函式只是想做一些事,不打算回傳什麼。這時型態寫 void,裡面也不用寫 return 什麼值。
例如一個函式只做一件事:印三次 Hello。
#include <stdio.h>
void hello();
int main(){
printf("Start\n");
hello();
printf("End\n");
return 0;
}
void hello(){
int i;
for (i = 0; i < 3; i++) {
printf("Hello\n");
}
}
Start
Hello
Hello
Hello
End
呼叫 hello() 時,程式跳進 hello 印三次,再回到 main 繼續往下。因為 hello 寫在 main 後面,前面記得要宣告。宣告的意思是:「hello 是不需要引數、也不會回傳任何值的函式。」
小提醒:在 C 裡,括號空著的
void hello()嚴格來說是「沒說要幾個引數」。想明確表示「沒有引數」,可以寫成void hello(void)。兩種寫法在這裡的結果都一樣。
exit:在任何地方結束程式
如果想在函式裡直接結束整個程式,stdlib.h 裡有一個 exit 函式。直播不建議這麼做,只是讓你知道有這個東西:
#include <stdio.h>
#include <stdlib.h> // exit 定義在標準函式庫 stdlib.h
void check(int n){
if (n < 0) {
printf("不能是負數,程式直接結束\n");
exit(0); // 在任何地方結束整個程式,0 就是 main 最後 return 的那個 0
}
printf("%d 沒問題\n", n);
}
int main(){
check(5);
check(-1);
printf("這行不會被印出來\n");
return 0;
}
5 沒問題
不能是負數,程式直接結束
exit 括號裡的 0,就是 main 最後 return 的那個 0,是程式結束時交給外面呼叫這個程式的人的值。
練習:自己寫一次 sum
練習就是剛剛那個程式:把 1 加到 n 抽成函式,在 main 裡呼叫兩次。會卡住的話,有時候不是不會寫函式,而是迴圈之類的部分還不熟,那就回頭再練一下前面的東西。
三、解題網站
練習時聊到可以去哪裡找題目。以前有架過批改系統,丟程式上去它就告訴你對或錯,但那些題目已經找不回來了。現在可以用這些網站(網址也補在直播的說明欄):
- ZeroJudge(zerojudge.tw):「高中生程式解題系統」,英文寫的是給 beginner,給剛開始寫程式的人。第一題 a001 哈囉:輸入一個字串,在前面加上
hello,印出來,用scanf讀進來、printf印出去就好。這題跟字串有關,有一個之前講過的小陷阱,讀字串時要小心什麼,可以自己想一想;真的解不出來再來問。後面也有給你兩個整數要你計算的題目。 - LeetCode(leetcode.com):找工作的人最常用的解題網站。它的題目難度比較高,選 C 語言看第一題(Two Sum),光是題目給的函式就用到了今天要講的指標和陣列。
兩種網站的做法不一樣:
- ZeroJudge 這類網站要你寫完整的程式,從
scanf讀輸入一路寫到printf印答案,再比對你的輸出對不對。輸出格式要完全一樣,少一個空白就錯,你可能花了 30 分鐘只是因為沒加空白。 - LeetCode 直接給你一個函式,把內容挖空叫你填。例如要算 1 加到 n,它就給你
int sum(int n),你只要算出結果return回去,輸入輸出都不用管,不用浪費時間處理格式。
有些 ZeroJudge 的題目是資訊奧林匹亞培訓的人出的,非常刁鑽,所以不是基礎題寫一寫就能馬上全部答對。不過函式教完之後,解題需要的知識大部分你都已經有了,可以去玩玩看。解題有點像玩遊戲,拿到分數會越來越有興趣,玩著玩著程式就變厲害了。
四、變數活在哪裡:區域變數與全域變數
之前說過:在大括號裡宣告的變數,只活在離它最近的那個大括號裡。 函式也一樣:main 裡宣告一個 x,另一個函式裡也宣告一個 x,這兩個 x 沒有任何關係,是不同的變數。離開大括號之後,變數就會被回收掉。這種變數叫區域變數(local variable)。
既然這樣,把變數宣告在所有大括號外面呢?那大家看到的就是同一個變數了,這叫全域變數(global variable)。
#include <stdio.h>
int g = 100; // 全域變數:寫在所有大括號外面,大家都看得到
void func(){
int x = 2; // func 的 x,跟 main 的 x 沒有關係
printf("func: x = %d, g = %d\n", x, g);
g = 200; // 改了全域變數,main 看到的也會跟著變
}
int main(){
int x = 1; // main 的 x
func();
printf("main: x = %d, g = %d\n", x, g);
return 0;
}
func: x = 2, g = 100
main: x = 1, g = 200
全域變數看起來很方便:剛剛的 sum 如果直接拿外面的 a 來用,連引數都不用傳。但危險也在這裡:只要有一個地方改了它,其他地方看到的也跟著變。 程式變大以後,你寫了十幾個函式,第 4 個函式改了 x,第 6 個、第 8 個用到 x 的函式就可能出錯,影響範圍非常大。所以全域變數的改動是很可怕的,盡量少用。
五、指標當引數
上一堂的疑問:指標到底用在哪?
上一堂講完指標,最大的疑問是:看不出來要用在哪裡。今天就來看一個例子。
之前講過兩數交換:要讓 a 和 b 交換,需要第三個地方暫存。就像要把床和桌子對調,不能讓兩個撞在一起就換好,要先把床移到一塊空地,桌子移到床的位置,再把床移到桌子原本的位置。這個暫存的變數叫 temp(temporary,暫時的意思):
#include <stdio.h>
int main(){
int a = 3, b = 5;
int temp;
temp = a; // 先把 a 放到暫存的地方
a = b; // b 搬到 a
b = temp; // 再把暫存的值搬到 b
printf("a = %d, b = %d\n", a, b);
return 0;
}
印出 a = 5, b = 3。
交換很常用到,接下來又有 c 和 d 也要交換,難道又要複製一次?當然想把它抽成函式。
第一個想法:直接把 a、b 交給函式
交換不需要回傳什麼,所以用 void,給它兩個值,讓它在裡面交換:
#include <stdio.h>
void swap(int a, int b){
int temp;
temp = a;
a = b;
b = temp;
}
int main(){
int a = 3, b = 5;
swap(a, b);
printf("a = %d, b = %d\n", a, b);
return 0;
}
印出來還是 a = 3, b = 5,沒有交換。
原因是:呼叫 swap(a, b) 時,給的是 a 的值 3 和 b 的值 5。這就像在做「swap 的 a = main 的 a」「swap 的 b = main 的 b」,只是複製一份過去。雖然名字一樣,但 swap 的 a、b 跟 main 的 a、b 是不同的變數。
用記憶體畫出來(位置都是隨便編的):
| 誰的 | 變數 | 位置 | 一開始 | 交換後 |
|---|---|---|---|---|
main |
a |
3000 | 3 | 3 |
main |
b |
3004 | 5 | 5 |
swap |
a |
4015 | 3(複製來的) | 5 |
swap |
b |
4028 | 5(複製來的) | 3 |
swap |
temp |
5024 | 3 |
swap 裡的 a 和 b 確實交換了,但 main 的兩個完全沒動。而且 swap 一執行完,離開大括號,它的三個變數全部被回收。做了一大堆事,最後什麼都沒留下,就像寫完一整份暑假作業然後丟進垃圾桶。
用指標:交給函式「位置」
變數名字沒辦法在兩個函式之間共通,但有一個東西大家都一樣:記憶體的位置。大家在同一台電腦上,同一個位置就是同一個變數。
所以不要給值了,改給位置。引數宣告成存整數位置的變數,也就是整數指標 int *;呼叫時用 & 取 a 和 b 的位置。拿到位置之後,要交換的不是位置本身,而是位置上的那個值,所以裡面要用 * 把位置轉回原本的變數:
#include <stdio.h>
void swap(int *a, int *b){
int temp;
temp = *a; // a 存的是位置,*a 才是那個位置上的值
*a = *b;
*b = temp;
}
int main(){
int a = 3, b = 5;
int c = 4, d = 6;
swap(&a, &b); // 給的是 a 和 b 的位置
printf("a = %d, b = %d\n", a, b);
swap(&c, &d); // 同一個函式,換別的變數也能用
printf("c = %d, d = %d\n", c, d);
return 0;
}
a = 5, b = 3
c = 6, d = 4
這次換成功了。這不是魔法,再畫一次記憶體:
| 誰的 | 變數 | 位置 | 存的值 |
|---|---|---|---|
main |
a |
3000 | 3 |
main |
b |
3004 | 5 |
swap |
a |
4018 | 3000(main 的 a 的位置) |
swap |
b |
5017 | 3004(main 的 b 的位置) |
swap |
temp |
6014 |
一行一行看:
temp = *a;:a是 3000,*a就是「3000 這個位置上的值」,也就是 3。所以temp變成 3。*a = *b;:*b是 3004 上的值 5。*a不是swap的 a,而是 3000 上的那個變數,也就是main的 a。就在這一刻,main的 a 被改成 5。*b = temp;:temp沒有*,就是 3。*b是 3004 上的變數,也就是main的 b,改成 3。
swap 結束後,它的三個變數一樣被回收,但 main 的 a 和 b 已經真的交換了。傳位置,就是為了在沒辦法用名字拿到別的函式的變數時,還有另一個方法可以改到它。
一次看不懂沒關係,照這樣畫一張圖,或自己寫程式試試看。就算是高手,看到複雜的程式也要花時間理解。寫程式寫到後來,最大的學問其實就是怎麼寫出大家都能馬上看懂、又好用、又好維護的程式。
抽成函式之後,c 和 d 也能直接用 swap(&c, &d) 交換。講陣列那一堂的排序法就用過交換的技巧,只是當時沒抽成函式;有了 swap,排序也會好寫很多。
為什麼大括號要把變數關起來
既然這麼麻煩,為什麼要讓大括號限制變數的範圍?
因為程式能切成很多函式之後,就不會是一個人寫全部的函式了:某些人寫某些功能,另一些人寫另一些功能。如果我的 main 用了 i 控制迴圈,其他人就不能用 i,那還怎麼分工?所以設計上讓每個函式(甚至每個大括號)的變數可以重複命名,又不會誤指到別人的變數。代價就是 swap 沒辦法直接指定「我要 main 的 a」,只能透過大家共通的位置去改。
把陣列傳給函式
會傳位置之後,你也會傳陣列了。之前說過,陣列名稱就是陣列開頭的位置,所以引數用指標來接就好。指標指向陣列時,一樣可以用中括號取值,因為 p[i] 就是 *(p + i) 的簡寫,這個前面講過兩次了。
寫一個印出陣列的函式 printArray:它要知道陣列有多長,以及陣列在哪裡。
#include <stdio.h>
void printArray(int len, int *array){
int i;
for (i = 0; i < len; i++) {
printf("array[%d] = %d\n", i, array[i]);
}
}
int main(){
int a[5] = {2, 3, 5, 7, 11};
printArray(5, a); // 陣列名稱就是開頭的位置
return 0;
}
array[0] = 2
array[1] = 3
array[2] = 5
array[3] = 7
array[4] = 11
i 從 0 跑到 len - 1,總共跑 len 次,陣列有幾格就跑幾次。
只看 int *array,看不出來這裡要放的是陣列,還是隨便一個整數的位置。想讓看的人一眼就知道,可以寫成中括號、不寫長度:
#include <stdio.h>
void printArray(int len, int array[]){ // 寫成 []:告訴看的人這裡要放陣列
int i;
for (i = 0; i < len; i++) {
printf("array[%d] = %d\n", i, array[i]);
}
}
int main(){
int a[5] = {2, 3, 5, 7, 11};
printArray(5, a);
return 0;
}
結果一樣。對機器來說,int array[] 還是一個整數指標,在 Dev-C++ 裡把滑鼠移過去,也會看到它的型態是指標;你硬要放一般整數變數的位置進去,它也不會怎樣。這個寫法是給人看的:明確告訴別人這裡應該放陣列。
練習:算平均
寫一個函式,拿到一個分數的陣列 score 和個數 n,把 n 個分數加起來除以 n,回傳平均。要注意分數是整數,但平均要回傳 double,中間記得轉成 double,不然算出來只會是整數的平均。
先自己寫寫看,下面是一種寫法:
#include <stdio.h>
double average(int score[], int n){
int i, total = 0;
for (i = 0; i < n; i++) {
total += score[i];
}
return (double)total / n; // 先轉成 double 再除,不然只剩整數的部分
}
int main(){
int score[5] = {60, 75, 90, 82, 100};
printf("%lf\n", average(score, 5));
return 0;
}
印出 81.400000。如果寫成 total / n,整數除法會先算出 81,再變成 81.000000。
休息聊天:學到這裡已經能做很多事
聽完兩三個小時就變成程式大師,當然很困難。不管畫圖、唱歌、作曲都一樣,聽完老師講之後,還是要回去好好練。程式的話,有了概念就開始寫:寫題目,或試著解決日常生活中的問題。直播主自己最近也因為實況想解決一些問題,寫了程式,例如處理綠界贊助的顯示、讓聊天室變成彈幕。找一件稍微超出能力範圍的事,想辦法用程式解決它。
函式是一個斷點。C 學到函式,大部分的事其實都能做了。直播主剛開始學程式時也只學到這裡,就開始寫一堆東西,只是組織架構不好,這是下一堂之後要講的。之後進到 C++ 或其他語言,差異也沒那麼大:例如指標那一堆 & 和 * 很難看,現代語言幾乎看不到了,它們用別的方法讓兩個函式知道彼此的變數,概念差不多,寫起來比較方便。
直播會錄下來,看不懂的地方可以多聽幾次,這是直播教學比課堂好的地方。
六、拆成多個檔案
函式能不能放在別的檔案?
回到今天第一個例子。main 能用 sum,是因為 sum 的宣告和內容都寫在同一個檔案裡。能不能讓這個檔案只留 main,sum 寫在別的檔案?
為什麼想這樣做?因為一個程式由好幾個函式組成,這些函式可能是別人開發的:你寫 swap、他寫 sum,我再把你們的東西合起來,在 main 裡呼叫。這樣才能真正協同開發、分工給很多人寫。
你有沒有發現:我們一直在用的 printf 和 scanf,也沒有寫在我們的 .c 檔裡,卻可以用。它是怎麼辦到的?
#include 其實就是複製貼上
答案是開頭那行 #include <stdio.h>。它做的事只有一件:把 stdio.h 這個檔案的內容複製貼上到這裡。
這個檔案在哪?在 Dev-C++ 的資料夾裡,編譯器 MinGW64 的 lib\gcc\...\include 底下,可以看到一堆 .h 檔:stdio.h、string.h、stdlib.h 都在這裡。(實際位置依版本不同。)
直播把 stdio.h 的內容全選、複製,把 #include 那行刪掉、直接貼上,結果一樣能動。不過現在的 stdio.h 裡面又會 #include 別的檔案,貼過來之後因為不在同一個資料夾,可能要一路把那些檔案也拆開貼上才行。總之,#include 就是複製貼上,但我們當然不會笨到真的手動複製,直接 #include 就好。
打開 .h 檔,裡面有很多看不懂的特殊語法(那些是跟作業系統溝通之類用的,先不管),但仔細看會發現很多長這樣的東西:回傳型態、名字、括號、最後一個分號。這些全都是函式的宣告。
為什麼要把它們貼到最前面?跟剛剛的 sum 是同一個道理:我想用 printf,但 printf 的內容不在我前面,可能在別的檔案、在預設的函式庫裡,總之不在這裡。所以要先告訴編譯器它是什麼樣的函式,編譯才不會出錯。
這種只放函式宣告的檔案,叫標頭檔(header),副檔名是 .h。
編譯的流程
程式從原始碼變成可以執行的 .exe,會經過這幾步:
- 前置處理(preprocess):先處理所有
#開頭的指令。#include就把檔案複製貼上;#define就做尋找並取代。做完之後,程式裡不會再有任何#指令,只剩 C 語言的語法。 - 編譯(compile):每個
.c檔各自編譯。編譯main.c時,看到sum(a),編譯器只知道sum的宣告,不知道它的內容在哪。它只檢查用法對不對:給的引數對不對、回傳值接得對不對。對的話,就在這裡先留空,記下「這裡要用到sum」。每個.c檔編譯完,會變成一個中間檔(例如.o),已經翻譯得差不多了,只剩這些留空的地方。 - 連結(link):最後由連結器(linker)來玩連連看:「你要的
sum,在sum.c編譯出來的這裡!」把每個留空的地方都連到真正的內容。全部連起來之後,就變成可以執行的.exe。
編譯器只負責檢查語法,內容在哪裡不關它的事,就交給後面的連結器處理。
編譯錯誤和連結錯誤
照這個流程,錯誤也分成兩種:
連結錯誤:有宣告 int sum(int n);,但把 sum 的內容刪掉。語法是對的,編譯過得去,但連結器找不到 sum 的內容在哪裡,不知道空白要填什麼:
main.c:(.text+0x41): undefined reference to `sum'
collect2: error: ld returned 1 exit status
undefined reference 就是「沒有定義的參考」;ld 是連結器。
編譯錯誤:同樣沒有內容,但 sum 要一個整數,你一個都沒給,寫成 sum():
error: too few arguments to function 'sum'
這時它不會先說「找不到 sum」,而是先說「sum 的引數太少了」。這就告訴你順序:先編譯(檢查語法),再連結(找內容)。 語法在編譯這一步就錯了,根本還沒輪到連結器。
實際拆檔案:Dev-C++ 的專案
一個程式由好幾個檔案組成時,要把它們放在同一個專案(project)裡:
- 左上角「檔案」→「開新檔案」,這次不選「原始碼」,而是選「專案」。
- 選 Empty Project,再選 C 專案,取個名字,例如
SumProject,找個資料夾存起來(例如開一個叫sum的資料夾)。 - 把剛剛的程式放進專案裡,存成
main.c,主程式所在的地方。 - 在左邊的專案上按右鍵「新增檔案」,只放
sum的宣告,存成sum.h。 - 再新增一個檔案,把
sum的內容搬過去,存成sum.c。sum沒用到其他函式,所以sum.c不用再#include什麼。
三個檔案長這樣:
sum.h:
int sum(int n);
sum.c:
int sum(int n){
int total = 0, i;
for (i = 1; i <= n; i++) {
total += i;
}
return total;
}
main.c:
#include <stdio.h>
#include "sum.h" // 自己專案裡的標頭檔用雙引號
int main(){
int a, b;
scanf("%d%d", &a, &b);
printf("sumA = %d, sumB = %d\n", sum(a), sum(b));
return 0;
}
要注意 #include 的寫法:
- 編譯器預設資料夾裡的標頭檔,用小於大於:
#include <stdio.h>。 - 自己專案裡的標頭檔,用雙引號,寫專案裡的相對路徑:
#include "sum.h"。
執行一樣能跑,輸入 5 10 印出 sumA = 15, sumB = 55。main.c 和 sum.c 會各自編譯,因為它們在同一個專案裡,連結器知道要把它們連在一起。
不用 Dev-C++ 的話,用 gcc 編譯時把兩個
.c檔一起給它就好:gcc main.c sum.c -o sum。.h檔不用寫進去,它已經被#include複製貼上了。
這是專案開發最常見的做法:一個功能有一個 .c 放內容,一個 .h 放它的介面長什麼樣子。別人要用這個功能,不需要知道內容,只要知道介面就能寫程式。內容都還沒寫,怎麼知道它會回傳什麼?定義介面時,通常就已經想好這個功能要做什麼了:例如先規定「sum 給一個整數 n,回傳 1 加到 n 的結果」,用的人就能先拿它寫自己的部分,內容再由其他人去實作(implement)。
七、回收伏筆:回頭看第一堂的程式
講完這些,回頭看從第一堂用到現在的程式,每個部分都能解釋了:
#include <stdio.h>
int main(){
int a, b;
scanf("%d%d", &a, &b);
printf("%d + %d = %d\n", a, b, a + b);
return 0;
}
輸入 3 4,印出 3 + 4 = 7。
#include <stdio.h>在做什麼? 複製貼上。什麼時候?編譯之前,前置處理的時候。貼什麼?stdio.h,放在編譯器附帶的函式庫資料夾裡。為什麼要貼?因為要用printf,得先有它的標頭,讓編譯器知道printf的用法;內容則是連結的時候才連起來。main為什麼長這樣? 因為它是一個函式。小括號放要傳給它的東西,前面寫它會回傳的型態,大括號是它的內容。- 為什麼最後要
return 0? 因為main是函式,說好要回傳整數。作業系統看一個程式有沒有正確執行,就是看它回傳的值,其他數字可以代表各種錯誤。我們用不到,回傳 0 就好。 %d是什麼? 一種代號,代替這裡要放一個整數。為什麼叫%d?那是當初的人定的,換別的寫法也可以,沒有太大的含義。scanf的變數前面為什麼要加&? 只寫a的話,是把 a 的值交給scanf,它拿到使用者的輸入,也沒辦法改到 a 和 b。就像剛剛的swap:不給位置,它就只能改自己的變數,改不到別人的。所以要用&給它位置。printf為什麼不用&? 它只是要印出值,給它值就好,不需要改你的變數。printf也就只是一個函式。
真要說還有一個沒解釋的:為什麼 scanf、printf 可以給不固定數量的引數?那是因為它們是可變引數的函式。這個特性在入門時很少自己用到,有興趣可以自己查。
第一堂課一直不去談的東西,今天全部收回來了。
八、補充:字串的 scanf 為什麼不用 &
最後再看一下字串。scanf 要的是位置,但字元陣列的名字本身就是位置,所以讀字串時寫 scanf("%s", s) 就好,不用寫成 &s。
這不是「scanf 一定要加 &」這種公式,而是 scanf 要能修改你的值,只能透過位置。這樣想,字串不加 & 就很合理。
那加了 & 會怎樣?直播用一個 1000 格的字元陣列實驗,把 s 和 &s 的位置印出來,結果是同一個數字。但它們的含義不一樣,加 1 的時候就看得出來:
#include <stdio.h>
int main(){
char s[1000];
scanf("%999s", s); // s 本身就是位置,不用加 &
printf("%s\n", s);
printf("%p\n", (void *)s); // 第一格的位置
printf("%p\n", (void *)&s); // 整個陣列的位置:數字一樣
printf("%d\n", (int)((char *)(s + 1) - (char *)s)); // s + 1:跳 1 格(1 byte)
printf("%d\n", (int)((char *)(&s + 1) - (char *)s)); // &s + 1:跳過整個陣列(1000 bytes)
return 0;
}
輸入 hello,會印出(位置每次執行都可能不同):
hello
0x7ffe57a2aa80
0x7ffe57a2aa80
1
1000
s是第一格的位置,型態是「字元的位置」。對它加 1,跳一個字元的大小,也就是 1 byte。&s是整個陣列的位置,型態是「1000 格陣列的位置」。對它加 1,會把整個 1000 格全部跳過去。
所以直播上 s + 1 是 6486561,&s + 1 卻是 6487560,多了 1000。
平常在 scanf、printf 裡多加一個 &,剛好位置一樣,不太會出事;但哪天對它做加減,就會被雷到。用陣列時,盡量不要加 &,你要的通常只是第一格的位置,不是整個陣列。
直播把位置用
%d印成整數。印位置正確的格式是%p,給它的值要轉成(void *),印出來通常是十六進位;位置的大小也不一定放得進int。上面的範例改成%p,並用「兩個位置相減」直接算出跳了幾個 byte。scanf也照 #4 的補充寫成%999s,限制最多讀 999 個字。
小複習
- 函式就是把一段流程抽出來取個名字:
回傳型態 名字(引數){ 內容 },用return回傳結果。 - 呼叫函式時,回傳值會蓋掉呼叫的地方;回傳值只能有一個。
- 不回傳值的函式型態寫
void。 main也是函式,程式執行時第一個被執行。- 用到函式之前,前面要有它的宣告(prototype)或定義;宣告最後有分號,引數名字可以省略。
- 大括號裡宣告的是區域變數,離開就被回收;全域變數大家共用,但一改就影響全部,盡量少用。
- 函式拿到的是值的複製品;想改到呼叫者的變數,要傳位置(指標),在裡面用
*改。陣列名稱本身就是位置,引數可以寫成int *p或int p[]。 #include就是複製貼上;.h放宣告,.c放內容。編譯流程是前置處理 → 各自編譯 → 連結。undefined reference是連結錯誤。- 標準函式庫的標頭用
<>,自己專案的用""。
演練
- 把 1 加到 n 寫成函式
sum,在main裡呼叫兩次,再試著把sum移到main後面,加上宣告。 - 寫
double average(int score[], int n),回傳 n 個分數的平均,記得轉成double。 - 去 ZeroJudge 試試 a001,或挑幾題基礎題寫寫看。
寫好的話截圖發到 Twitter,加上 tag #悠太作業。有問題可以到 Discord 群組問,群裡有很多會寫程式的 Vtuber 和常客可以幫忙回答。剛開始照著別人的程式碼打也完全沒關係:學程式一開始本來就是照著打,現在連程式編輯器都會用 AI,從大量公開的程式碼裡猜你接下來最可能寫什麼。
下一堂預告
下週二不會是第七堂課,課程先停一週,讓大家把這六週的內容消化一下。函式是第一階段的結局:前面出現的伏筆都收完了,有了這六週的內容,理論上已經能寫出解決各種問題的程式。像有人會在黑畫面裡做可以上下左右移動的小遊戲,怎麼讀方向鍵、怎麼讓畫面動起來,現在就可以自己去查、去試。內建還有哪些函式,也不可能全部教,想做什麼就去查。
下週二適逢中秋連假,還是有程式直播:預計用 Unity 實況做一個烤肉小遊戲,把肉放上鐵網、翻面、烤到剛好的熟度拿給客人,限時內烤出最多的肉。之後再回來繼續第七堂。