C++ 17 類別樣版參數推導

今天我想要介紹 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 推得 SintTdouble

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 推得 ST 都是 int,接著因為 Example<S, int> 是一個類別樣版特化,所以會呼叫建構式 #3。

右值參考與型別推導

雖然類別樣版參數的推導規則與函式樣版的推導規則相似,但兩者對 Type && 有不同的解讀。首先,我們必需區分類別樣版參數函式樣版參數(建構式樣版參數)。以下範例程式碼之中 ST 是類別樣版參數,UV 是函式樣版參數:

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 類別樣版為例,傳入的實際參數與推導出的樣版參數(STUV)與形式參數型別(stuv)如下表:

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 的提案動機就有提及 std::scoped_lock。不過 C++ 17 的另一個提案 P0135R1 修改了 R-value 的定義並且要求編譯器一定要對「滿足特定條件的函式」施行 Copy Elision。所以我們其實還是能在 C++ 17 定義與 std::scoped_lock 對應的函式樣版(如下所示)。不過因為 C++ 17 已經支援類別樣版參數推導,所以沒有將 std::make_scoped_lock() 加進標準函式庫的迫切需求。

template <typename... Types>
std::scoped_lock<Types...> make_scoped_lock(Types&... args) {
  return std::scoped_lock<Types...>(args...);
}

Note

如果你喜歡這篇文章,請追蹤羅根學習筆記粉絲專頁。最新文章都會在粉絲專頁發佈通知。