類別樣版參數推導 Class Template Argument Deduction (CTAD) 光看這名字第一時間有些難懂在幹嘛,其實這個 C++17 的新特性最主要的目的又是一個「語法糖」。讓同學們少寫很多字,code 看起來也舒爽。
只是這個「語法糖」甜到的是誰呢…端看同學們立場了,嘻。下面就給同學們帶來展示 Class Template Argument Deduction, CTAD 最顯而易見的改變。
使用類別樣版宣告物件
在 C++17 之前
比如同學們宣告一個 std::pair
,裝載著一個 std::vector<int>
和 int
。
std::vector<int> vi{1, 2, 3, 4, 5};
std::pair<std::vector<int>, int> pvi{vi, 100};
又或是在生產環境中,很實際地,我們利用 RAII 慣用語的產物 std::lock_guard
,想要防止一段 code 同時被多個執行緒進入。
// 類別私有變數
std::mutex m_;// cpp 業務邏輯
std::lock_guard<std::mutex> lk{m_};
上面我們發現不管是 vector
, pair
, lock_guard
,他們在宣告物件時,都需要手動給定這個類別樣版的參數,也就是 vector<int>
, std::pair<vector<int>, int>
, std::lock_guard<std::mutex>
。但是,我們初始化這些物件的時候,明明就已經把參數都給好了啊?為什麼不能像呼叫樣版函式一樣,讓編譯器自己推導出型別好了呢?
講到函式樣版,上面問題在 C++17 以前的標準庫的解法就是包成函式再加上 auto
去簡化生成這些物件的語法。例如
auto pvi = std::make_pair(vi, 100);
auto sp = std::make_shared<my_class>(1, 'a', 3.99);
相信這個疑問存在在大家心中已久 (好啦可能只有我)。那接下來看看 C++17 帶來什麼改變吧!
在 C++17 CTAD 來臨後
上面那些物件的宣告,如同大家所期待地,寫成下面這樣就行了。
std::vector vi{1, 2, 3, 4, 5};
std::pair pvi{vi, 100};std::lock_guard lk{m_};
非常直覺簡明!然而,為什麼這麼簡單容易想到的事情,到了 C++17 才正式規範完成呢?是我們太年輕太簡單了嗎?就讓我們繼續看下去。
類別樣版參數推導原理
簡單的 pair
這件事情原理其實就跟函式樣版的參數推導差不了多少,我們就用 std::pair
舉例,自己包一個簡易版的 my_pair
實驗一下。
template<typename T, typename U>
struct my_pair
{
my_pair(T t, U u) : t_(t), u_(u) {} T t_;
U u_;
};
my_pair mp1{55.66, 78}; // !!
一切就是這麼自然地就發生了,編譯器依照我們用 55.66 和 78 呼叫 my_pair
的 constructor 時,就把 my_pair
的 T
和 U
推導出是 double
和 int
。所以 mp1
的型別就是 my_pair<double, int>
。那麼類別樣版參數推導到底難在哪?關鍵就藏在那個 constructor 上面。
不那麼簡單的 pair
今天我們的 my_pair
想要吃陣列,然後把陣列的前兩項存進 my_pair
,聽起來也是件合情合理的要求,寫成 code 長這樣
char ca[] = {'a', 'b'};
my_pair mp2{ca};printf("%c %c\n", mp2.t_, mp2.u_);
// a bdouble da[] = {11.22, 33.44};
my_pair mp3{da};printf("%lf %lf\n", mp3.t_, mp3.u_);
// 11.22 33.44
我們希望 mp2
能被編譯器認為是 my_pair<char, char>
而 mp3
被編譯器認成 my_pair<double, double>
。
顯然上面那個吃兩個參數的 constructor 不能套進來這兒,所以需要再嚕一個新的 ctor 給 my_pair
。而且為了吃各種型別的陣列,我們新寫的 ctor 必須也是一個樣版函式。
template<typename T, typename U>
struct my_pair
{
// #1
my_pair(T t, U u) : t_(t), u_(u) {}
// #2
template<typename A>
my_pair(A a[]) : t_(a[0]), u_(a[1]) {}
};char ca[] = {'a', 'b'};
my_pair mp2{ca}; // #2: A = char
好啦,我們新的 my_pair
可以吃任意型別的陣列了…嗎?問題來了,我們通過這個新的 ctor #2 構造 my_pair mp2{ca}
時,根據傳統的函式樣版推導,很容易知道 A
可以被推導成 char
。那,my_pair
的重點 T
和 U
呢?
在 C++17 之前,如同片頭說的其實我們就明確把型別標出來就完事了。
// prior to C++17
my_pair<char, char> mp2{ca}。
但時代在進步,為了少打一些字減少碳足跡。我們還是想要做到不手動標示型別就做出 my_pair mp2{ca}
,看起來也高大上一些。
Deduction Guide 推導指引
顯然我們 my_pair
的開發者需要提示編譯器怎麼從推導 A
到推導出 T
和 U
。這也就是 C++17 的 (User-defined) Deduction Guide 推導指引所要做的事:開發者要 guide 編譯器怎麼推導出類別樣版的參數。
— 以下講的全稱應該是 User-defined Deduction Guide 自定義推導指引,簡稱 Deduction Guide 方便些。而相對來說的存在,也就是 implicit deduction guide,就是上面那些非樣版的 constructor 由編譯器自行推導的情況。
C++17 的 Deduction Guide 格式借助了 C++11 的 trailing-return type 標註方法。記憶方法很生硬直接:把 constructor 當作是一個被呼叫的函式,而這個函式要回傳一個已經填好型別的類別樣版出來。
ctor(呼叫建構式的參數們) -> 回傳型別
所以把上面的 my_pair
套用進來就可以直接寫成這樣:
template<typename T, typename U>
struct my_pair
{
// #1
my_pair(T t, U u) : t_(t), u_(u) {}
// #2
template<typename A>
my_pair(A a[]) : t_(a[0]), u_(a[1]) {}
};// Deduction Guide
template<typename A>
my_pair(A[]) -> my_pair<A, A>;char ca[] = {'a', 'b'};
my_pair mp2{ca};
// my_pair<char, char>
代表說:我從 ctor 呼叫時推導出來的 A
型別,他就是 my_pair
的兩個型別參數 T
和 U
。
因此這樣的一行宣告 my_pair mp2{ca}
,就可以順利被編譯器辨認出是 my_pair<char, char>
。而前面的 my_pair mp3{da}
,就被認成是 my_pair<double, double>
。
Deduction Guide 的特化
今天我們想特別針對「字串陣列」處理:因為 const char*
指標用來存資料要各種操作不太方便也不太安全也不太現代,所以我們想要做到:當 ctor 傳入的是 const char*[]
,我們想要將 my_pair
的 T
和 U
都設成 std::string
來存資料。
這個想法和做法都異常直覺,結論是就像函式重載那樣多寫一條明確的 deduction guide 規則指引一下就好
// Deduction Guide
template<typename A>
my_pair(A[]) -> my_pair<A, A>;my_pair(const char*[]) -> my_pair<string, string>; // !!const char* stra[] = {"netflix", "spotify"};
my_pair mp4{stra};
// my_pair<string, string>
Explicit Deduction Guide
既然 Deduction Guide 是用來描述建構式 constructor 和最終推導的類別型別的關係,那麼在 ctor 之前掛上 explicit
來避免手殘眼殘意外發生的 copy-initialization 也是合情合理的事情。
就來上面的「字串陣列 -> std::string
」這條規則來說吧,如果我們在前面掛上 explicit
。
// Deduction Guide
template<typename A>
my_pair(A[]) -> my_pair<A, A>; // #1explicit my_pair(const char*[]) -> my_pair<string, string>; // #2
這個時候各位同學必須特別把目光聚焦在這裡!這個時候,(#1) 規則依然還是存在的,所以用字串陣列初始化 my_pair
的結果就變成兩種情況:
const char* stra[] = {"netflix", "spotify"};// 一般建構
my_pair mp4{stra};
// 套用 #2, my_pair<string, string>// 複製建構 copy-initialization
my_pair mp4copy = stra;
// 套用 #1, my_pair<const char*, const char*>
類別樣版特化 Deduction Guide for Template Specialization
上面給的例子都是一個類別樣版 my_pair
寫到底。但我們都忘記了樣版一個非常重要的特性,那就是樣版的特化了 (template specialization)。特仕版的樣版類別,如果也想做到不指定型別參數而宣告物件的話,又該怎麼辦呢?
假設同學們想要寫一個類別 wrap_ptr
用來包裝我們最容易 core dump 的好朋友指標們。為了廣 (裝) 泛 (逼) 的應用,我想要讓 wrap_ptr
能包裝一般 C++ 指標或是聰明指標 shared_ptr
。對於這兩種不同的東西內部處理方法可能不一樣,例如包裝一般指標的 wrap_ptr
在 destructor 解構時要 delete
釋放指標占用空間。
template<typename T> struct wrap_ptr;template<typename T> struct wrap_ptr<T*>
{
wrap_ptr(T* p) : p_(p) {}
~wrap_ptr() { delete p_; } int *p_;
};template<typename T> struct wrap_ptr<shared_ptr<T>>
{
wrap_ptr(shared_ptr<T> p) : p_(p) {} shared_ptr<T> p_;
};
依照描述寫的 wrap_ptr
類大致就長成上面這樣。使用上我們當然就很自然地宣告
wrap_ptr nptr{new int{100}};shared_ptr<int> isp{new int {666}};
wrap_ptr sptr{isp};
在沒有 deduction guide 教編譯器辨認的情況下,這樣編譯器是會報錯給你看的。好啦,那這情況下我們的自定義推導指引寫法不難,就只要照著上面的格式,把呼叫 wrap_ptr
建構式的格式當作一個函式寫成指引規則。
template<typename T>
wrap_ptr(T*) -> wrap_ptr<T*>;template<typename T>
wrap_ptr(shared_ptr<T>) -> wrap_ptr<shared_ptr<T>>;
意思就是
- 當我們呼叫
wrap_ptr
建構式參數能夠匹配出一個指向T
的指標,那這個wrap_ptr
呼叫就相當於正在構造一個wrap_ptr<T*>
特化類。 - 當我們呼叫
wrap_ptr
建構式剛好能匹配到包含T
的shared_ptr
,那這個wrap_ptr
呼叫相當於構造一個wrap_ptr<shared_ptr<T>>
特化類。
所以說上面的宣告也就等於
wrap_ptr nptr{new int{100}};
// wrap_ptr<int*>shared_ptr<int> isp{new int {666}};
wrap_ptr sptr{isp};
// wrap_ptr<shared_ptr<int>>
而且 nptr
在解構時還會因為我們的特仕版解構式自動呼叫 delete
釋放空間,真是美侖美奐豈不美哉。
小結
大致上 C++17 的類別樣版參數推導 class template argument deduction 和推導指引 deduction guide 用法就如同上面。當然這很多是行為上的描述,更多細部邊邊角角的用法還有正規的標準行為同學們可以參照 cppreference。
最前頭說到這是一個語法糖,說到底就看同學們處在什麼角色哪個位階。如果你就是一個一般業務邏輯開發者,調用底層函式接口,或是拼接 STL 提供的好用工具,那麼「類別樣版參數推導」的確就是個語法糖讓你少寫很多字開心開發的糖;然而如果同學們在食物鏈中是底層庫開發者,那麼這個功能可能是讓你又愛又恨,為了讓同伴們開發更方便,自己要多寫幾條 deduction guide 指引規則 XD,但轉過頭來想,其實這也都是讓我們的 C++ 更加簡明更加靈活的功能啊~。