C++ 函數與標頭檔:宣告、多載與分離編譯
從傳值、傳參考到指標,看 C++ 如何把函數的抽象建立在靜態型別與編譯期決策之上,並深入多載解析與名稱修飾的底層機制。
從一支「重複計算面積」的程式,認識 C++ 函數
假設你正在寫一個小工具,要算好幾個矩形的面積。最直覺的寫法是把 寬 * 高 抄好幾遍:
#include <iostream>
int main() {
int w1 = 3, h1 = 4;
std::cout << w1 * h1 << "\n"; // 第一個矩形
int w2 = 5, h2 = 2;
std::cout << w2 * h2 << "\n"; // 第二個矩形
// ... 抄到天荒地老
return 0;
}
問題不在於「太長」,而在於:當你某天發現面積要改成「寬 * 高 再加上邊框」,你得逐處修改、漏一個就出錯。函數(function)的存在,就是把一段「有名字、可重複呼叫、單一職責」的邏輯收進一個盒子裡:
int area(int width, int height) {
return width * height;
}
之後任何地方只要寫 area(3, 4),就拿到 12。聽起來和 Python 的 def area(width, height): 沒兩樣——但 C++ 在這層抽象底下藏了一整套靜態型別、編譯期檢查、記憶體佈局的機制。這篇文章的重點,不是教你「函數是什麼」(你在 Python 篇已經懂了),而是帶你看 C++ 的函數和其他語言到底差在哪,以及這些差異為什麼存在。

