函式是程式的基本建構單元。將重複的邏輯封裝成函式,可以提高程式碼的可讀性、可維護性和重用性。
一支好的程式不是「一大段做很多事的程式碼」,而是「許多各司其職的小函式互相合作」。學會切分函式,是從「會寫程式」邁向「會設計程式」的第一步。
學習目標
- 理解函式的宣告(declaration)與定義(definition)的差異,以及為什麼大型專案要分開放
- 掌握參數傳遞的四種方式:傳值、傳參考、傳 const 參考、傳指標,並知道何時該用哪一種與其效能影響
- 學會使用預設參數與函式多載,理解多載解析(overload resolution)的運作
- 理解
inline在現代 C++ 的真正意義(不只是「展開」) - 深入理解遞迴:呼叫堆疊、堆疊溢位、尾遞迴
- 認識
constexpr函式與編譯期計算 - 初步認識函式指標
- 學會在標頭檔宣告、原始檔定義,以及將陣列傳入函式
一、函式的基本結構
1.1 為什麼需要函式?
想像沒有函式的世界:每次要計算圓面積,你都得重打一次 3.14159 * r * r。若公式要改(例如改用更精確的 π),你得在程式裡到處找。函式解決了三個核心問題:
| 問題 | 函式如何解決 |
|---|---|
| 重複(Repetition) | 寫一次,呼叫多次(DRY: Don't Repeat Yourself) |
| 複雜(Complexity) | 把大問題拆成小問題,各個擊破(分而治之) |
| 變動(Change) | 邏輯集中一處,修改只需改一個地方 |
1.2 宣告 vs 定義
// 宣告(declaration / prototype)——告訴編譯器「這個函式存在,長這樣」
int add(int a, int b);
// 定義(definition)——提供函式的實際實作
int add(int a, int b) {
return a + b;
}
- 宣告只需要回傳型別、函式名稱和參數型別(參數名稱可省略,例如
int add(int, int);)。 - 定義包含完整的函式主體
{}。 - 如果定義出現在呼叫之前,可以省略宣告。
- C++ 採用「先宣告才能使用」的規則:編譯器由上而下讀檔,呼叫一個函式時,它必須已經「看過」這個函式的簽名。
為什麼要分開? 因為編譯器只需要「簽名」就能檢查你的呼叫是否正確,實作可以放在別處(甚至別的檔案)。這正是標頭檔(.h)/ 原始檔(.cpp)分離的基礎,詳見第八節。
#include <iostream>
void sayHi(); // 宣告:先承諾「稍後會給實作」
int main() {
sayHi(); // 此時編譯器已知道 sayHi 的簽名,OK
return 0;
}
void sayHi() { // 定義:實作放在後面
std::cout << "Hi!\n";
}
1.3 回傳值
// 有回傳值
double circleArea(double radius) {
constexpr double PI = 3.14159265358979;
return PI * radius * radius;
}
// 無回傳值(void)
void greet(const std::string& name) {
std::cout << "你好," << name << "!\n";
}
重要:除了
main()之外,所有有回傳型別的函式都必須在每條路徑上回傳值。忘記回傳是未定義行為(UB),開-Wall編譯器會警告control reaches end of non-void function。
二、參數傳遞方式(本章核心)
這是初學者最容易混淆、卻最關鍵的主題。理解的關鍵是問兩個問題: 1. 函式需要修改呼叫者的變數嗎? 2. 這個物件複製起來貴不貴?
2.1 傳值(Pass by Value)
函式收到的是引數的一份複製品。函式內怎麼改都不影響原始變數。
void increment(int x) {
x++; // 只修改了區域副本,原始變數不受影響
}
int n = 5;
increment(n);
std::cout << n; // 仍然是 5
呼叫前 increment 執行中 呼叫後
n: 5 → n: 5 x: 5(副本) → n: 5
x++ → x: 6 (x 已銷毀)
- 優點:安全,函式不可能弄壞呼叫者的資料。
- 缺點:複製大型物件(如有一百萬元素的
vector)非常昂貴。
2.2 傳參考(Pass by Reference)
函式參數是引數的別名,操作參數就是操作原始變數。
void increment(int& x) {
x++; // 直接修改原始變數
}
int n = 5;
increment(n);
std::cout << n; // 變成 6
- 優點:可修改呼叫者變數;不複製,對大型物件高效。
- 缺點:呼叫端看不出來變數會被改(
increment(n)和傳值寫法一樣),需靠文件或慣例。
2.3 傳 const 參考(Pass by const Reference)
「不複製,但保證不修改」——這是 C++ 傳遞大型唯讀物件的黃金標準。
void printName(const std::string& name) { // 不複製字串,且不會改它
std::cout << name << '\n';
// name = "x"; // ✗ 編譯錯誤:name 是 const
}
C++ Core Guidelines F.16:對於「只讀取、不修改」的參數,小型廉價型別(如
int、double、指標)用傳值;大型或複製成本未知的型別用const T&。經驗法則:物件大於 2~3 個指標(約 16~24 bytes)就考慮const&。
2.4 傳指標(Pass by Pointer)
傳入變數的位址,函式需用 * 解參考才能存取值。
void increment(int* p) {
if (p) (*p)++; // 先檢查非空,再透過指標修改
}
int n = 5;
increment(&n); // 呼叫時要加 & 取位址
std::cout << n; // 變成 6
- 優點:可以是
nullptr(表達「可選 / 沒有」),與 C API 相容,可重新指向。 - 缺點:可能為空(需檢查),語法較囉嗦。
2.5 四種方式比較表
| 方式 | 語法 | 修改原始值 | 可為空 | 複製成本 | 何時使用 |
|---|---|---|---|---|---|
| 傳值 | f(int x) |
否 | — | 有 | 小型型別、不需修改 |
| 傳參考 | f(int& x) |
是 | 否 | 無 | 需修改呼叫者的變數 |
| 傳 const 參考 | f(const T& x) |
否 | 否 | 無 | 大型物件、唯讀(最常用) |
| 傳指標 | f(int* p) |
是 | 是 | 無 | 可選參數、C API、需重新指向 |
2.6 效能直覺
struct Big { int data[1000]; }; // 4000 bytes
void byValue(Big b); // 每次呼叫複製 4000 bytes ❌ 慢
void byConstRef(const Big& b); // 只傳一個位址(8 bytes)✅ 快
決策流程圖:
需要修改呼叫者的變數嗎?
├─ 是 ─→ 可能不存在/可選嗎?
│ ├─ 是 ─→ 用「指標」 T*
│ └─ 否 ─→ 用「參考」 T&
└─ 否 ─→ 物件大嗎(複製貴)?
├─ 是 ─→ 用「const 參考」 const T&
└─ 否 ─→ 用「傳值」 T (int、double、char...)
三、預設參數(Default Parameters)
void printLine(char ch = '-', int count = 40) {
for (int i = 0; i < count; i++) std::cout << ch;
std::cout << '\n';
}
printLine(); // ----------------------------------------(兩個都用預設)
printLine('='); // ======================================== count 用預設
printLine('*', 20); // ********************(都不用預設)
規則與陷阱:
- 預設參數必須從右到左連續設定,不能跳過:
cpp void f(int a, int b = 2, int c = 3); // ✓ void g(int a = 1, int b, int c); // ✗ 編譯錯誤 - 預設值應寫在宣告(通常在標頭檔),不要在定義重複寫,否則重定義錯誤:
cpp void f(int a, int b = 10); // 宣告(.h)寫預設值 void f(int a, int b) { ... } // 定義(.cpp)不再寫 - 預設參數在呼叫端展開,與多載結合時可能造成歧義(見下節)。
四、函式多載(Function Overloading)
同名函式可以有不同的參數列表(型別或數量不同):
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
int add(int a, int b, int c) { return a + b + c; }
std::string add(const std::string& a, const std::string& b) { return a + b; }
編譯器根據呼叫時的引數型別和數量,自動選擇最匹配的版本。
4.1 多載解析(Overload Resolution)順序
當你寫 add(...),編譯器依下列優先順序尋找最佳匹配:
1. 精確匹配(Exact match) ← 型別完全相同
2. 型別提升(Promotion) ← char→int, float→double
3. 標準轉換(Standard conversion) ← int→double, double→int
4. 使用者定義轉換(User-defined)
void print(int);
void print(double);
print('A'); // char 提升為 int → 呼叫 print(int)
print(3.14f); // float 提升為 double → 呼叫 print(double)
4.2 不能構成多載的情況
int f(int x);
double f(int x); // ✗ 錯誤!僅回傳型別不同,無法多載
原因:呼叫端 f(5); 可能不使用回傳值,編譯器無從判斷該選哪個。
4.3 多載歧義(Ambiguity)
void g(int);
void g(double);
g(5L); // ✗ long 可轉 int 也可轉 double,兩者一樣「好」→ 歧義錯誤
建議:多載應用於「概念相同、型別不同」的操作(如
五、inline 函式與其現代意義
inline int square(int x) {
return x * x;
}
很多教材說 inline 是「建議編譯器把函式呼叫展開成行內程式碼,減少呼叫開銷」。這在早期是對的,但現代意義已改變:
- 編譯器自己決定是否展開:現代編譯器(開
-O2)會根據成本模型自動內聯,完全忽略你的inline建議。標inline不保證真的展開,不標也可能被展開。 inline真正的語意是「允許多重定義」:它讓同一個函式定義能出現在多個翻譯單元(每個.cpp)而不違反 ODR(One Definition Rule)。這正是為什麼可以把函式定義寫在標頭檔——只要標inline。
// utils.h —— 因為標了 inline,可被多個 .cpp #include 而不會「重複定義」連結錯誤
inline int doubleIt(int x) { return x * 2; }
結論:別為了「效能」手動加
inline,把最佳化交給編譯器。當你想把短小函式定義在標頭檔時,才需要inline。
六、遞迴(Recursion)
遞迴函式會呼叫自己。每個遞迴函式必須具備: 1. 基底情況(Base Case):停止條件,不再遞迴。 2. 遞迴情況(Recursive Case):問題縮小並再次呼叫自身,且必須朝基底情況前進。
6.1 階乘(Factorial)
long long factorial(int n) {
if (n <= 1) return 1; // 基底情況
return n * factorial(n - 1); // 遞迴情況
}
// factorial(5) = 120
6.2 呼叫堆疊視覺化
每次函式呼叫都會在呼叫堆疊(call stack)上壓入一個「堆疊框(stack frame)」,存放區域變數與回傳位址。factorial(4) 的展開:
呼叫階段(往下壓堆疊) 回傳階段(往上彈堆疊)
factorial(4) factorial(4) = 4 * 6 = 24 ← 最終結果
└ 4 * factorial(3) factorial(3) = 3 * 2 = 6
└ 3 * factorial(2) factorial(2) = 2 * 1 = 2
└ 2 * factorial(1) factorial(1) = 1 ← 基底情況,開始回傳
└ return 1
堆疊在 factorial(1) 達到最深,然後逐層回傳、彈出。
6.3 堆疊溢位(Stack Overflow)
呼叫堆疊空間有限(通常數 MB)。遞迴太深或忘記基底情況,會耗盡堆疊:
int bad(int n) {
return bad(n + 1); // ✗ 永遠不停 → 堆疊溢位,程式崩潰
}
int factorial(int n) {
return n * factorial(n - 1); // ✗ 忘記基底情況!n 變成負數仍不停
}
6.4 尾遞迴(Tail Recursion)
若遞迴呼叫是函式的最後一個動作(回傳值直接是遞迴呼叫的結果,沒有額外運算),稱為尾遞迴。某些編譯器(開最佳化)能把它轉成迴圈,避免堆疊增長(尾呼叫最佳化,TCO)。
// 非尾遞迴:回傳後還要做 n * (...),乘法在遞迴呼叫「之後」
long long factA(int n) {
if (n <= 1) return 1;
return n * factA(n - 1); // 不是尾遞迴
}
// 尾遞迴:遞迴呼叫是最後一步,結果直接回傳
long long factB(int n, long long acc = 1) {
if (n <= 1) return acc;
return factB(n - 1, acc * n); // 尾遞迴(用累加器 acc 帶著結果)
}
注意:C++ 標準不保證做尾呼叫最佳化(與函數式語言如 Scheme 不同)。對深度遞迴,最可靠的做法仍是改寫成迴圈。
6.5 遞迴 vs 迭代
| 比較項目 | 遞迴 | 迭代 |
|---|---|---|
| 可讀性 | 對樹、分治問題較直觀 | 簡單迴圈較直觀 |
| 記憶體 | 每層一個堆疊框,較多 | 通常常數空間 |
| 效能 | 函式呼叫有開銷 | 通常較快 |
| 風險 | 可能堆疊溢位 | 無 |
| 適用 | 樹/圖走訪、分治法(quicksort、河內塔) | 線性掃描、累加 |
Fibonacci 的樸素遞迴是 O(2ⁿ)(大量重複計算),請改用迭代或記憶化(memoization)降到 O(n)。
七、constexpr 函式(編譯期計算)
constexpr 函式在引數是編譯期常數時,於編譯期就算出結果,零執行期開銷。
constexpr int square(int x) { return x * x; }
constexpr int n = square(5); // 編譯期算出 25
int arr[square(3)]; // OK:陣列大小需編譯期常數 → 9
int x = readFromUser();
int y = square(x); // x 非常數 → 退化為一般執行期呼叫,仍合法
- 適合數學常數、查表、編譯期可決定的計算。
- C++14 起
constexpr函式可包含迴圈、區域變數等,限制大幅放寬。
八、標頭檔宣告 vs 原始檔定義
大型專案的標準做法:宣告放標頭檔(.h),定義放原始檔(.cpp)。
// math_utils.h —— 介面
#ifndef MATH_UTILS_H // include guard,防止重複引入
#define MATH_UTILS_H
int add(int a, int b); // 只放宣告
double circleArea(double r);
#endif
// math_utils.cpp —— 實作
#include "math_utils.h"
int add(int a, int b) { return a + b; }
double circleArea(double r) { return 3.14159265 * r * r; }
// main.cpp —— 使用者
#include "math_utils.h" // 只需看到宣告就能呼叫
int main() {
return add(3, 4);
}
為什麼這樣分?
- 介面(怎麼用)與實作(怎麼做)分離,使用者只需讀標頭檔。
- 改實作(.cpp)不必重編譯所有引入者,加快編譯。
- 避免重複定義:函式定義在整個程式中只能有一份(ODR)。若把定義放進標頭檔再被多個 .cpp 引入,連結時會「重複定義」報錯——除非該函式是 inline(見第五節)。
九、把陣列傳入函式
C 風格陣列傳入函式時會退化為指標(decay to pointer),喪失大小資訊,因此必須額外傳大小。
// 以下三種寫法完全等價,arr 其實都是 int*!
void printArray(int arr[], int size);
void printArray(int arr[5], int size); // 那個 5 會被編譯器忽略
void printArray(int* arr, int size); // 最誠實的寫法
void printArray(const int arr[], int size) {
for (int i = 0; i < size; i++) std::cout << arr[i] << ' ';
std::cout << '\n';
}
int data[] = {1, 2, 3, 4, 5};
printArray(data, 5);
// 注意:函式內 sizeof(arr) 是「指標大小」(8),不是陣列大小!
現代建議:傳
std::array/std::vector(用const&)或std::span(C++20),它們會保留大小資訊,更安全。詳見 Ch05。
最佳實踐(Best Practices)
- F.1:把有意義、可命名的運算封裝成函式。函式應「只做一件事,並把它做好」。
- F.2:一個函式應執行單一邏輯操作,保持簡短(理想上一個螢幕看得完)。
- F.16:唯讀參數,廉價型別傳值(
int、double),昂貴型別傳const T&。 - F.17:「輸入/輸出」參數(要被讀也被改)用
T&。 - F.21:要回傳多個值時,用
struct或std::pair/tuple,別用一堆輸出指標參數。 - ES.20 / F.5:短小、需放標頭檔的函式才用
inline;別為效能濫用。 - 函式名用動詞(
calculateArea、isValid),讓呼叫處讀起來像句子。 - 優先用回傳值而非輸出參數;現代編譯器有 RVO(回傳值最佳化),不必擔心複製成本。
重點整理
| 概念 | 要點 |
|---|---|
| 宣告 vs 定義 | 宣告告知簽名,定義提供實作;分離以利大型專案編譯 |
| 傳值 | 複製、安全、適合小型型別 |
傳參考 T& |
別名、可修改、不複製 |
傳 const 參考 const T& |
不複製、唯讀,大型物件首選 |
傳指標 T* |
可為空、可重指向、C API |
| 預設參數 | 從右到左連續設定;預設值寫在宣告 |
| 函式多載 | 同名不同參數列表;回傳型別不算;注意歧義 |
inline |
現代意義是「允許多重定義」,非效能保證 |
| 遞迴 | 必須有基底情況;注意呼叫堆疊與溢位 |
| 尾遞迴 | 遞迴呼叫是最後一步;C++ 不保證最佳化 |
constexpr |
編譯期計算,零執行期開銷 |
| 陣列傳參 | 退化為指標,須額外傳大小 |
常見錯誤與陷阱
- 忘記基底情況:遞迴沒有停止條件 → 堆疊溢位(stack overflow),程式崩潰。
- 傳值卻期望修改:以為改了參數就改了原始變數,其實只改了副本。需要修改請用
&。 - 預設參數順序錯誤:把有預設值的參數放在沒有預設值的參數前面 → 編譯錯誤。
- 預設值寫兩次:宣告與定義都寫預設值 → 重定義錯誤;只在宣告寫。
- 只靠回傳型別多載:
int f()與double f()無法多載。 - 多載歧義:隱式轉換讓多個版本都「一樣好」→ 編譯器報 ambiguous。
- 回傳區域變數的參考/位址:函式結束後該變數已銷毀 → 懸垂參考(dangling reference),UB。
- 非 void 函式漏掉 return:某條路徑沒回傳值 → UB(
-Wall會警告)。 - 以為
sizeof(陣列參數)是陣列大小:在函式內它是指標大小(通常 8),務必另外傳size。 - 過度遞迴:樸素 Fibonacci 在大 n 時 O(2ⁿ) 極慢;改用迭代或記憶化。
練習題(至少 5 題)
基礎
練習 1:溫度轉換(基礎)
寫兩個函式 double celsiusToFahrenheit(double c) 與 double fahrenheitToCelsius(double f),分別做攝氏↔華氏轉換,並在 main 測試 0°C、100°C。
提示:公式
F = C * 9/5 + 32;注意用9.0/5.0避免整數除法。
練習 2:傳遞方式實驗(基礎)
寫三個版本的 triple:tripleByValue(int)、tripleByRef(int&)、tripleByPtr(int*),各自把數字變三倍。在 main 觀察哪個會改變原始變數。
提示:傳值不會改變原值;指標版呼叫時要加
&,函式內要*p。
練習 3:預設參數(基礎)
寫 void drawBox(int width = 10, int height = 3, char fill = '#'),用巢狀迴圈印出一個矩形。分別以 drawBox()、drawBox(5)、drawBox(5, 2, '*') 測試。
提示:外層迴圈跑
height,內層跑width,每列印width個fill。
中級
練習 4:函式多載(中級)
寫多載函式 maxValue:分別接受 (兩個 int)、(兩個 double)、(三個 int)。三參數版本可內部呼叫兩參數版本。
提示:
maxValue(a, b, c)可寫成maxValue(maxValue(a, b), c)。
練習 5:最大公因數(中級)
用遞迴實作歐幾里得演算法 int gcd(int a, int b)。
提示:基底情況
b == 0時回傳a;遞迴情況gcd(b, a % b)。
練習 6:回文判斷(中級)
寫遞迴函式 bool isPalindrome(const std::string& s, int left, int right) 判斷字串是否為回文。
提示:基底情況
left >= right回傳true;若s[left] != s[right]回傳false;否則遞迴isPalindrome(s, left+1, right-1)。
挑戰
練習 7:河內塔(挑戰)
用遞迴解河內塔 void hanoi(int n, char from, char to, char aux),印出每一步移動(如 將盤子 1 從 A 移到 C)。
提示:把上面
n-1個從from移到aux,搬第n個到to,再把n-1個從aux移到to。盤數 n 的移動次數為 2ⁿ−1。
練習 8:記憶化費波那契(挑戰)
先寫樸素遞迴 fib(n),再用一個 std::vector<long long> 當快取改寫成記憶化版本,比較兩者在 n=40 時的速度差異。
提示:快取初始化為 -1;每次先查快取,算過就直接回傳,否則算完存進快取。
練習 9:constexpr 編譯期階乘(挑戰)
寫 constexpr long long factorial(int n),並用 constexpr long long f10 = factorial(10); 驗證它在編譯期算出 3628800。試著用它當陣列大小 int arr[factorial(4)];。
提示:C++14 起
constexpr可用迴圈;確認結果在編譯期就確定。
對應範例程式
| 檔案 | 說明 |
|---|---|
| functions_basic.cpp | 函式定義、呼叫、回傳值、參數傳遞、預設參數 |
| overloading.cpp | 函式多載與多載解析 |
| recursion.cpp | 遞迴:階乘、費波那契、陣列加總、次方、二分搜尋 |
編譯與執行
g++ -std=c++17 -Wall -o functions_basic functions_basic.cpp && ./functions_basic
g++ -std=c++17 -Wall -o overloading overloading.cpp && ./overloading
g++ -std=c++17 -Wall -o recursion recursion.cpp && ./recursion