潮.C++17 | Class Template Argument Deduction 和 Deduction Guide 類別樣版參數推導

TJSW
12 min readApr 11, 2020

--

類別樣版參數推導 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_pairTU 推導出是 doubleint。所以 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 b
double 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 的重點 TU 呢?

在 C++17 之前,如同片頭說的其實我們就明確把型別標出來就完事了。

// prior to C++17
my_pair<char, char> mp2{ca}。

但時代在進步,為了少打一些字減少碳足跡。我們還是想要做到不手動標示型別就做出 my_pair mp2{ca},看起來也高大上一些。

Deduction Guide 推導指引

顯然我們 my_pair 的開發者需要提示編譯器怎麼從推導 A 到推導出 TU。這也就是 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 的兩個型別參數 TU

因此這樣的一行宣告 my_pair mp2{ca},就可以順利被編譯器辨認出是 my_pair<char, char>。而前面的 my_pair mp3{da},就被認成是 my_pair<double, double>

Deduction Guide 的特化

今天我們想特別針對「字串陣列」處理:因為 const char* 指標用來存資料要各種操作不太方便也不太安全也不太現代,所以我們想要做到:當 ctor 傳入的是 const char*[],我們想要將 my_pairTU 都設成 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>; // #1
explicit 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>>;

意思就是

  1. 當我們呼叫 wrap_ptr 建構式參數能夠匹配出一個指向 T 的指標,那這個 wrap_ptr 呼叫就相當於正在構造一個 wrap_ptr<T*> 特化類。
  2. 當我們呼叫 wrap_ptr 建構式剛好能匹配到包含 Tshared_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++ 更加簡明更加靈活的功能啊~。

--

--