先讀語法與例子,再操作互動圖解並完成四選一自測。忘記寫法時回看速查表,最後用中英詞彙卡複習。
函式把一段工作取名字,呼叫時交給它輸入,完成後可把結果交回。先認識每個語法位置,再比較後面的傳值、參考與指標。
int deliveryFee(int count) 中,第一個 int 是回傳型別,deliveryFee 是名稱,int count 是參數。大括號是本體;return 結束呼叫並回傳值。呼叫 deliveryFee(3) 時,引數 3 交給 count;函式不會因定義寫在 main 前面就先執行。
36
呼叫回來後,fee 取得 36,main 再顯示它。void 表示不回傳值;cout 顯示文字與 return 交出結果是不同動作。下一節再看宣告與定義如何安排。
C++ 在編譯一個來源檔時,函式呼叫處必須已看得到相符的宣告。若定義放在 main 後面,就先在前面寫函式原型,讓編譯器能檢查引數與回傳型別。
[編譯失敗]
cell.cpp: In function ‘int main()’:
cell.cpp:5:13: error: ‘square’ was not declared in this scope
5 | cout << square(5) << endl; // 編譯器讀到這裡時還沒見過 squareerror: ‘square’ was not declared in this scope。這是初學最常收到的錯誤訊息之一,意思很單純:你用了一個編譯器還沒認識的名字。
解法是先寫一行函式原型(function prototype),把回傳型別、名稱、參數型別交代清楚,結尾用分號。這一行就是宣告;有函式本體的那一份才是定義。
square(5) = 25
宣告只需要型別,參數名可以省略:int square(int); 一樣合法。編譯器需要函式名稱、參數型別與回傳型別來檢查呼叫;參數名稱不影響函式型別。
| 宣告 declaration | 定義 definition | |
|---|---|---|
| 長相 | int square(int n); | int square(int n) { ... } |
| 作用 | 告訴編譯器這個名字的長相 | 提供實際的程式碼 |
| 可以出現幾次 | 幾次都行 | 本頁的一般函式在整個程式中只有一份定義 |
| 放哪個檔 | .hpp | .cpp |
一般的分工是宣告放標頭檔、定義放 .cpp;引入標頭後,其他檔案就能檢查函式呼叫。模板是另一種情況:編譯器通常需要看見完整定義才能依使用型別產生版本,因此模板定義常放在標頭。P8、P9 再分別介紹多檔組織與模板。
void f(int n) 這種寫法叫傳值(pass by value)。呼叫的瞬間,編譯器把你給的東西整個抄一份放進函式的參數裡。函式接下來怎麼折騰那份副本,外面那個都不會知道。
這個規則沒有例外,而且對物件也一樣。物件被複製時,編譯器會呼叫它的複製建構式(copy constructor)。下面這支程式把複製這件事印出來給你看。
傳值複製自訂物件時,會由型別決定複製工作;完整複製建構式示範放在 P8 類別與物件,先理解本頁的副本與別名即可。
| 傳值的時機 | 理由 |
|---|---|
參數是 int、double、char、bool 這種小東西 | 介面簡單,通常不需為避免小型值複製而改用參考 |
| 函式本來就想要一份自己的,改了不該影響呼叫端 | 複製本身就是你要的效果 |
| 大物件 | 別用傳值。用 const T& |
Python 傳的一律是物件的參考,所以一個 list 傳進函式,函式裡 append 一下外面就跟著變;整數則因為不可變而看起來像傳值。C++ 把這件事交給你在參數列上明講:抄一份、還是給本人。講清楚的代價是多打幾個字,好處是讀程式的人光看簽章就知道這個函式會不會動到你的資料。
int &r = x; 讀作「r 是 x 的參考」。在語言語意上,r 是既有物件 x 的別名,而不是可改指向的指標;編譯器如何實作參考、是否需要額外儲存空間,不能由這項語意推定。
改了 r 之後 x = 99 x 的位址 = 0x7ffe931b930c r 的位址 = 0x7ffe931b930c <- 完全一樣 sizeof(x)=4 sizeof(r)=4
對 r 取址得到的是被參考物件 x 的位址,因此兩個位址相同;sizeof(r) 量到的也等同 sizeof(x)。這些結果不能用來推定參考本身是否需要額外儲存空間。對照 P4 的指標:指標是保存位址的物件,其大小依平台而異。
參數列上寫 T&,函式收到的就是呼叫端那個變數本人:
呼叫 addOneWrong 之後: 60 呼叫 addOneRight 之後: 61
兩個函式的本體完全相同,只有參數列差一個 &,結果一個做白工、一個真的加到了。編譯這支程式時 g++ 一個字都不會抱怨——addOneWrong 在語法上完全合法,它只是沒有用。
傳值 void f(T x) | 傳參考 void f(T& x) | |
|---|---|---|
| 函式拿到什麼 | 一份副本 | 呼叫端那個變數本人 |
| 改得動外面嗎 | 改不動 | 改得動 |
| 複製成本 | 整個物件抄一遍 | 零 |
| 呼叫端寫法 | f(x); | f(x); ← 看不出差別 |
| 可以傳 3、傳 a+b 嗎 | 可以 | 不行,非 const 參考只收得下有名字的變數 |
f(x); 這一行,無論 f 收的是值還是參考,寫法完全一樣。所以你必須去看函式簽章才知道自己的變數會不會被改掉。指標就沒這個問題:f(&x) 那個 & 明擺在呼叫端。這是下一節要談的取捨。
如果把調整分數的函式寫成 void adjustScores(vector<int> v)(沒有 &),呼叫 adjustScores(data) 後再印 data,函式內的修改會留在原資料嗎?
先辨認這三個位置:int value=3; 建立整數;&value 取得位址;int* p=&value; 建立保存位址的指標,*p 才代表原整數。下面先用這個最小模型讀參數,完整記憶體圖在 P4。
第三種寫法是 void f(T* p),呼叫時寫 f(&x)。函式收到一個位址,用 *p 存取目標。與參考參數相比,指標還能以 nullptr 表示沒有目標,也能在函式內改為指向別處。
tryFill(&a) 之後 a = 42 收到 nullptr,什麼都不做 傳 nullptr 進去會安全返回 mustFill(b) 之後 b = 42 p 改指向 d 之後 *p = 2
tryFill(nullptr) 是合法呼叫,函式檢查後安全返回。參考沒有一般的空值狀態,適合表達參數必須綁定有效物件的介面;物件生命週期仍由呼叫端負責。最後兩行展示指標可以改指向另一個目標。
目標一定存在且需要修改時,參考常能清楚表達需求;允許「沒有目標」或需要描述位址時可用指標。注意指標參數本身也是副本,改函式內的指向不會自動改呼叫端指標。完整比較見 P4。
下面這個逐步器跑的就是開場那支程式。左邊是兩個函式框(呼叫端與被呼叫端)在記憶體裡的樣子,右邊的程式碼會同步高亮。三個按鈕換傳法,注意被呼叫端那兩格是新的還是舊的。
傳值:callee 那格是新配的,位址不同。
傳參考:callee 那格根本沒有新配,a 只是 x 的另一個標籤。
傳指標:callee 多了兩格,裡面裝的是 x 和 y 的門牌。
const T&:不複製,也不讓你改前面兩節各有一項限制:傳值可能產生複製成本,傳參考則允許函式改動呼叫端資料。const T& 結合兩者需要的特性:參數不複製,而且編譯器禁止函式透過它修改資料。
[編譯失敗]
cell.cpp: In function ‘void peek(const int&)’:
cell.cpp:5:7: error: assignment of read-only reference ‘v’
5 | v = 99; // const 參考不准改error: assignment of read-only reference。這是編譯期就擋下來的,不是跑到一半才出事。const 在 C++ 是一個會被檢查的承諾,不是註解。
| 你想做的事 | 寫法 | 複製嗎 | 改得動嗎 |
|---|---|---|---|
| 參數很小,函式想要一份自己的 | void f(int n) | 會 | 只改副本 |
| 要改動呼叫端的資料 | void f(T& x) | 不會 | 會 |
| 只是要讀一個大物件 | void f(const T& x) | 不會 | 不會 |
| 參數可能是「沒有」,或要換目標 | void f(T* p) | 不會 | 會 |
第三列是傳大物件的標準做法。看到 const vector<int>& v 這種簽章不用緊張,它只是在說「我要看你的資料,我保證不動它,而且我不想抄一份」。
物件成員函式後面的 const 必須先理解類別;原有成功與失敗對照完整放在 P8。
回傳值讓呼叫端取得獨立的結果,所以回傳一般區域變數的值可以是安全的。對類別型別,實際可能複製、移動或省略複製,不能推論每次 return 都有一份額外複製。回傳 T& 則提供原物件的參考,使用時必須確認該物件仍存活。
回傳參考 T& 就不一樣了。它交出去的是「那個東西本人」,所以那個東西必須在函式結束後還活著。
count = 8
currentValue 回傳 main 中 count 的參考,原物件在呼叫結束後仍存在,所以呼叫結果可作為賦值左側。不能把它改成回傳函式內區域變數的參考。
回傳區域變數的位址或參考。區域變數住在呼叫堆疊上,函式一 return,那塊空間就還給系統了,你交出去的是一張過期的門牌。
cell.cpp: In function ‘int* makeNumber()’:
cell.cpp:6:12: warning: address of local variable ‘local’ returned [-Wreturn-local-addr]
6 | return &local; // 回傳一個馬上要失效的位址
| ^~~~~~
cell.cpp:5:9: note: declared here
5 | int local = 1234; // 住在堆疊上,函式一結束就沒了
| ^~~~~-Wreturn-local-addr 這個警告不需要 -Wall 就會出現。看到 warning 不要跳過——C++ 的警告訊息裡,這一類幾乎百分之百是真的錯。
錯誤示範:使用已失效的位址,行為未定義,沒有可依賴的執行輸出。
這是不可執行的錯誤示範:local 的生命週期在函式返回時結束,p 無法合法指向可供讀取的 local。不同編譯器可能給出不同診斷及執行結果;不能依一次看到的位址、數值或終止訊號推論 C++ 的保證。
如果只要把計算結果交給呼叫端,直接回傳值最清楚,也不需要管理額外的生命週期:
讀出來 = 1234
區域變數 value 會在函式結束時消失,但它的值已用 return 交給呼叫端,因此 n 可安全取得 1234。只有確實需要動態生命週期時,才要另外安排所有權;new/delete 與自動管理資源的觀念見 P4。
C++ 允許好幾個函式共用一個名字,只要參數的個數或型別不同就行。編譯器會依照你呼叫時給的東西挑一個,這叫函式多載(function overloading)。挑選只看參數,回傳型別不算——兩個函式只有回傳型別不同的話會直接編譯錯。
同名 deliveryFee 依參數個數選擇版本;三個呼叫各自選中兩參數、一參數與無參數版本,不需要外部標頭。
30 60 12
同名 deliveryFee 依參數個數選擇版本;三個呼叫各自選中兩參數、一參數與無參數版本,不需要外部標頭。
另一條路是給參數一個預設值,呼叫時可以省略:
小明 92 通過 小華 55 通過 小美 60 通過
三次呼叫分別給了 3、2、1 個引數。省略的部分從左到右對不上就補預設值,所以 report("小華", 55) 的 pass 用的是 true。
因為呼叫端是由左往右對位的。如果 void f(int a = 1, int b) 合法,那 f(5) 到底是給 a 還是給 b?編譯器不猜,直接拒絕。
[編譯失敗]
cell.cpp:5:36: error: default argument missing for parameter 2 of ‘void report(int, std::string)’
5 | void report(int score = 60, string name) { // 有預設值的參數放前面
| ~~~~~~~^~~~default argument missing for parameter 2,訊息說得很白:第 2 個參數也得有預設值。規則記成一句話:有預設值的參數必須排在最後面,而且一路排到底。
同時寫 void f(int a) 與 void f(int a, int b = 0),那 f(3) 兩邊都符合,編譯器會回報 call of overloaded ... is ambiguous。這兩個工具解決的是同一個問題,挑一個用就好。
每次呼叫函式,系統會在呼叫堆疊(call stack)上開一個新的堆疊框(stack frame),裡面放這一次呼叫的參數、區域變數,以及「回去之後要從哪一行繼續」。函式回傳時這個框就被彈掉。遞迴沒有任何特別之處,它只是讓框疊得比較高。
進入 sumTo(4)
進入 sumTo(3)
進入 sumTo(2)
進入 sumTo(1)
離開 sumTo(1) 回傳 1
離開 sumTo(2) 回傳 3
離開 sumTo(3) 回傳 6
離開 sumTo(4) 回傳 10
答案 = 10縮排就是堆疊的高度。注意四個「進入」全部印完,才開始印「離開」——因為 n + sumTo(n - 1) 這一行必須等右邊算完才能相加,所以 sumTo(4) 的框會一直卡在堆疊上等。這就是為什麼遞迴的空間複雜度是 O(深度)。
基底情形:小到不必再遞迴的情況,這裡是 n <= 1。
遞迴情形:把問題變小一號再交給自己。
少了基底情形,或問題沒有真的變小,堆疊就會一直疊上去。
呼叫堆疊的可用容量依作業系統、執行緒設定與編譯方式而異。即使有基底情形,過深的遞迴仍可能耗盡資源;此外也須檢查計算結果是否超過回傳型別範圍。
風險示範,不提供固定輸出:過深遞迴可能耗盡堆疊,總和也可能使 int 溢位。
在可容納結果且堆疊足夠的環境中,sumTo(1000) 得到 500500。sumTo(1000000) 除了可能耗盡堆疊,結果 500000500000 也超出常見 32 位元 int。此例不作為可執行範本;只換較寬型別仍不能解決過深呼叫,應選擇適合的迴圈或其他做法。
每個框裝的是這一次呼叫專屬的參數與區域變數。所以 sumTo(4) 的 n 跟 sumTo(3) 的 n 是兩個不同的東西,互不干擾。這也回頭解釋了 PART 06:區域變數活在框裡,框一彈掉它就沒了,回傳它的位址當然拿不到東西。
有人寫一個函式,裡面宣告 int n=31;,然後回傳 &n。呼叫端經由這個位址讀取整數。問題是什麼?
傳值 f(T x) | 傳參考 f(T& x) | const 參考 f(const T& x) | 傳指標 f(T* p) | |
|---|---|---|---|---|
| 函式拿到什麼 | 副本 | 本人的別名 | 本人的唯讀別名 | 本人的位址 |
| 改得動呼叫端嗎 | 不行 | 可以 | 不行(編譯期擋) | 可以 |
| 複製成本 | 整個物件 | 零 | 零 | 一個指標值(大小依實作) |
| 可以是「沒有」嗎 | 不行 | 不行 | 不行 | 可以,nullptr |
| 綁定後能換目標嗎 | — | 不行 | 不行 | 可以 |
| 呼叫端寫法 | f(x); | f(x); | f(x); | f(&x); |
| 函式裡取值 | x | x | x | *p |
| 什麼時候用 | 小型別 | 要就地改 | 只讀大物件 | 可能沒有、要換目標 |
| 你在 Python 怎麼做 | C++ 的對應 | 差在哪 |
|---|---|---|
| 函式定義寫在檔案任何地方都能用 | 要先宣告或先定義 | C++ 由上而下編譯一遍,沒見過的名字就是錯 |
| 參數一律傳物件參考 | T x / T& x / T* p | C++ 要你在簽章上明講抄不抄 |
| 函式裡對 list 做 append,外面跟著變 | void f(vector<int>& v) | 少了 & 就完全不會變 |
| 函式裡對整數重新指派,外面不變 | void f(int n) | 行為剛好一樣,理由不同 |
參數預設值 score=60 | int score = 60 | C++ 規定有預設值的一律排在最後 |
| 同名函式只能有一個,後蓋前 | 多載,同名可以好幾個 | C++ 靠參數型別挑 |
| 回傳一個區域 list 完全安全 | 回傳區域變數的位址是未定義行為 | Python 有垃圾回收,C++ 的堆疊框說收就收 |
| 遞迴太深丟 RecursionError | 遞迴太深可能耗盡堆疊,具體失敗方式依實作 | C++ 不幫你檢查深度 |
① 函式要改動呼叫端的資料,參數列一定要有 &。
② 只讀的大物件用 const T&,別用傳值。
③ 類別的唯讀成員函式通常需要加 const;完整規則見 P8。
④ 參數可能是「沒有」時才用指標,其餘場合優先用參考。
⑤ 永遠不要回傳區域變數的位址或參考。
| 資源 | 看什麼 |
|---|---|
| C++ Tutor | 把本頁的 swap 三個版本貼進去單步跑,看兩個函式框裡的方格與箭頭。 |
| cppreference · Reference declaration | 參考的完整規則,包含它為什麼不能重新綁定。 |
| cppreference · Default arguments | 預設引數的完整限制。 |
| 先備 P4 · 陣列、指標與動態記憶體 | 下一頁。* 與 &、堆疊與堆積、new/delete 的完整版。 |
| 本站 · 遞迴 | 把 PART 08 的呼叫堆疊實際用起來。 |
每個選項都有解說:選錯也點開看看為什麼錯。全對之後再往下翻詞彙卡。
#include <iostream>
using namespace std;
void bump(int n) { n = n + 10; }
int main() {
int n = 5;
bump(n);
cout << n << endl;
return 0;
}
#include <iostream>
using namespace std;
void bump(int &n) { n = n + 10; }
int main() {
int n = 5;
bump(n);
cout << n << endl;
return 0;
}
#include <iostream>
using namespace std;
void grow(int *p) { *p = *p * 2; }
int main() {
int v = 21;
grow(&v);
cout << v << endl;
return 0;
}
#include <iostream>
using namespace std;
int f(int a, int b = 2) { return a * b; }
double f(double a) { return a + 0.5; }
int main() {
cout << f(3) << " " << f(3, 4) << " " << f(3.0) << endl;
return 0;
}
#include <iostream>
using namespace std;
int mystery(int n) {
if (n == 0) return 0;
return n % 10 + mystery(n / 10);
}
int main() {
cout << mystery(9305) << endl;
return 0;
}
先看正面術語,心中默想定義再翻面對答案;洗牌後再過一輪,直到每張都能不假思索說出來。