Learning Objectives
- 說明為什麼需要模板:同一段邏輯不必為每種型別各寫一次
- 用 template <typename T> 寫函式模板,說明編譯器如何從引數推導 T
- 看懂型別推導衝突的錯誤,並用 <型別> 明確指定
- 寫類別模板,並在類別外定義它的成員函式
- 使用非型別模板參數,並說明 vector<int>、map<string, int> 就是類別模板的實例
為什麼需要模板
假設你要寫一個「回傳兩數中較大者」的函式。C++ 的每個參數都要寫型別,所以 int 寫一個、double 寫一個、string 再寫一個:
int largerInt(int a, int b) { return a > b ? a : b; }
double largerDouble(double a, double b) { return a > b ? a : b; }
string largerString(string a, string b) { return a > b ? a : b; }
三個函式除了型別,內容一模一樣。哪天發現邏輯有錯,要改三個地方,漏改一個就出問題。
模板(template)讓你把型別也當成參數:
template <typename T>
T larger(T a, T b) {
return a > b ? a : b;
}
T 是型別參數,代表「呼叫時才決定的某個型別」。這段程式本身還不是函式,而是產生函式的樣板:
你寫 larger(3, 7) 時,編譯器看出 T 是 int,就照著模板產生一份 larger<int>;寫 larger(2.5, 1.5) 又產生一份 larger<double>。
這個過程叫實例化(instantiation),發生在編譯時,程式執行時不需要再判斷型別。
template <typename T> 也可以寫成 template <class T>,兩者在這裡意思完全相同;T 只是慣用的名字,取別的名字也可以。
一個模板,四種型別
前兩次呼叫的是為 int、double 分別手寫的函式。接下來四次都呼叫同一個模板 larger,注意每次新框架的名稱:larger<int>、larger<double>、larger<string>、larger<char>,這是編譯器依引數型別產生的四個不同函式。
型別推導與明確指定
呼叫函式模板時,編譯器會從引數的型別推導 T,這叫模板引數推導(template argument deduction)。 推導時不做任何型別轉換,每個用到 T 的參數都必須推導出同一個型別:
template <typename T>
T add(T a, T b) { return a + b; }
add(2, 3); // T = int
add(1.5, 2.25); // T = double
add(2, 0.5); // 編譯錯誤:第一個引數推出 int,第二個推出 double,衝突
解決的方法有兩種:
- 明確指定型別:
add<double>(2, 0.5)。指定之後就不用推導,2 會像一般函式呼叫一樣轉成 2.0 - 用兩個型別參數:
template <typename T, typename U>,讓兩個參數各自推導
模板只有在被呼叫時才會依實際型別檢查。如果某個型別不支援模板裡用到的運算(例如對沒有定義 > 的類別呼叫 larger),
編譯器會在實例化的地方報錯,錯誤訊息常常很長,看的時候先找「required from here」附近指出的那一行呼叫。
推導、明確指定與兩個型別參數
注意每次呼叫的框架名稱:add<int>、add<double>。第 19、20 行用角括號指定 T,引數會先轉成那個型別再傳入,看 add<int>(7, 2.9) 裡的 b 變成了多少。show 有兩個型別參數,"one" 這種字串字面值推導出的是 const char*。
想一想:函式模板
下面的程式能不能編譯?如果能,會印出什麼?
template <typename T>
T add(T a, T b) { return a + b; }
int main() {
cout << add(2, 0.5);
}
下面的程式會印出什麼?
template <typename T>
T twice(T x) { return x + x; }
int main() {
cout << twice(3) << " " << twice(1.5) << " " << twice<int>(2.9);
}
larger("apple", "banana")(larger 是前面的模板)有什麼問題?
類別模板
類別也可以寫成模板。前一課的 vector<int>、map<string, int> 就是標準函式庫裡的類別模板,
角括號裡的型別就是模板引數:
template <typename T>
class Box {
T item;
public:
Box(T value) : item(value) {}
T open(); // 只宣告
};
template <typename T> // 在類別外定義成員函式:
T Box<T>::open() { // 要再寫一次 template,類別名稱寫成 Box<T>
return item;
}
Box<int> a(42); // 使用時寫出型別
Box<string> b("hello");
幾個要點:
Box本身不是型別,Box<int>、Box<string>才是,而且是兩個不同的型別,不能互相指定- 成員函式寫在類別外面時,前面要加
template <typename T>,類別名稱寫成Box<T>:: - 模板的定義通常整個放在標頭檔(.h)裡,因為編譯器在每個使用的地方都要看到完整的定義才能實例化
C++17 起可以在建構時省略型別,寫成
Box b(42);讓編譯器從建構子的引數推導(類別模板引數推導,CTAD)。 CodeLab 的直譯器還不支援這個寫法,本課的範例一律寫出Box<int>。
類別模板 Box<T>
a 是 Box<int>、b 是 Box<string>,看右邊兩個物件的成員型別各不相同。呼叫成員函式時,框架名稱寫成 Box<int>::open 或 Box<string>::open:同一份在類別外定義的 open,依物件的型別各自實例化。
非型別模板參數
模板參數不一定是型別,也可以是一個編譯時就確定的整數(或其他常數),這叫非型別模板參數:
template <typename T, int N>
class RingBuffer {
T data[N]; // N 在編譯時已知,可以當陣列大小
};
RingBuffer<int, 3> last3; // 只保留最後 3 筆
RingBuffer<double, 7> week; // 只保留最後 7 筆
RingBuffer<int, 3> 和 RingBuffer<int, 4> 是不同的型別。N 必須是常數運算式:寫 5、const int n = 5; 的 n 可以,
一般的變數 int n = 5; 不行,因為編譯器在編譯時不知道它的值。標準函式庫的 std::array<int, 5> 就是用這種參數決定大小。
RingBuffer:只保留最後 N 筆
last3 裡的陣列只有 3 格。加入第 4、5 筆時,next 繞回 0,新的值覆蓋最舊的資料。注意框架名稱 RingBuffer<int, 3>::add:N 是型別的一部分,函式裡的 N 直接就是 3。
想一想:類別模板
類別模板 Box<T> 在類別裡宣告了 T open();,要在類別外面定義它,正確的寫法是哪一個?
下面的程式會印出什麼?
template <int N>
int times(int x) { return x * N; }
int main() {
cout << times<3>(5) + times<2>(1);
}
動手寫:通用的堆疊 Stack<T>
完成類別模板 Stack<T>:push 把元素放到最上面、pop 取出並回傳最上面的元素、empty 回傳是否為空。內部用 vector<T> 儲存,尾端就是「最上面」。main 同時用了 Stack<int> 和 Stack<string>。
重點回顧
- 模板把型別當成參數,同一段邏輯只寫一次;編譯器在編譯時依實際型別產生各自的版本(實例化)
- 函式模板的 T 由引數推導,推導時不做型別轉換;推導衝突時用
函式名<型別>(...)明確指定,或改用兩個型別參數 - 類別模板使用時要寫出型別,例如
Box<int>;Box<int>和Box<string>是不同的型別 - 在類別外定義成員函式要寫
template <typename T>和Box<T>:: - 非型別模板參數是編譯時已知的常數,例如
RingBuffer<int, 3>的 3,可以當陣列大小 vector<int>、list<string>、map<string, int>都是標準函式庫的類別模板