今天我想要介紹 C++ 17 的類別樣版參數推導(Class Template Argument Deduction)。在 C++ 17 之前,樣版參數推導(Template Argument Deduction)只會被施行於「函式樣版(Function Template)」。以 std::pair
為例,如果我們要呼叫 std::pair
的建構式創建一個 std::pair<double, int>
暫時物件,我們必須指定所有 std::pair
的樣版參數:
std::pair<double, int>(5.0, 1)
然而明確地指定樣版參數太過繁瑣,我們通常會另外定義一個函式樣版(Function Template),讓編譯器以「函式參數的型別(Function Argument Type)」推導「樣版參數(Template Argument)」。與 std::pair
對應的函式樣版是 std::make_pair
:
template <typename T, typename U>
std::pair<T, U> make_pair(T &&t, U &&u) {
return std::pair<T, U>(std::forward<T>(t), std::forward<U>(u));
}
有了 std::make_pair
函式樣版,我們就能省略樣版參數:
std::make_pair(5.0, 1)
而 C++ 17 新增的「類別樣版參數推導」能讓我們直接在呼叫建構式的時候推導類別樣版(Class Template)的樣版參數(Template Argument):
std::pair(5.0, 1)
當然,宣告區域變數時,也能利用「類別樣版參數推導」簡化程式碼:
std::pair p(5.0, 1);
除此之外,一些 Non-copyable 與 Non-movable 的類別樣版沒有對應的函式樣版[1],我們只能明確地指定所有樣版參數。舉例來說,std::scoped_lock
就沒有對應的函式樣版:
std::scoped_lock<std::mutex, std::mutex> l(m1, m2);
如果善用類別樣版推導,上面的程式碼可以簡化為:
std::scoped_lock l(m1, m2);
類別樣版參數推導規則
C++ 17 編譯器在推導類別樣版參數的時候,會先搜集所有該類別樣版的建構式(不含「類別樣版特化」)或推導指引(下一節會介紹)。然後以重載解析規則(Overload Resolution)找出最合適的建構式。以下面的 Example
類別樣版為例:
template <typename S, typename T>
class Example {
public:
Example(S s, T t) {} // #1
Example(S s, T t, const char *str) {} // #2
};
template <typename S>
class Example<S, int> {
public:
Example(S s, int t) {} // #3
Example(S s, int t, int t2) {} // #4
};
在推導樣版參數的時候,編譯器只會考慮建構式 #1 和 #2。例如,以下程式碼分別會以建構式 #1 和 #2 推得 S
為 int
而 T
為 double
。
Example x(1, 2.0);
Example y(1, 2.0, "hello");
而下面的程式碼會導致編譯錯誤,因為只有建構式 #1 和 #2 在候選清單上,但傳入的參數都不匹配:
Example z(1, 2, 3); // compilation error
如果要使用建構式 #4,我們只能和以前一樣明確地指定樣版參數:
Example<int, int> z(1, 2, 3);
最後是一個比較複雜的例子:
Example w(1, 2);
這個例子中,編譯器會先以建構式 #1 推得 S
與 T
都是 int
,接著因為 Example<S, int>
是一個類別樣版特化,所以會呼叫建構式 #3。
右值參考與型別推導
雖然類別樣版參數的推導規則與函式樣版的推導規則相似,但兩者對 Type &&
有不同的解讀。首先,我們必需區分類別樣版參數與函式樣版參數(建構式樣版參數)。以下範例程式碼之中 S
與 T
是類別樣版參數,U
與 V
是函式樣版參數:
template <typename S, typename T>
class Test {
public:
template <typename U, typename V>
Test(S s, T &&t, U u, V &&v) {}
};
兩者對 Type &&
的解讀分述如下:
- 函式樣版參數推導會將
Type &&
當成 Forwarding Reference。具體的說,如果傳入的實際參數(Actual Argument)是右值,則該型別會被推定為傳入參數的型別(不包含參考),而該形式參數(Formal Argument)的型別會是右值參考(Rvalue Reference)。如果傳入的實際參數(Actual Argument)是左值,則該型別會被推定為傳入參數的左值參考型別(Lvalue Reference Type),而形式參數(Formal Argument)的型別會依據 Reference Collapsing(參考摺疊)規則變成左值參考(Lvalue Reference)。 - 類別樣版參數推導不會將
Type &&
當成 Forwarding Reference。Type &&
只會被當成右值參考(Rvalue Reference)。如果實際參數(Actual Argument)是左值,則該候選建構式會被忽略。如果每個候選建構式都被忽略,則編譯器會產生錯誤訊息。
以 Test
類別樣版為例,傳入的實際參數與推導出的樣版參數(S
、T
、U
、V
)與形式參數型別(s
、t
、u
、v
)如下表:
int x = 2, y = 4; | S | s | T | t | U | u | V | v |
---|---|---|---|---|---|---|---|---|
Test a(1, 2, 3, 4); | int | int | int | int&& | int | int | int | int&& |
Test b(1, 2, 3, y); | int | int | int | int&& | int | int | int& | int& |
Test c(1, x, 3, 4); | 編譯錯誤 |
這個不對稱的規則是為了避免一些意外的推導結果。以下面的程式碼之中,建構式 #2 的參數 x
通常會被理解為右值參考:
template <typename T>
class MyClass {
public:
MyClass(const T &x) {} // #1
MyClass(T &&x) {} // #2
};
如果 C++ 17 編譯器將 T &&
視為 Forwarding Reference,當使用者將左值 std::string
傳入 MyClass
建構式時:
std::string m("hello");
MyClass x(m);
T
會被推導為 std::string &
。但是如果 T
是參考型別,建構式 #1 與 #2 會有相同的 Function Signature,進而造成編譯錯誤。
為了避免意外的推導結果,C++ 17 標準最後就只將類別樣版參數的 T &&
視為右值參考。
推導指引(Deduction Guide)
在一些情況下 C++ 17 編譯器沒有辦法只從建構式推導出樣版參數,因此我們需要「推導指引(Deduction Guide)」。一個典型的例子是 std::vector(iterator begin, iterator end)
建構式:
namespace std {
template <typename T, typename Allocator = std::allocator<T> >
class vector {
public:
class iterator { /* ... */ };
vector(iterator begin, iterator end);
};
} // namespace std
以下範例第二行,我們以 std::vector(iterator begin, iterator end)
建構 dest
物件。我們希望編譯器能從 source.begin()
推導出 T
:
std::vector<int> source{1, 2, 3, 4, 5};
std::vector dest(source.begin(), source.end());
為了讓編譯器推導出 T
,C++ 標準函式庫會在同一個視野宣告「推導指引」:
namespace std {
template <typename Iterator>
vector(Iterator begin, Iterator end)
-> vector<typename iterator_traits<Iterator>::value_type>;
} // namespace std
這個推導指引能讓編譯器在看到 vector(Iterator, Iterator)
的時候,以 iterator_traits<Iterator>::value_type
為第一個樣版參數。
Note
根據 C++ 17 標準,前述 std::vector
推導指引必須與 std::vector
定義於同一個視野。然而至截稿前 Ubuntu 18.04 LTS 內建的 g++ 編譯器還沒有完整支援。暫時的解決方法就是在測試 std::vector dest(source.begin(), source.end());
之前,插入前述的推導指引。
推導指引(Deduction Guide)的文法如下:
explicitopt template-name ( parameter-declaration-clause ) -> simple-template-id ;
通常推導指引會是樣版宣告(Template Declaration)的子句,兩者合併後文法如下:
template < template-parameter-list >explicitopt template-name ( parameter-declaration-clause ) -> simple-template-id ;
各個部分分述如下:
- explicit(可選) -- 如果有指定 explicit 關鍵字,在隱式轉型時,編譯器不會考慮這個推導指引。
- template-name 是樣版名稱。上面的例子是
vector
。 - parameter-declaration-clause 是推導指引的參數列表。上面的例子是
Iterator begin, Iterator end
。 - simple-template-id 是推導結果。其中,樣版名稱必須與 template-name 一致。而樣版參數通常會以「推導指引的參數型別」為計算基礎。上面的例子中,我們以
iterator_traits<Iterator>::value_type
抽取vector
的第一個樣版參數T
。
另外,值得注意的是 parameter-declaration-clause 裡面的 Type &&
會被視為 Forwarding Reference。如果你的推導指引會有右值參考必須特別注意。如果你不希望推導指引本身的樣版參數因為 Forwarding Reference 被推導為左值參考,請以 std::remove_reference_t
清除參考型別或以 std::enable_if
觸發 SFINAE(擇一使用)。概念程式碼如下:
#include <type_traits>
template <typename T>
class RVExample {
public:
typedef T value_type;
RVExample(T &&) {}
};
#ifdef OPTION1
// Option 1: Remove reference type
template <typename T>
RVExample(T &&t) -> RVExample<std::remove_reference_t<T> &&>;
#else
// Option 2: Disable deduction guide if T is a reference type
template <typename T,
typename = std::enable_if_t<!std::is_reference<T>::value>>
RVExample(T &&t) -> RVExample<T &&>;
#endif
參考資料
- P0091R3: Template argument deduction for class templates (Rev. 6)
- P0433R2: Toward a resolution of US7 and US14: Integrating template deduction for class templates into the standard library
- P0512R0: Class template argument deduction assorted NB resolution and issues
[1] | 在 P0091R3 提議於 C++ 17 加入類別樣版參數推導的時候,「Non-copyable 與 Non-movable 的型別」無法作為函式回傳型別。P0091R3 的提案動機就有提及 template <typename... Types>
std::scoped_lock<Types...> make_scoped_lock(Types&... args) {
return std::scoped_lock<Types...>(args...);
}
|
Note
如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。