學習目標

讀完本章後,你應該能夠:

  • 說明 為什麼需要模板,並比較模板與「複製貼上多份程式碼」、巨集(macro)、void* 三種泛型手段的優劣
  • 撰寫 函式模板,理解 模板引數推導(template argument deduction) 的規則與限制
  • 使用 明確模板引數多型別參數非型別模板參數(NTTP)
  • 撰寫 類別模板,並能把成員函式定義在類別外
  • 區分並實作 完整特化(full specialization)部分特化(partial specialization)
  • 認識 可變參數模板(variadic templates)C++17 折疊表達式(fold expressions)
  • 理解 模板編譯模型:為什麼模板的定義通常要放在標頭檔,什麼是 實體化(instantiation)
  • 初步使用 型別特徵(<type_traits>if constexpr(C++17)
  • 學會 閱讀模板的錯誤訊息
  • 認識 概念 Concepts(C++20) 作為下一步的進階主題

1. 為什麼需要模板?

1.1 問題:同一段邏輯、不同型別

假設我們要寫一個「回傳兩者較大值」的函式。沒有模板時,每種型別都得寫一份:

int    max_int(int a, int b)          { return (a > b) ? a : b; }
double max_double(double a, double b)  { return (a > b) ? a : b; }
char   max_char(char a, char b)        { return (a > b) ? a : b; }
// ... std::string、long、自訂型別 ... 寫不完

這種「複製貼上」的做法有三大問題:

問題 說明
程式碼重複(DRY 違反) 邏輯完全相同,只有型別不同,違反 Don't Repeat Yourself
維護惡夢 一旦邏輯要修改(例如加入 log),所有版本都得手動同步,很容易漏改
無法涵蓋未來型別 使用者自訂的新型別你事先不可能知道,永遠補不齊

1.2 在模板之前:人們用什麼?

在泛型機制成熟前,C/C++ 程式設計師有兩種「土法煉鋼」的替代品。理解它們的缺陷,才能體會模板的價值。

(A) 前置處理器巨集(macro)

#define MAX(a, b) ((a) > (b) ? (a) : (b))

巨集只是「純文字替換」,編譯器在做真正的語法分析「之前」就把它展開了,因此:

int x = 5;
int y = MAX(x++, 3);   // 展開成 ((x++) > (3) ? (x++) : (3))
                       // x++ 可能被求值兩次!結果是未定義行為般的陷阱

(B) void* + 函式指標

C 的 qsort 就是這種風格:傳入 void* 與一個比較函式。缺點是 完全沒有型別安全,要靠強制轉型,且因為無法 inline 而較慢。

1.3 三種泛型手段比較

特性 巨集 macro void* 泛型 模板 template
型別安全 ❌ 無(純文字) ❌ 弱(需轉型) ✅ 完整,編譯期檢查
多次求值副作用 ⚠️ 有風險 ✅ 安全 ✅ 安全
效能 快(inline) 慢(無法 inline、需轉型) 快(可 inline,零額外成本)
除錯/錯誤訊息 ❌ 難(展開後才報錯) ⚠️ 執行期才爆 ⚠️ 訊息冗長但是編譯期
支援自訂型別 勉強 需手動轉型 ✅ 自然支援

核心觀念:模板是 編譯期的程式碼產生器(code generator)。你寫「一份藍圖」,編譯器依照實際用到的型別,自動「生出」對應的具體版本。這就是 泛型程式設計(generic programming) 的基礎,也是整個 STL 的根基。


2. 函式模板

2.1 基本語法

template <typename T>
T my_max(T a, T b) {
    return (a > b) ? a : b;
}
  • template <typename T>模板參數列(template parameter list),宣告這是一個模板,T型別參數(type parameter)
  • typename 可以替換成 class,在此處兩者 完全等價(現代風格偏好 typename,因為 T 不一定是 class)。
  • T 是個「佔位符」,編譯器之後會用真正的型別把它填進去。

2.2 模板引數推導(Template Argument Deduction)

最神奇也最常用的功能:你 不必明確寫出型別,編譯器會從引數推導出 T

my_max(3, 7);          // 兩個都是 int    → T = int
my_max(3.14, 2.71);    // 兩個都是 double → T = double
my_max('a', 'z');      // 兩個都是 char   → T = char

編譯器看到 my_max(3, 7),推導出 T = int,於是「實體化(instantiate)」出一份:

// 編譯器自動產生(概念示意)
int my_max(int a, int b) { return (a > b) ? a : b; }

推導失敗的經典陷阱:當兩個引數型別不一致時,T 無法同時被推導成兩種型別:

my_max(3, 2.5);   // ❌ 編譯錯誤:T 既要是 int(來自 3) 又要是 double(來自 2.5)

錯誤訊息會類似(GCC):

error: no matching function for call to 'my_max(int, double)'
note: candidate: template<class T> T my_max(T, T)
note: template argument deduction/substitution failed:
note: deduced conflicting types for parameter 'T' ('int' and 'double')

解法見下一節。

2.3 明確指定模板引數(Explicit Template Arguments)

當推導失敗、或你想強制某個型別時,用尖括號明確指定:

my_max<double>(3, 2.5);   // 明確 T = double;3 會隱式轉成 3.0 → 結果 3.0
my_max<int>(3.9, 2.1);    // 強制 T = int;兩者截斷成 3 和 2 → 結果 3

2.4 多個型別參數

模板可以有多個型別參數,用於處理不同型別的引數:

template <typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {   // 回傳型別由 a + b 推導
    return a + b;
}

add(3, 2.5);      // int + double → 5.5(型別為 double)
add(1.5, 2);      // double + int → 3.5
  • -> decltype(a + b)尾置回傳型別(trailing return type),讓回傳型別依運算結果決定。
  • 在 C++14 以後,函式模板可直接用 auto 推導回傳型別:template <typename T, typename U> auto add(T a, U b) { return a + b; }

2.5 函式模板的多載與明確特化

函式模板可以被 多載(overload),也可以被 完整特化。對於函式,多載通常比特化更直觀、更不容易出錯(這是 C++ Core Guidelines 與多數專家的建議,原因見 §7.3)。

#include <cstring>

template <typename T>
T my_max(T a, T b) { return (a > b) ? a : b; }

// 完整特化:針對 const char*,用 strcmp 比較「內容」而非指標位址
template <>
const char* my_max<const char*>(const char* a, const char* b) {
    return (std::strcmp(a, b) > 0) ? a : b;
}

my_max("apple", "banana");   // 呼叫特化版,比較字串內容

📖 完整可執行範例請參考 function_templates.cpp


3. 非型別模板參數(NTTP)

模板參數 不一定要是型別,也可以是 編譯期常數值

template <typename T, int Size>     // Size 是「非型別模板參數」
class FixedArray {
    T data_[Size];
public:
    T& operator[](int index) { return data_[index]; }
    constexpr int size() const { return Size; }
};

FixedArray<int, 10>   arr;   // 大小為 10 的 int 陣列(大小寫死在型別裡)
FixedArray<double, 5> d;     // 大小為 5 的 double 陣列

關鍵特性Size編譯期 就確定,所以 FixedArray<int, 10>FixedArray<int, 5>兩個完全不同的型別std::array<T, N> 正是這樣設計的。

非型別模板參數的允許型別:

允許 範例
整數型別、boolchar template <int N>template <bool B>
列舉(enum) template <Color C>
指標 / 參考(指向有 linkage 的物件) template <int* P>
std::nullptr_t
浮點數、字面值類別 C++20 才開始支援(C++17 之前不行)
template <typename T, int N>
void print_array(const T (&arr)[N]) {     // 編譯器自動推導 N
    for (int i = 0; i < N; ++i) std::cout << arr[i] << ' ';
}

int nums[] = {1, 2, 3, 4, 5};
print_array(nums);   // N 自動推導為 5

4. 類別模板

4.1 基本語法

template <typename T>
class Stack {
    std::vector<T> data_;
public:
    void push(const T& value);
    void pop();
    const T& top() const;
    bool empty() const { return data_.empty(); }
    std::size_t size() const { return data_.size(); }
};

使用時 必須明確提供型別引數(C++17 之前),因為類別模板不像函式模板那樣能從引數推導:

Stack<int>         int_stack;
Stack<std::string> str_stack;

C++17 補充——CTAD(類別模板引數推導):C++17 起,某些情況下可省略尖括號,例如 std::vector v = {1, 2, 3}; 會推導為 std::vector<int>。但初學階段建議仍明確寫出型別。

4.2 成員函式定義在類別外

把宣告與定義分開時,每一個 類別外的成員函式都要重新帶上 template <typename T> 前綴,且類別名稱要寫成 Stack<T>

template <typename T>
void Stack<T>::push(const T& value) {     // 注意 Stack<T>:: 與前綴
    data_.push_back(value);
}

template <typename T>
const T& Stack<T>::top() const {
    if (data_.empty())
        throw std::out_of_range("Stack<T>::top(): 堆疊為空");
    return data_.back();
}

4.3 預設模板參數

類別模板可以有預設型別參數(函式模板從 C++11 起也可以):

template <typename T = int, typename Container = std::vector<T>>
class SimpleList {
    Container data_;
public:
    void add(const T& value) { data_.push_back(value); }
};

SimpleList<>            a;   // T = int,    Container = vector<int>
SimpleList<double>      b;   // T = double, Container = vector<double>

📖 完整可執行範例請參考 class_templates.cpp


5. 模板特化

「特化」就是:為某些特定型別提供和通用版本不同的實作

5.1 完整特化(Full Specialization)

所有 模板參數都填上具體型別。函式模板與類別模板都支援。

// 通用版本
template <typename T>
class Printer {
public:
    void print(const T& value) const {
        std::cout << "[通用] 值: " << value << '\n';
    }
};

// 針對 std::string 的完整特化(template <> 後面沒有參數)
template <>
class Printer<std::string> {
public:
    void print(const std::string& value) const {
        std::cout << "[std::string] \"" << value
                  << "\" (長度: " << value.size() << ")\n";
    }
};

預期輸出:

Printer<int>{}.print(42);            → [通用] 值: 42
Printer<std::string>{}.print("hi");  → [std::string] "hi" (長度: 2)

5.2 部分特化(Partial Specialization)

只把 一部分 參數特化,或限定參數的「形狀」(如「是個指標」)。

⚠️ 重要規則:部分特化只適用於「類別模板」,函式模板不支援部分特化(函式請改用多載)。

// 通用版本
template <typename T>
class TypeInfo {
public:
    void describe() const { std::cout << "一般型別\n"; }
};

// 部分特化:任何「指標型別 T*」
template <typename T>
class TypeInfo<T*> {
public:
    void describe() const { std::cout << "指標型別\n"; }
};

// 部分特化:任何「左值參考 T&」
template <typename T>
class TypeInfo<T&> {
public:
    void describe() const { std::cout << "左值參考型別\n"; }
};
TypeInfo<int>{}.describe();    // 一般型別
TypeInfo<int*>{}.describe();   // 指標型別  ← 命中 T* 部分特化
TypeInfo<int&>{}.describe();   // 左值參考型別

多參數的部分特化也很常見:

template <typename T, typename U>
class Pair { /* 通用:兩個不同型別 */ };

template <typename T>
class Pair<T, T> { /* 部分特化:兩個型別相同時 */ };

template <typename T, typename U>
class Pair<T, U*> { /* 部分特化:第二個是指標 */ };

📖 完整可執行範例請參考 template_specialization.cpp


6. 可變參數模板(Variadic Templates,C++11)

可變參數模板能接受 任意數量、任意型別 的引數。語法核心是 參數包(parameter pack) ...

// 終止條件:沒有參數時換行
void print() { std::cout << '\n'; }

// 遞迴展開:取出第一個,其餘繼續遞迴
template <typename T, typename... Args>
void print(const T& first, const Args&... rest) {
    std::cout << first;
    if (sizeof...(rest) > 0) std::cout << ", ";
    print(rest...);              // 展開:把 rest 拆開再呼叫
}

print(1, 2.5, "hello", 'A');     // 輸出: 1, 2.5, hello, A

關鍵語法整理:

語法 意義
typename... Args 宣告一個 型別參數包
const Args&... rest 宣告一個 函式參數包
rest... 展開 參數包
sizeof...(rest) 取得參數包內 元素的數量(編譯期常數)

6.1 C++17 折疊表達式(Fold Expressions)

C++17 引入折疊表達式,讓「對參數包做一連串運算」不再需要寫遞迴:

template <typename... Args>
auto sum(Args... args) {
    return (args + ...);        // 一元右折疊:a1 + (a2 + (a3 + ...))
}

sum(1, 2, 3, 4);                // = 10

四種折疊形式:

形式 寫法 展開(以 a, b, c 為例)
一元右折疊 (args + ...) a + (b + c)
一元左折疊 (... + args) (a + b) + c
二元右折疊 (args + ... + 0) a + (b + (c + 0))
二元左折疊 (0 + ... + args) ((0 + a) + b) + c

二元形式可提供「初始值/空包安全值」,例如空參數包時 (args + ...) 會編譯錯誤,但 (0 + ... + args) 回傳 0。

實用範例——用折疊把全部引數印出來:

template <typename... Args>
void println(const Args&... args) {
    ((std::cout << args << ' '), ...);   // 用逗號運算子折疊
    std::cout << '\n';
}
println("x =", 42, "pi =", 3.14);        // x = 42 pi = 3.14

7. 模板編譯模型:為什麼定義要放在標頭檔?

這是初學者最常踩的坑,務必理解。

7.1 兩階段編譯與實體化

模板本身 不是程式碼,而是「產生程式碼的藍圖」。編譯器只有在 看到你用某個具體型別 時,才會「實體化(instantiate)」出真正的函式/類別:

template <typename T>          使用 my_max(3, 7)            使用 my_max(1.5, 2.5)
T my_max(T a, T b){...}   ──►  實體化 int 版本        ──►   實體化 double 版本
   (藍圖,不產生程式碼)         int my_max(int,int)          double my_max(double,double)

7.2 為什麼定義要放在 .h 而不是 .cpp

C++ 採用 分離編譯:每個 .cpp 各自編譯成 .o,最後連結。問題在於——當編譯器在 main.cpp 看到 my_max(3, 7),它需要 my_max完整定義 才能實體化出 int 版本。如果定義藏在 utils.cpp 裡,main.cpp 看不到,就無法產生程式碼:

// 若把模板定義放在 utils.cpp:
編譯 main.cpp  → 只看到宣告,無法實體化 → 暫時 OK,產生「未定義符號」
編譯 utils.cpp → 沒人用,編譯器不知道要實體化 int 版本 → 沒產生
連結階段       → undefined reference to `int my_max<int>(int, int)`  ❌
一般函式 模板
宣告放 .h,定義放 .cpp 宣告與定義都放 .h(或 .hpp/.ipp
連結期解析符號 使用點需看到完整定義才能實體化

實務結論:模板的定義要寫在標頭檔中(這也是為什麼 STL 幾乎全是標頭檔)。進階做法有「顯式實體化(explicit instantiation)」,但初學階段記住「模板放 header」即可。C++20 的 Modules 正在改變這個格局,但目前仍以 header 為主流。


8. 型別特徵與 if constexpr(C++11 / C++17)

8.1 <type_traits> 簡介

標準函式庫提供一組 編譯期型別查詢/轉換 工具,能在模板中「詢問型別的性質」:

#include <type_traits>

static_assert(std::is_integral<int>::value);          // int 是整數型別 → true
static_assert(std::is_floating_point<double>::value); // double 是浮點 → true
static_assert(std::is_same<int, int>::value);         // 兩型別相同 → true

// C++17 起有 _v 簡寫
static_assert(std::is_integral_v<int>);
常用 trait 用途
std::is_integral_v<T> T 是否為整數型別
std::is_floating_point_v<T> T 是否為浮點型別
std::is_pointer_v<T> T 是否為指標
std::is_same_v<T, U> T 與 U 是否為同一型別
std::remove_const_t<T> 去掉 const 後的型別
std::decay_t<T> 模擬傳值時的型別退化(去參考/const、陣列轉指標)

8.2 if constexpr(C++17)——編譯期分支

if constexpr 讓你在模板中根據型別 在編譯期 選擇要保留哪段程式碼,被捨棄的分支 完全不會被編譯

template <typename T>
std::string describe(const T& value) {
    if constexpr (std::is_integral_v<T>) {
        return "整數: " + std::to_string(value);
    } else if constexpr (std::is_floating_point_v<T>) {
        return "浮點數: " + std::to_string(value);
    } else {
        return "其他型別";
    }
}

describe(42);     // 整數: 42
describe(3.14);   // 浮點數: 3.140000
describe("hi");   // 其他型別

為什麼不用普通 if?因為普通 if 兩個分支 都要能通過編譯。若某分支對某些型別不合法(例如對字串呼叫 std::to_string),就會編譯失敗。if constexpr 會把不成立的分支直接丟棄,所以安全。回顧 §6 的可變參數遞迴,if constexpr (sizeof...(rest) > 0) 也是同樣道理。


9. 概念 Concepts 預告(C++20)

模板最大的痛點是 錯誤訊息冗長難懂:當你傳了不支援 operator> 的型別給 my_max,編譯器會吐出幾十行「深入模板內部才報錯」的訊息。

C++20 的 Concepts 讓你能 為模板參數加上約束,把錯誤提前、訊息變清楚:

#include <concepts>

template <typename T>
concept Comparable = requires(T a, T b) {
    { a > b } -> std::convertible_to<bool>;   // 要求 T 支援 a > b 且結果可轉 bool
};

template <Comparable T>           // 只接受滿足 Comparable 的型別
T my_max(T a, T b) {
    return (a > b) ? a : b;
}

若傳入不可比較的型別,錯誤訊息會直接說「不滿足 Comparable」,而不是一堆內部細節。本章僅作預告,深入學習可留待進階。


10. 最佳實踐(參考 C++ Core Guidelines)

  • [T.1] 用模板提升程式碼的抽象層次:把「演算法邏輯」與「資料型別」解耦,寫一次到處用。
  • [T.2] 用模板表達適用於多種型別的演算法:例如排序、搜尋這類與元素型別無關的邏輯。
  • [T.3] 用模板表達容器與範圍:自訂容器應做成類別模板。
  • [T.10 / 概念]:盡量為模板參數加上 概念約束(C++20),讓介面意圖明確、錯誤訊息友善。
  • [T.42] 用模板別名(alias)簡化記法template <typename T> using Vec = std::vector<T>;
  • [T.47] 避免不受約束的高度可見模板與普通名稱衝突:別讓你的 template <typename T> void swap(...) 意外蓋掉別人的多載。
  • [T.62] 把與參數無關的類別模板成員抽出去:避免每個實體化都重複產生相同的非相依程式碼(減少程式碼膨脹)。
  • [ES.5 / 偏好]:函式優先用 多載,類別才用特化;函式模板的「完整特化」容易與多載互動出意外,能避則避。
  • 優先用標準工具std::maxstd::swapstd::arraystd::tuple 等已存在,別重造輪子;自己寫模板是為了學習與處理標準庫沒涵蓋的需求。

11. 常見錯誤與陷阱

錯誤 說明與解法
模板定義放在 .cpp 連結期出現 undefined reference。解法:把定義移到標頭檔(見 §7)。
型別推導衝突 my_max(3, 2.5)T 無法同時是 int 與 double。解法:明確指定 my_max<double>(3, 2.5) 或用兩個型別參數。
忘記 template <> 寫完整特化時漏掉前綴 template <>,會被當成重新定義。
對函式模板做部分特化 語法不允許。解法:改用 函式多載
非型別參數用浮點數(C++17 前) template <double D> 在 C++17 之前不合法,C++20 才放寬。
typename 漏寫(相依名稱) 存取相依型別的巢狀型別要加 typename,例如 typename Container::iterator it;,否則編譯器以為那是成員變數。
每個類別外成員都要前綴 漏寫 template <typename T> 或把 Stack<T>:: 寫成 Stack:: 都會報錯。
以為模板會「自動」實體化全部型別 只有「被用到」的型別才會被實體化;沒用到的特化不存在。
程式碼膨脹(code bloat) 大量不同型別實體化會放大二進位檔。可用 §10 的 [T.62] 技巧減輕。

如何閱讀模板錯誤訊息?

模板錯誤往往幾十行,閱讀訣竅:

  1. 從最上面第一條 error: 看起(不是 note:),那通常是根因。
  2. required from here:它會告訴你「是哪一行的使用」觸發了這個錯誤。
  3. deduced conflicting types / no matching function:代表引數型別不符或推導衝突。
  4. 善用 C++20 Concepts 把約束寫清楚,能讓訊息大幅精簡。
example.cpp: In instantiation of 'T my_max(T, T) [with T = Widget]':
example.cpp:30:18:   required from here          ← 第 30 行的呼叫造成的
example.cpp:6:22: error: no match for 'operator>' (operand types are 'Widget' and 'Widget')
                                                  ← 根因:Widget 沒有定義 operator>

重點整理

  1. 模板是編譯期的程式碼產生器:寫一份藍圖,編譯器依用到的型別生出具體版本,兼顧 程式碼重用型別安全
  2. 相較巨集(無型別安全、副作用陷阱)與 void*(無型別安全、較慢),模板是最佳泛型手段。
  3. 函式模板 支援 引數推導,多數情況不需明確指定型別;推導衝突時用明確引數或多型別參數。
  4. 非型別模板參數 是編譯期常數,會讓不同數值成為不同型別(如 std::array<T, N>)。
  5. 類別模板 的類別外成員定義需帶 template<...> 前綴與 ClassName<T>::
  6. 完整特化 適用函式與類別;部分特化只限類別模板,函式請用多載。
  7. 可變參數模板 + 折疊表達式(C++17) 處理任意數量引數。
  8. 模板定義要放標頭檔,因為使用點需看到完整定義才能實體化。
  9. <type_traits> + if constexpr 讓你在編譯期依型別性質做分支。
  10. Concepts(C++20) 為模板參數加約束,提前報錯、改善錯誤訊息。

練習題

以下每題都能用 g++ -std=c++17 -Wall 編譯。建議先自己嘗試,再對照 對應程式碼檔案 中的相關範例。

練習 1(基礎):泛型搜尋函式

寫一個函式模板 find_index,接受一個 C 風格陣列(用 const T (&arr)[N] 接收)與目標值,回傳目標值的索引,找不到回傳 -1

  • 提示:用非型別模板參數 int N 讓編譯器自動推導陣列大小;以 for 迴圈逐一用 == 比較。可參考 function_templates.cpp 中的 find_in_array

練習 2(基礎):泛型 clamp

實作 template <typename T> T my_clamp(T value, T lo, T hi),把 value 限制在 [lo, hi] 區間內(小於 lolo,大於 hihi)。用 intdouble 各測一次。

  • 提示:只需兩個三元運算子或 if。注意三個參數都是 T,所以呼叫時型別要一致,否則需明確指定。

練習 3(中級):泛型 Pair 類別與比較

實作類別模板 MyPair<T, U>,提供 first()second() 存取器,以及 operator==(兩個 pair 的對應元素都相等才相等)。

  • 提示:成員 T first_; U second_;operator== 比較 first_second_。可參考 class_templates.cppPair

練習 4(中級):完整特化的格式化輸出

延續練習 3,為 MyPair<std::string, std::string> 撰寫 完整特化,新增 print()"key = value" 格式輸出(例如 language = C++)。

  • 提示template <> class MyPair<std::string, std::string> { ... }; 整個類別重寫。可參考 template_specialization.cppPrinter<std::string>

練習 5(中級):可變參數 println

用可變參數模板寫 println,以空格分隔印出所有引數並自動換行。請 同時 提供「遞迴展開」與「C++17 折疊表達式」兩種版本。

  • 提示:遞迴版需要一個無參數的終止函式;折疊版用 ((std::cout << args << ' '), ...)

練習 6(挑戰):用 if constexpr 寫泛型 to_string

實作 template <typename T> std::string smart_to_string(const T& v):若 T 是整數或浮點數,回傳數字字串;若是 std::stringconst char*,直接回傳內容;其他型別回傳 "<unprintable>"

  • 提示#include <type_traits>,用 if constexpr (std::is_arithmetic_v<T>)std::is_same_v<T, std::string> 等做分支;數字用 std::to_string

練習 7(挑戰):非型別參數固定矩陣

實作 template <typename T, int Rows, int Cols> class Matrix,內部用 T data_[Rows][Cols],提供 at(r, c)fill(value)print(),以及相同維度矩陣的 operator+

  • 提示:兩個非型別參數 RowsCols 讓維度成為型別的一部分;operator+ 的回傳型別是 Matrix<T, Rows, Cols>,逐元素相加。

對應程式碼檔案