宣告與定義:C++ 把「告知」和「實作」拆開
在 Python 裡,函數只有「定義」一種狀態:你 def 它,它就存在了。C++ 卻刻意把一件事拆成兩半:
- 宣告(declaration):告訴編譯器「有一個叫
area的函數,吃兩個int、回傳int」,但不講內容。又叫函數原型(prototype)。 - 定義(definition):真正寫出函數體
{ ... }。
#include <iostream>
int area(int width, int height); // 宣告(原型),注意結尾的分號
int main() {
std::cout << area(3, 4) << "\n"; // 此時編譯器已知道 area 的「長相」
return 0;
}
int area(int width, int height) { // 定義寫在後面也沒關係
return width * height;
}
為什麼要這麼麻煩?因為 C++ 編譯器是由上往下一遍掃過去的。在 main 裡呼叫 area 時,編譯器必須已經知道 area 收什麼、回傳什麼,才能做型別檢查、才能正確產生呼叫指令。先給一行宣告,就等於先打個招呼。這個「宣告與定義分離」的設計,正是後面標頭檔與分離編譯的地基。
相較於 Python 的「執行到
def才存在」,C++ 是「編譯期就要敲定每個名字的型別」。多打的這幾個字,換來的是編譯器在你按下執行前,就幫你抓出一大票錯誤。
傳值、傳參考、傳指標:C++ 給你三種「怎麼把資料交給函數」
這是 C++ 與 Python 最分歧、也最常讓初學者踩雷的地方。Python 的參數傳遞只有一種模型(物件參考);C++ 給你三種明確可選的方式,而你必須親手選對。
傳值(pass by value):函數拿到一份影本
void tryToModify(int x) {
x = 999; // 改的是「影本」
}
int main() {
int a = 5;
tryToModify(a);
std::cout << a << "\n"; // 輸出:5(沒被改到)
return 0;
}
int x 是 a 的拷貝。函數內怎麼改都動不到外面的 a。安全,但若參數是個很大的物件(例如有一百萬筆資料的 std::vector),複製整份會很貴。
傳參考(pass by reference):函數拿到本尊的別名
在型別後面加 &,參數就成了外面那個變數的別名(alias):
void doModify(int& x) { // 注意 int&
x = 999; // 直接改到本尊
}
int main() {
int a = 5;
doModify(a);
std::cout << a << "\n"; // 輸出:999
return 0;
}
傳參考有兩個用途:真的想修改呼叫端的變數,以及避免大物件複製。當你只想避免複製、但保證不改它時,請用 const 參考:
// 大物件、唯讀:用 const 參考,零複製又防手滑
double averageOf(const std::vector<double>& data) {
double sum = 0;
for (double v : data) sum += v;
return data.empty() ? 0.0 : sum / data.size();
}
const std::vector<double>& 表示「我借用你的 vector 來讀,不複製、也保證不改」。這是 C++ 函數參數的慣用預設:能用 const 參考就別用傳值(對於 int、double 等小型別則仍直接傳值,因為複製成本比建立參考還低)。
傳指標(pass by pointer):傳一個「地址」
指標(pointer)是 C++(繼承自 C)的招牌特色,Python 完全沒有對應物。指標變數存的是另一個變數的記憶體位址,用 & 取址、用 * 解參考:
void doModifyPtr(int* x) { // x 是「指向 int 的指標」
if (x != nullptr) { // 指標可能為空,務必檢查
*x = 999; // *x 解參考,存取它指到的那個 int
}
}
int main() {
int a = 5;
doModifyPtr(&a); // 傳 a 的地址
std::cout << a << "\n"; // 輸出:999
return 0;
}
那何時用指標、何時用參考?現代 C++ 的慣例是:
- 預設用參考,語法乾淨、不會是
nullptr。 - 當「這個參數可能不存在(可以是空)」時,用指標,並用
nullptr表達「沒有」。 - 需要在函數內讓參數指向不同物件、或操作陣列/動態記憶體時,用指標。
| 傳遞方式 | 語法 | 能改到本尊? | 會複製? | 典型用途 |
|---|---|---|---|---|
| 傳值 | int x |
否 | 是 | 小型別、不想被改 |
| 傳參考 | int& x |
是 | 否 | 要修改、或避免大物件複製 |
| const 參考 | const T& x |
否 | 否 | 大物件唯讀(最常用) |
| 傳指標 | int* x |
是 | 否(複製地址) | 可為空、陣列、動態記憶體 |
預設參數:少打幾個字
C++ 函數可以給參數預設值,呼叫時省略就用預設:
#include <iostream>
// power 預設為 2(平方)
double raise(double base, int power = 2) {
double result = 1.0;
for (int i = 0; i < power; ++i) result *= base;
return result;
}
int main() {
std::cout << raise(5) << "\n"; // 輸出:25(用預設 power=2)
std::cout << raise(2, 10) << "\n"; // 輸出:1024
return 0;
}
兩個關鍵規則:
- 預設參數只能寫一次——如果函數有分離的宣告與定義,預設值寫在宣告(通常在標頭檔),定義端不要重複寫。
- 預設參數必須從右邊開始連續。不能跳著給:
void f(int a = 1, int b)是錯的,因為呼叫f(7)時編譯器無從得知7該配給誰。
函數多載(overload):同名不同參數
Python 沒有真正的函數多載(後定義的 def 會蓋掉前一個)。C++ 允許多個同名函數共存,只要它們的參數列表不同(個數或型別不同),編譯器會依呼叫時的引數自動挑對的那個:
#include <iostream>
#include <string>
int addUp(int a, int b) { return a + b; }
double addUp(double a, double b) { return a + b; }
std::string addUp(const std::string& a,
const std::string& b) { return a + b; }
int main() {
std::cout << addUp(3, 4) << "\n"; // 輸出:7(呼叫 int 版)
std::cout << addUp(1.5, 2.5) << "\n"; // 輸出:4(呼叫 double 版)
std::cout << addUp(std::string("Ue"),
std::string("du")) << "\n"; // 輸出:Uedu(呼叫 string 版)
return 0;
}
注意:回傳型別不算在多載的區分依據裡。下面兩個會被編譯器當成「同名同參數的重複定義」而報錯:
int pick(int x);
long pick(int x); // 錯誤:只有回傳型別不同,不構成合法多載
inline:給編譯器的一句提示
當函數很短(例如 area),呼叫它本身的開銷(建立堆疊框、跳轉、返回)可能比函數體還大。inline 關鍵字建議編譯器把函數呼叫處直接「展開」成函數體,省去呼叫開銷:
inline int square(int x) { return x * x; }
但有兩件事要釐清,否則容易誤用:
inline是建議不是命令。現代編譯器有自己的最佳化判斷,常常忽略你的inline,也常常自動內聯你沒標的函數。所以別為了效能而到處灑inline。inline在現代 C++ 真正的主要用途,是繞過「單一定義規則(ODR)」:它允許同一個函數定義出現在多個編譯單元(多個.cpp透過#include同一個標頭檔)而不會在連結時報「重複定義」。這也是為什麼寫在標頭檔裡的函數定義通常要標inline。
標頭檔與分離編譯:C++ 怎麼把大專案拆開
到這裡,「宣告與定義分離」終於開花結果。真實專案不會把幾萬行塞進一個檔案,而是拆成多個 .cpp,各自獨立編譯,最後連結(link)成一支程式。要讓 main.cpp 用到 mathutils.cpp 裡的函數,靠的就是標頭檔(header file,副檔名 .h 或 .hpp)。
慣例是:標頭檔放宣告,原始檔放定義。
// ===== mathutils.h =====
#ifndef MATHUTILS_H // include guard:防止重複引入
#define MATHUTILS_H
int area(int width, int height); // 只放宣告
double raise(double base, int power = 2); // 預設值寫在宣告端
#endif
// ===== mathutils.cpp =====
#include "mathutils.h" // 引入自己的標頭,讓編譯器核對宣告與定義是否一致
int area(int width, int height) {
return width * height;
}
double raise(double base, int power) { // 定義端不重複寫預設值
double result = 1.0;
for (int i = 0; i < power; ++i) result *= base;
return result;
}
// ===== main.cpp =====
#include <iostream>
#include "mathutils.h" // 只需要看到宣告,就能呼叫
int main() {
std::cout << area(3, 4) << "\n"; // 輸出:12
std::cout << raise(5) << "\n"; // 輸出:25
return 0;
}
編譯與連結(以 g++ 為例):
g++ -c mathutils.cpp -o mathutils.o # 編譯成目的檔(不連結)
g++ -c main.cpp -o main.o # 各自獨立編譯
g++ main.o mathutils.o -o app # 連結成可執行檔
./app
幾個關鍵觀念:
#include只是文字貼上:前置處理器把標頭檔內容原封不動貼進來。所以標頭檔放定義會被貼好幾次,導致重複定義(除非標inline)。- include guard(
#ifndef/#define/#endif) 或#pragma once防止同一個標頭在一次編譯中被貼超過一次。每個標頭檔都該有。 - 分離編譯的好處:改動
mathutils.cpp只需重新編譯它一個,不必整個專案重來;這在大專案能省下大量時間。相較於 Python 的「import 即執行」,C++ 的編譯/連結模型雖然繁瑣,卻換來啟動時零解譯開銷與更早的錯誤偵測。
動手寫一段:一個迷你計算機模組
把上面學到的東西串起來。我們做一個小模組,示範多載 + 預設參數 + const 參考,並拆成三個檔案。為了方便你直接複製測試,這裡先用單檔版本(實務上請照上面拆成 .h / .cpp):
#include <iostream>
#include <vector>
#include <string>
// 多載:兩數相加
int combine(int a, int b) { return a + b; }
// 多載:字串相接
std::string combine(const std::string& a, const std::string& b) {
return a + b;
}
// 預設參數:scale 預設不縮放
double total(const std::vector<double>& xs, double scale = 1.0) {
double sum = 0.0;
for (double x : xs) sum += x; // const 參考,零複製讀取
return sum * scale;
}
int main() {
std::cout << combine(20, 22) << "\n"; // 輸出:42
std::cout << combine(std::string("C"),
std::string("++")) << "\n"; // 輸出:C++
std::vector<double> scores = {80.0, 90.0, 100.0};
std::cout << total(scores) << "\n"; // 輸出:270
std::cout << total(scores, 0.5) << "\n"; // 輸出:135
return 0;
}
完整預期輸出:
42
C++
270
135
編譯執行:g++ -std=c++17 demo.cpp -o demo && ./demo。試著把 total 的參數從 const std::vector<double>& 改成 std::vector<double>(去掉 const &),程式仍會跑出一樣的結果——但每次呼叫都多複製了整個 vector。這正是「正確」與「慣用」的差別。
常見錯誤(初學者最容易踩的雷)
- 標頭檔裡放了「非 inline 的函數定義」:多個
.cpp引入後連結時爆「multiple definition」。標頭檔只放宣告;若真要在標頭放定義,記得標inline(或改放.cpp)。 - 預設參數在宣告和定義端都寫了一次:會編譯錯誤。預設值只在宣告端(標頭檔)寫一次。
- 以為「傳參考能改本尊」就到處用非 const 參考:唯讀的大物件請用
const T&,既零複製又防手滑誤改;該傳值的小型別(int、char)就老實傳值。 - 指標沒檢查
nullptr就*解參考:對空指標解參考是未定義行為(多半直接崩潰)。收到指標先if (p != nullptr)。 - 忘了 include guard:同一個標頭被間接引入兩次,型別/函數宣告重複而報錯。每個標頭檔開頭加
#pragma once或#ifndef三件套。 - 想靠「回傳型別不同」做多載:不合法。多載只看參數列表(個數與型別),不看回傳型別。
深入探討(研究所視角)
多載解析(overload resolution):編譯器怎麼選
當你寫 addUp(3, 4.0),編譯器面對 int 版與 double 版兩個候選,要決定呼叫哪一個。這個決策過程稱為多載解析(overload resolution),發生在編譯期,大致分三階段:
- 找出候選函數(candidate functions):所有名字相符、且可見的函數(含經由 ADL/參數依賴查找帶進來的)。
- 篩出可行函數(viable functions):參數個數對得上、且每個引數都能(經由隱式轉換)轉成對應參數型別的候選。
- 挑出最佳可行函數(best viable function):對每個引數比較各候選所需的「轉換等級」,選出整體最不需要轉換的那一個。
轉換有優先順序(由優到劣):完全符合 > 提升(如 char→int、float→double)> 標準轉換(如 int→double、double→int)> 使用者自訂轉換。若兩個候選一個對第一引數較好、另一個對第二引數較好,又沒有人「整體不差且至少一項更好」,就構成模稜兩可(ambiguous),編譯器直接報錯:
void f(long, double);
void f(double, long);
// f(1, 2); // 錯誤:ambiguous。1 與 2 都是 int,
// // 第一候選要轉 (int→long, int→double),
// // 第二候選要轉 (int→double, int→long),無一者整體佔優
理解多載解析的價值在於:很多「編譯器選錯版本」或「ambiguous call」的疑難,其實都是這套規則的必然結果。當你遇到時,可用明確轉型(addUp(3, static_cast<int>(4.0)))或調整參數型別來消除歧義,而不是亂試。
名稱修飾(name mangling):多載在連結器層面如何實現
這裡藏著一個有趣問題:連結器(linker)只認符號名稱(symbol name),它根本不懂 C++ 的型別。那 addUp(int,int) 和 addUp(double,double) 在目的檔裡若都叫 addUp,連結時不就撞名了嗎?
答案是名稱修飾(name mangling,又稱 name decoration):編譯器把函數的完整簽章資訊(名稱、namespace、參數型別、const 限定等)編碼進一個獨一無二的符號名。以 Itanium C++ ABI(GCC/Clang 採用)為例:
int addUp(int, int); // 修飾後約:_Z5addUpii
double addUp(double, double); // 修飾後約:_Z5addUpdd
_Z 是 C++ 修飾前綴,5addUp 是長度 5 的名稱 addUp,ii 表示兩個 int、dd 表示兩個 double。你可以實際驗證:
g++ -c overload.cpp -o overload.o
nm overload.o # 看修飾後的符號
nm overload.o | c++filt # c++filt 把修飾名「翻譯」回人看得懂的簽章
這也解釋了兩件實務上的關鍵:
- 回傳型別為什麼不參與多載:因為名稱修飾不編碼回傳型別(呼叫端可能根本不取回傳值,連結器無從由回傳型別區分),所以只有回傳型別不同的兩個函數會修飾出相同符號而衝突。
extern "C"的用途:當 C++ 要呼叫 C 函式庫,或要被 C 程式呼叫時,需用extern "C"關閉名稱修飾,讓符號名維持 C 風格的純名稱(不帶參數編碼)。代價是:extern "C"的函數不能多載,因為失去了區分用的修飾資訊。
extern "C" int legacy_add(int a, int b); // 符號就是 legacy_add,無修飾
把這兩層串起來看:多載解析在編譯期決定「呼叫哪個函數」,名稱修飾在符號層面讓「每個多載各有唯一身分」,分離編譯與連結再把它們組裝成程式。Python 用一套動態、解譯期的物件模型換取彈性;C++ 則把這些決策全推到編譯與連結期,用更多的前置規則,換取執行期的零成本抽象(zero-cost abstraction)——你享受到的多載、inline、模組化,在程式跑起來時幾乎不付額外代價。這正是 C++ 設計哲學最核心的一句話:你不用為你沒用到的東西付費,用到的東西也沒有比你手寫更慢。