函式是程式的基本建構單元。將重複的邏輯封裝成函式,可以提高程式碼的可讀性、可維護性和重用性。

一支好的程式不是「一大段做很多事的程式碼」,而是「許多各司其職的小函式互相合作」。學會切分函式,是從「會寫程式」邁向「會設計程式」的第一步。


學習目標

  1. 理解函式的宣告(declaration)與定義(definition)的差異,以及為什麼大型專案要分開放
  2. 掌握參數傳遞的四種方式:傳值、傳參考、傳 const 參考、傳指標,並知道何時該用哪一種與其效能影響
  3. 學會使用預設參數與函式多載,理解多載解析(overload resolution)的運作
  4. 理解 inline 在現代 C++ 的真正意義(不只是「展開」)
  5. 深入理解遞迴:呼叫堆疊、堆疊溢位、尾遞迴
  6. 認識 constexpr 函式與編譯期計算
  7. 初步認識函式指標
  8. 學會在標頭檔宣告、原始檔定義,以及將陣列傳入函式

一、函式的基本結構

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:對於「只讀取、不修改」的參數,小型廉價型別(如 intdouble、指標)用傳值;大型或複製成本未知的型別用 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);   // ********************(都不用預設)

規則與陷阱:

  1. 預設參數必須從右到左連續設定,不能跳過: cpp void f(int a, int b = 2, int c = 3); // ✓ void g(int a = 1, int b, int c); // ✗ 編譯錯誤
  2. 預設值應寫在宣告(通常在標頭檔),不要在定義重複寫,否則重定義錯誤: cpp void f(int a, int b = 10); // 宣告(.h)寫預設值 void f(int a, int b) { ... } // 定義(.cpp)不再寫
  3. 預設參數在呼叫端展開,與多載結合時可能造成歧義(見下節)。

四、函式多載(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,兩者一樣「好」→ 歧義錯誤

建議:多載應用於「概念相同、型別不同」的操作(如 print 各種型別)。若行為不同,請用不同名稱,別硬湊多載。


五、inline 函式與其現代意義

inline int square(int x) {
    return x * x;
}

很多教材說 inline 是「建議編譯器把函式呼叫展開成行內程式碼,減少呼叫開銷」。這在早期是對的,但現代意義已改變

  1. 編譯器自己決定是否展開:現代編譯器(開 -O2)會根據成本模型自動內聯,完全忽略你的 inline 建議。標 inline 不保證真的展開,不標也可能被展開。
  2. 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)

對應 C++ Core Guidelines

  1. F.1:把有意義、可命名的運算封裝成函式。函式應「只做一件事,並把它做好」。
  2. F.2:一個函式應執行單一邏輯操作,保持簡短(理想上一個螢幕看得完)。
  3. F.16:唯讀參數,廉價型別傳值(intdouble),昂貴型別傳 const T&
  4. F.17:「輸入/輸出」參數(要被讀也被改)用 T&
  5. F.21:要回傳多個值時,用 structstd::pair/tuple,別用一堆輸出指標參數。
  6. ES.20 / F.5:短小、需放標頭檔的函式才用 inline;別為效能濫用。
  7. 函式名用動詞calculateAreaisValid),讓呼叫處讀起來像句子。
  8. 優先用回傳值而非輸出參數;現代編譯器有 RVO(回傳值最佳化),不必擔心複製成本。

重點整理

概念 要點
宣告 vs 定義 宣告告知簽名,定義提供實作;分離以利大型專案編譯
傳值 複製、安全、適合小型型別
傳參考 T& 別名、可修改、不複製
傳 const 參考 const T& 不複製、唯讀,大型物件首選
傳指標 T* 可為空、可重指向、C API
預設參數 從右到左連續設定;預設值寫在宣告
函式多載 同名不同參數列表;回傳型別不算;注意歧義
inline 現代意義是「允許多重定義」,非效能保證
遞迴 必須有基底情況;注意呼叫堆疊與溢位
尾遞迴 遞迴呼叫是最後一步;C++ 不保證最佳化
constexpr 編譯期計算,零執行期開銷
陣列傳參 退化為指標,須額外傳大小

常見錯誤與陷阱

  1. 忘記基底情況:遞迴沒有停止條件 → 堆疊溢位(stack overflow),程式崩潰。
  2. 傳值卻期望修改:以為改了參數就改了原始變數,其實只改了副本。需要修改請用 &
  3. 預設參數順序錯誤:把有預設值的參數放在沒有預設值的參數前面 → 編譯錯誤。
  4. 預設值寫兩次:宣告與定義都寫預設值 → 重定義錯誤;只在宣告寫。
  5. 只靠回傳型別多載int f()double f() 無法多載。
  6. 多載歧義:隱式轉換讓多個版本都「一樣好」→ 編譯器報 ambiguous。
  7. 回傳區域變數的參考/位址:函式結束後該變數已銷毀 → 懸垂參考(dangling reference),UB。
  8. 非 void 函式漏掉 return:某條路徑沒回傳值 → UB(-Wall 會警告)。
  9. 以為 sizeof(陣列參數) 是陣列大小:在函式內它是指標大小(通常 8),務必另外傳 size
  10. 過度遞迴:樸素 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:傳遞方式實驗(基礎) 寫三個版本的 tripletripleByValue(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,每列印 widthfill

中級

練習 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

延伸閱讀