Home
Explore Uedu
Student Console
Register as Member/Login
(2) In future presentations of the research findings, in addition to the course project website and public presentations, your real name and personal information will not appear in this research report. If you are interested in the research results, we can provide you with an executive summary after the study is completed.
問卷中心
Teacher Console
Course Setup
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

0

Local AI

--

CISOSE26 本地 AI UG26
政治大學 AQI 18 29°C

AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

函數與標頭檔

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++ 函數與標頭檔概念示意圖

宣告與定義: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 xa拷貝。函數內怎麼改都動不到外面的 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 參考就別用傳值(對於 intdouble 等小型別則仍直接傳值,因為複製成本比建立參考還低)。

傳指標(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;
}

兩個關鍵規則:

  1. 預設參數只能寫一次——如果函數有分離的宣告與定義,預設值寫在宣告(通常在標頭檔),定義端不要重複寫。
  2. 預設參數必須從右邊開始連續。不能跳著給: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。這正是「正確」與「慣用」的差別。

常見錯誤(初學者最容易踩的雷)

  1. 標頭檔裡放了「非 inline 的函數定義」:多個 .cpp 引入後連結時爆「multiple definition」。標頭檔只放宣告;若真要在標頭放定義,記得標 inline(或改放 .cpp)。
  2. 預設參數在宣告和定義端都寫了一次:會編譯錯誤。預設值只在宣告端(標頭檔)寫一次。
  3. 以為「傳參考能改本尊」就到處用非 const 參考:唯讀的大物件請用 const T&,既零複製又防手滑誤改;該傳值的小型別(intchar)就老實傳值。
  4. 指標沒檢查 nullptr* 解參考:對空指標解參考是未定義行為(多半直接崩潰)。收到指標先 if (p != nullptr)
  5. 忘了 include guard:同一個標頭被間接引入兩次,型別/函數宣告重複而報錯。每個標頭檔開頭加 #pragma once#ifndef 三件套。
  6. 想靠「回傳型別不同」做多載:不合法。多載只看參數列表(個數與型別),不看回傳型別。

深入探討(研究所視角)

多載解析(overload resolution):編譯器怎麼選

當你寫 addUp(3, 4.0),編譯器面對 int 版與 double 版兩個候選,要決定呼叫哪一個。這個決策過程稱為多載解析(overload resolution),發生在編譯期,大致分三階段:

  1. 找出候選函數(candidate functions):所有名字相符、且可見的函數(含經由 ADL/參數依賴查找帶進來的)。
  2. 篩出可行函數(viable functions):參數個數對得上、且每個引數都能(經由隱式轉換)轉成對應參數型別的候選。
  3. 挑出最佳可行函數(best viable function):對每個引數比較各候選所需的「轉換等級」,選出整體最不需要轉換的那一個。

轉換有優先順序(由優到劣):完全符合 > 提升(如 charintfloatdouble)> 標準轉換(如 intdoubledoubleint)> 使用者自訂轉換。若兩個候選一個對第一引數較好、另一個對第二引數較好,又沒有人「整體不差且至少一項更好」,就構成模稜兩可(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 的名稱 addUpii 表示兩個 intdd 表示兩個 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++ 設計哲學最核心的一句話:你不用為你沒用到的東西付費,用到的東西也沒有比你手寫更慢。

AI 共讀助教正在陪你讀:C++ 函數與標頭檔:宣告、多載與分離編譯
嗨!我是這篇文章的共讀助教,只根據〈C++ 函數與標頭檔:宣告、多載與分離編譯〉的內容回答。可以問我「解釋某段」「舉個例子」「出題考我」,或反白文中段落後點下方「解釋選取段落」。