摘要訊息 : 想玩玩 C++ 的黑魔法嗎? 來看看這篇文章認識樣板超編程吧!
TIP : 閱讀這個系列的文章之前, 你需要非常熟悉 C++
template
. 這個系列的文章將以 C++ 11 為基礎
在 C++ 被全世界熟知之前, 它還只是一個 C with Class. 當時, 還沒有引入樣板. 也就是說, 當時的 C++ 僅僅是一個包含了過程式的語言以及一個物件導向式的語言. 後來, C++ 引入了樣板, 一開始只是給 C++ 帶來了新的一個程式設計範式, 稱它為泛型, 也就是 C++ 又是一個泛型式的語言. 在 C++ 引入樣板之後, 人們發現 template
的威力遠遠超過了人們的想像, 人們意外發現 template
是圖靈完全的. 也就是說, 將樣板作為一門程式設計語言式人們偶然發現的. 因此, C++ 又多了一門新的程式設計範式 - 超編程式. 然而, 在 C++ 中, 超編程一直和 template
相結合, 所以又稱它為樣板超編程 (Template Meta-Programming, 簡稱 TMP). 又由於 TMP 與一些函數式程式設計非常相似, 所以也將 TMP 歸入到函數式程式設計中. 在 C++ 11 引入 lambda 表達式以及更加強大的 <functional>
標頭檔之後, C++ 的函數式程式設計又得到了加強
接下來所有此系列的文章中的 Template Meta-Programming 和樣板超編程都會被縮寫為 TMP
因此, 在 C++ 中存在五種程式設計範式 :
- 過程式程式設計
- 物件導向式程式設計
- 泛型程式設計
- 函數式程式設計
- 超編程式程式設計 (樣板超編程)
除此之外, 你還需要認識到, template
其實是可以算作一門獨立的程式設計語言, 也就是 C++ 的子語言. 也就是說, template
它是圖靈完全的!
那如何證明一個語言是圖靈完全的語言呢?
非常簡單, 如若一門程式設計語言滿足下面三個條件 :
- 可以進行數值運算和符號運算
- 擁有判斷結構
- 擁有迴圈結構
就可以說這門語言是圖靈完全的 (這裡不進行詳細的證明, 如果不同的理解或者異議可以在評論區提出)
首先我們來嘗試數值運算 :
template <typename T, T Value1, T Value2>
struct add {
constexpr static auto value {Value1 + Value2}; //C++ 11 式
enum {
VALUE = value1 + value2 //舊式
};
};
在沒有特殊的情況下, 今後都會使用 C++ 11 式
然後, 我們來嘗試判斷 :
template <bool Value>
struct if_constexpr {
//If Value == true, do something
};
template <>
struct if_constexpr<false> {
//If Value == false, do something
};
當樣板引數為 true
, 那麼編碼器將選擇第一個樣板具現化, 否則就會選擇第二個樣板具現化 (這有點類似於 C++ 17 的 if constexpr
)
有沒有恍然大悟? 這不就是樣板的特質化嗎~ 沒錯了, 說的那麼高端, 判斷結構, 實際上就是樣板的特質化
最後, 我們來說說迴圈結構, 相對於另外兩個, 這個稍有些複雜 :
template <typename T>
struct remove_extents {
using result = T;
};
template <typename T>
struct remove_extents<T []> {
using result = typename remove_extents<T>::result;
};
template <typename T, decltype(sizeof 0) N>
struct remove_extents<T [N]> {
using result = typename remove_extents<T>::result;
};
我們都知道, 任何的迴圈都會有判斷式, 否則就會進入無止盡的循環無法退出, TMP 的迴圈也不例外. 上述程式碼中, 兩個特質化的類別就是判斷式. remove_extents
是用來將陣列還原為基底型別的一個類別. 例如現在有一個 int [][3][4][5]
, 若將其作為引數放入 remove_extents
的樣板參數當中, remove_extents<int [][3][4][5]>
, 那麼最終的 result
型別就是 int
型別
你可能還沒明白它是如何工作的. 首先, 樣板參數接受了一個引數. 此時, 編碼器就根據具體的型別進行樣板具現化. 當你的型別是一個不帶維度數量的陣列型別, 那麼就會具現化第二個類別樣板; 當你的型別是一個帶維度數量的陣列型別, 那麼就會具現化第三個類別樣板. 而第二個和第三個類別樣板中的 result
又將 T
重新放入 remove_extents
中. 此時, remove_extents
已經完成了移除第一個維度的工作, 就如同你看到的一樣, T
後面的 []
或者 [N]
被我們捨棄了. 那麼此時, 若將 int [][3][4][5]
放入, result
的型別將是 typename remove_extents<int [3][4][5]>::result
. 你會發現, 這其實是一個遞迴的結構, 而不是我們所熟悉的迴圈模式. 是的, 在 TMP 中, 所有的迴圈都將被遞迴所替換. 在完成第一個維度的移除工作之後, 此時又會發現 int [3][4][5]
也是一個帶維度的陣列型別. 那麼又會具現化到第三個類別樣板... 如此不同地重複直到陣列的維度被移除完了, 此時 result
的型別將是 typename remove_extents<int>::result
. 對於沒有維度的型別來說, 第一個類別樣板就會被具現化, 這個類別之內不再有迴圈的結構, 而是直接將 int
作為 result
型別, 然後停止迴圈
如果你看懂了上面的舉例, 你會明白, 當放入的型別是一個陣列型別, remove_extents
會不斷地移除第一個維度, 直到型別不再帶有維度. 當型別帶有維度的時候, 就會不斷地具現化第二個或者第三個類別樣板. 當型別不帶維度的時候, 就會具現化第一個樣板, 也就是當型別不帶維度的時候, 停止迴圈
我們在使用 remove_extents
的時候, 就好比這樣 :
int array[1][3][5][6][7][8];
typename remove_extents<decltype(array)>::result integral {0}; //integral 的型別為 int
你可能會發現, 這就像是在呼叫函式一樣. remove_extents
是函式的名稱, <>
中放置函式的引數, 最終回傳了一個結果. 這幾乎和函式的模式是一樣的, 只不過現在, 這個函式是一個類別. 因為它太像函式了, 所以民間給它取了一個名字, 稱它為超函式 (Meta-Function)
在 TMP 中, 你需要理解超函式的概念, 否則的話, 可能會令你寸步難行 : 類別的名稱是超函式的名稱, 放入類別的樣板引數就是超函式的引數, 類別的樣板參數就是超函式的參數. 在上述程式碼中, 我們在類別中宣告了一個別名 result
作為回傳型別, 這也就是超函式的回傳值. 但是在民間, 通常使用 type
作為回傳值, 至少 C++ 標準樣板程式庫中的程式碼就是這樣. 當然, 這個回傳值的名稱你想怎麼取名都可以, 在這裡取名為 result
只是方便大家理解這個 result
就是超函式的最終結果. 對於值來說, value
作為超函式的最終結果, 這應該是沒有任何異議的
那麼, 你又會發現, 樣板超編程既可以操作數值也可以操作型別. 所以在通常的樣板超編程中, 為了統一, 都會把值包裹為型別, 大家通常會建立一個專門用來包裹值的類別 :
template <typename T, T Value>
struct wrapper {
constexpr static auto value {Value};
};
這個做法是為了讓值轉換為型別, 然後統一對型別進行操作, 以免程式碼重複
當然, 這個系列的文章有時也需要這麼做, 但是我會儘量讓它簡單一些, 以至於大家看起來沒有那麼困難
看到這裡, 有些人難免發問 : TMP 有什麼用呢?
因為 TMP 是編碼器計算的, 所以有時候, 我們可以用它結合編碼器的某些優化來提高程式的性能
例如一個函式是否被 noexcept
標識, 編碼器會根據具體的情況進行優化. 但是可以肯定的是, 被 noexcept
標識的函式在同樣的情況下, 比沒有被 noexcept
表示的函式在性能上要好一些 :
#include <iostream>
using namespace std;
template <typename T>
void func(const T &t) noexcept(is_nothrow_copy_constructible<T>::value) {
T copy {t};
}
上述程式碼中, 若 T
的型別在複製建構的時候, 是不會擲出例外情況的, 那麼它的性能會比擲出另外情況的版本要稍好一些 (只是一些). is_nothrow_copy_constructible
適用於判別一個型別的複製建構子是否被 noexcept
標識, 我們以後會學習如何編寫它
對於普通的 C++ 程式設計師而言, TMP 的作用非常小的. 因為它通常用於程式庫的設計, 工程計算. 若你是一個追求性能的 C++ 程式設計師, 即使你再普通, TMP 也能幫到你手, 就像上面的示例給出的情況一樣
對於程式庫的作者而言, TMP 幾乎是必不可少的, 因為程式庫需要同時兼顧效率以及相容性; 對於工程計算而言, TMP 的強型別可以讓某些錯誤在編碼器就可以被編碼器檢查出來
因此, TMP 的好處總結起來可以有以下兩點 :
- 提高程式的性能
- 將某些運作期的錯誤提前到編碼期
當然了, TMP 帶來了有點也一定有些缺陷 :
- 程式碼可讀性較差
- 編碼時間增長
- 在不同的編碼器情況下, 程式碼移植性不盡相同
到這裡為止, TMP 的介紹已經給大家講解完了, 並且文中的示例也可能讓大家對 TMP 有一些了解了. 作為一個 ending, 這裡將舉例如何在編碼期使用 template
找出一堆數值中的最小值 (基於 C++ 11 的可變參數樣板, C++ 98/03 版本比較複雜, 以後會呈現) :
首先呈現比較簡單的數值版本 :
template <typename T, T ...>
struct find_min;
template <typename T, T Value1, T Value2, T ...Values>
struct find_min<T, Value1, Value2, Values...> {
constexpr static auto value {find_min<T, find_min<T, Value1, Value2>::value, Values...>::value};
};
template <typename T, T Value1, T Value2>
struct find_min<T, Value1, Value2> {
constexpr static auto value {Value1 < Value2 ? Value1 : Value2};
};
template <typename T, T Value>
struct find_min<T, Value> {
constexpr static auto value {Value};
};
#include <iostream>
using namespace std;
int main(int argc, char *argv[]) {
cout << find_min<int, 1, 2, 3, 4, 5, 0>::value << endl; //0
cout << find_min<char, '1', '3', 'd', ' '>::value << endl; //' '
//cout << find_min<double, 0.9, 1.2, 0.0>::value << endl; //編碼錯誤
wcout << find_min<wchar_t, L'1'>::value << endl; //'1'
}
接下來是型別運算版本, 這個版本會比數值版本稍為複雜 :
template <typename T, T Value>
struct constant_value {
constexpr static auto value {Value};
};
template <bool, typename T, typename>
struct if_constexpr {
using result = T;
};
template <typename T, typename U>
struct if_constexpr<false, T, U> {
using result = U;
};
template <typename, typename>
struct is_same {
constexpr static auto value {false};
};
template <typename T>
struct is_same<T, T> {
constexpr static auto value {true};
};
template <typename T, typename U>
struct operator_less {
static_assert(is_same<decltype(T::value), decltype(U::value)>::value,
"Invalid template arguments, the types must be same!");
using result = typename if_constexpr<(T::value < U::value), T, U>::result;
};
template <typename ...>
struct find_min;
template <typename T, typename U, typename ...Args>
struct find_min<T, U, Args...> {
using result = typename find_min<typename find_min<T, U>::result, Args...>::result;
};
template <typename T, typename U>
struct find_min<T, U> {
using result = typename operator_less<T, U>::result;
};
template <typename T>
struct find_min<T> {
using result = T;
};
#include <iostream>
using std::cout;
using std::endl;
using std::wcout;
int main(int argc, char *argv[]) {
cout << find_min<constant_value<int, 5>, constant_value<int, 9>, constant_value<int, 99>>::result::value << endl; //5
wcout << find_min<constant_value<wchar_t, L'3'>, constant_value<wchar_t, L'l'>>::result::value << endl; //3
cout << find_min<constant_value<int, 0>>::result::value << endl; //0
}
是不是有些嚇到了呢?
其實這只是在數值的外面包裹了一層型別而已, 把這層衣服扒掉, 和數值的版本是一模一樣的. 這兩段程式碼的註解我就不放在上面了, 大家需要自己理解它們
另外, 型別版本的程式碼為了禁止不同的型別相比較, 使用了 C++ 11 的靜態斷言 static_assert
. 這是之前在《C++ 學習筆記》中沒有講到的. static_assert
是在編碼期進行檢查的, 第一個引數必須是一個常數表達式, 第二個引數是錯誤提示, 是一個字面值字串. 當第一個常數表達式計算的結果是 false
, 那麼此處會產生編碼錯誤. 我們以此來禁止不同型別之間的比較. 如果想要允許不同型別進行比較 (前提是可以比較, 例如 long
和 int
之間是可以比較的, 但是 int
和 int *
之間直接比較是不被允許的), 只需要註解 static_assert
即可
如果實在是無法理解型別版本的程式碼的話, 嘗試一下拆分它們, 把它們拆分為之前所說的運算、判斷式和迴圈再去理解就簡單多了
想要入門 TMP, 上面這些程式碼都是必須看得懂的, 嘗試一下理解它們吧~
本文是 TMP 入門的基礎文章, 如果無法理解, 請直接評論, 我會詳細為你講解
自創文章, 原著 : Jonny. 如若閣下需要轉發, 在已經授權的情況下請註明本文出處 :