1 類模板參數推導(CTAD)
1.1 曲線救國
? CTAD 的全稱是類模板參數推導(Class Template Argument Deduction),它允許在實例化類模板時,根據構造函數的參數類型自動推導模板參數,從而避免顯式指定模板參數。CTAD 是在 C++ 17 引入的,在這之前,只有模板函數支持根據函數參數自動推導模板參數,類模板不支持這樣的動作。代碼中實例化類模板必須顯式指定模板參數,十分不便,以致怨聲載道。
? C++ 11 引入了 auto,用作占位符衍生出了一種“工廠函數”慣用法,就是利用函數模板的推導規則,根據函數參數推導出模板參數,然后用推導出的模板參數實例化類對象。比如這個例子:
template<typename T, typename U>
class Foo {
public:Foo(T begin, U end) : m_begin(std::move(begin)), m_end(std::move(end)) {}
private:T m_begin;U m_end;
};template<typename T, typename U>
auto MakeFoo(T begin, U end) {return Foo<T, U>{begin, end};
}auto f2 = MakeFoo(42, 5.24);
1.2 隱式規則
? C++ 17 的 CTAD 默認通過類模板的構造函數定義模板參數的推導規則,和函數模板一樣,由構造函數的實參類型決定模板的參數類型。比如上一節的 Foo 類,不需要工廠函數,可以直接這樣用:
Foo f1{42, 5.24};
但是編譯器對類模板參數的推導是有條件的,那就是構造函數的形式參數列表必須能覆蓋全部模板參數,并且這些形參都必須參與推導,不能有在非推導語境中的模板參數。簡單來說,以下兩個類模板就不支持 CTAD 隱式推導:
template<typename T, typename U>
struct Bar {Bar(const T& t) {}
};template<typename T, typename U>
struct Widget {Widget(const T& t, typename std::type_identity_t<U> u) {}
};Bar b1(42); //錯誤
Widget w(1, 2.3); //錯誤,不能實例化 Widget<int, double> 類型
Bar 的構造函數形參列表只覆蓋了一個模板參數,另一個未知,不能通過構造函數同時推導出T 和 U 的類型。Widget 同樣不支持 CTAD,它的構造函數形參覆蓋了兩個模板參數,但是 U 出現在典型的非推導語境中,它不參與推導,編譯器不會根據實參 2.3 去推導 U 為 double,所以不能同時確定 T 和 U 的類型,也就無法實例化 Widget<int, double> 類型。
1.3 演化
? CTAD 在 C++ 20 改善了一下對聚合類型的支持。對于聚合類型,可以在不提供顯式構造函數的情況下,按照聚合類型的初始化順序實現類型推導。我們假設下面例子中的 Foo 是個聚合類型,為什么假設呢?因為是不是聚合類型還要看它那三個成員的類型,我們這里給出的例子能確保 Foo 實例化后是個聚合類型。
template<typename T, typename U, typename V>
struct Foo {T t;U u;V v;
};Foo f{ 1, 2.3, "Hello" };
大括號中的參數,按照按照聚合類型的初始化順序,以及傳值類型模板形參的推導規則,依次與 t、u 和 v 匹配,推導出 T、U 和 V 的類型為 int、double 和 const char* ,并用 Foo<int, double, const char*> 類型初始化 f。
2 推斷指示(Deduction Guides)
2.1 什么是推斷指示
? 盡管 CTAD 可以根據構造函數參數自動推導模板參數,但有些復雜情況下,隱式的規則可能無法滿足需求。此時我們可以利用 C++ 17 的顯示推斷指示(推斷指引),通過提供自定義的模板參數推導規則,讓編譯器知道如何確定類模板的模板參數,從而實現復雜類模板參數的自動推導。
? 推斷指示的語法大概是這個樣子的:
//deduction-guide:
explicit(opt) template-name (parameter-declaration-clause) -> simple-template-id ;
explicit 關鍵字是可選的,用于說明是否是顯式推斷指示。這個語法的重點是 減號和大于號組成的箭頭符號(->),箭頭符號左邊的 template-name 必須與箭頭符號右邊的 simple-template-id 具有相同的標識符。此外,如果一個 template-name 有多個推斷指引,那么它們的 parameter-declaration-clause 不能相同。以 std::tuple 為例,看看它的推斷指引的語法:
template<class... UTypes>
tuple(UTypes...) -> tuple<UTypes...>;
箭頭符號的左邊是 std::tuple 的構造函數(之一),其中 UTypes… 就是傳遞給構造函數的參數包(就是 parameter-declaration-clause)。箭頭符號的右邊是 std::tuple 的模板參數(simple-template-id),這個語法告訴編譯器,可以根據構造函數的參數推斷對應的類模板實例化使用的模板參數。
2.2 推斷指示的典型用法
? ContainerT 類有一個符合 CTAD 的構造函數,c1 就是通過這個構造函數提供的隱式規則,推導出 c1 的類型是 ContainerT。但是當我們希望傳遞一個大括號列表的時候,我們希望 T 是一個 vector 容器類型,此時構造函數提供的默認規則就無能為力了。c2 的定義會導致編譯錯誤,因為模板形參推導不支持大括號列表(auto 的推導支持將大括號列表推導為具體的 std::initializer_list 類型,但這是個寫死的規則,算不上推導)。
template <typename T>
class ContainerT {
public:ContainerT(T value) : val(value) {}T val;
};ContainerT c1(5); //ContainerT<int>
ContainerT c2({ 1, 5, 8 }); //錯誤
? 為了達成目標,我們需要為 ContainerT 類模板提供一個顯式推斷指示,通過顯式推斷指示明確模板參數 T 是 vector 類型。這是我們提供的推斷指示:
template <typename U>
ContainerT(std::initializer_list<U>) -> ContainerT<std::vector<U>>;
函數形參不支持自動推導成 std::initializer_list,我們干脆寫死就是 std::initializer_list,它與大括號列表是可以匹配的,相當于只需推導 std::initializer_list 的模板參數 U。當確定了 U 之后,我們希望 ContainerT 的模板參數是 std::vector,這就是這條顯式推斷指示的語法解釋。有了這條推斷指示,c2 的定義就合法了,并且也得到了我們希望的 ContainerT<std::vector> 類型。
? 再來看一個稍微復雜一點的例子:
template<typename T>
class Foo {
public:Foo(T value) {m_values.push_back(std::move(value));}template<class Iter>Foo(Iter begin, Iter end) {std::copy(begin, end, std::back_inserter(m_values));}
private:std::vector<T> m_values;
};Foo f1{ 5 }; // Foo<int> std::vector<int> vi{ 1, 3, 5, 7 };
Foo f2{ vi.begin(), vi.end() }; //錯誤
Foo 有兩個構造函數,第一個構造函數配合 CTAD,使得 f1 的定義沒有問題,但是 f2 的定義不被編譯器支持,因為通過構造函數傳遞的兩個迭代器,編譯器無法推斷出模板參數 T 的類型。當我們拿到一對迭代器的時候,我們可以通過類型萃取獲得迭代器的值類型,可以將這個值類型指代類模板參數 T。
? 按照這個思路得到推斷指示:
template<class Iter>
Foo(Iter begin, Iter end)->Foo<typename std::iterator_traits<Iter>::value_type>;
有了這個顯式推斷指示,上面例子代碼中 f2 的定義就合法了,并且得到的 f2 的類型也是我們希望的 Foo 類型。
2.3推斷指示的非典型用法
? 顯示推斷指引可以用在一些需要提供類模板特化版本的場合,比如下面這個例子中的 Foo 類模板,當面對指針類型的時候,比如字符串字面量,如果按照默認的構造函數提供的 CTAD,T 被推導為指針,成員 m_t 只保存了字符串的指針,在很多情況下,這都是比較危險的,一不小心就出現野指針訪問。傳統方法是針對指針類型提供特化版本,就如同這個例子一樣。
template<class T>
struct Foo {Foo(T t) { m_t = t; }T m_t;
};//特化版本
template<>
struct Foo<const char*> {Foo(const char* t) { m_t = t; }std::string m_t;
};
? 提供特化版本也沒什么不妥,就是要敲很多鍵盤。但是如果用顯式推斷指示,只需一行代碼就可以了:
//推斷指引
Foo(const char*)->Foo<std::string>;
少敲幾次鍵盤,還不需要提供函數體的代碼,通過類型的指示,復用原來的構造函數,有什么利用不用推斷指示?
3 總結
? CTAD 拖了這么長時間實在氣憤,好在顯式推斷指示讓類模板參數的自動推導比函數模板的模式匹配強大 N 倍,也就沒那么大的氣了。顯式推斷指示在標準庫中也是大量引用,比如你可以這樣定義一個 array:
std::array arr{1, 2, 3, 4, 5};
因為它有一條這樣的推斷指示:
template <class... T>
array(T&&... t) -> array<std::common_type_t<T...>, sizeof...(T)>;
參考資料
[1] Marc Gregoire, Professional C++ (Fifth Edition), John Wiley & Sons, Inc., 2021
[2] https://en.cppreference.com/w/cpp/language/class_template_argument_deduction
[3] Nicolai M. Josuttis, C++20 - The Complete Guide, http://leanpub.com/cpp20’
[4] Jacek Galowicz. C++17 STL Cookbook. Packtpub. 2017
[5] P0702:Language support for Constructor Template Argument Deduction
[6] CWG 2628:Implicit deduction guides should propagate constraints
關注作者的算法專欄
https://blog.csdn.net/orbit/category_10400723.html
關注作者的出版物《算法的樂趣(第二版)》
https://www.ituring.com.cn/book/3180