Chapter9 类模板参数推导

在C++17之前,你必须明确指出类模板的所有参数。 例如,你不可以省略下面的double

  1. std::complex<double> c{5.1, 3.3};

也不可以省略下面代码中的第二个std::mutex

  1. std::mutex mx;
  2. std::lock_guard<std::mutex> lg(mx);

自从C++17起必须指明类模板参数的限制被放宽了。 通过使用 类模板参数推导(class template argument deduction) (CTAD), 只要编译器能根据初始值 推导出 所有模板参数,那么就可以不指明参数。

例如:

  • 你现在可以这么声明:
    1. std::complex c{5.1, 3.3}; // OK:推导出std::complex<double>
  • 你现在可以这么写:
    1. std::mutex mx;
    2. std::lock_guard lg{mx}; // OK:推导出std::lock_guard<std::mutex>
  • 你现在甚至可以让容器来推导元素类型:
    1. std::vector v1 {1, 2, 3}; // OK:推导出std::vector<int>
    2. std::vector v2 {"hello", "world"}; // OK:推导出std::vector<const char*>

9.1 使用类模板参数推导

只要能根据初始值推导出所有模板参数就可以使用类模板参数推导。 推导过程支持所有方式的初始化(只要保证初始化是有效的):

  1. std::complex c1{1.1, 2.2}; // 推导出std::complex<double>
  2. std::complex c2(2.2, 3.3); // 推导出std::complex<double>
  3. std::complex c3 = 3.3; // 推导出std::complex<double>
  4. std::complex c4 = {4.4}; // 推导出std::complex<double>

因为std::complex只需要一个参数就可以初始化并推导出模板参数T

  1. namespace std {
  2. template<typename T>
  3. class complex {
  4. constexpr complex(const T&re = T(), const T& im = T());
  5. ...
  6. }
  7. };

所以c3c4可以正确初始化。 对于如下声明:

  1. std::complex c1{1.1, 2.2};

编译器会查找到构造函数:

  1. constexpr complex(const T& re = T(), const T& im = T());

并调用。因为两个参数都是double类型,因此编译器会推导出T就是 double并生成如下代码:

  1. complex<double>::complex(const double& re = double(), const double& im = double());

注意推导的过程中模板参数必须没有歧义。也就是说,如下初始化代码不能通过编译:

  1. std::complex c5{5, 3.3}; // ERROR:尝试将T推导为int和double

像通常的模板一样,推导模板参数时不会使用隐式类型转换。

也可以对可变参数模板使用类模板参数推导。例如,对于一个如下定义的std::tuple

  1. namespace std {
  2. template<typename... Types>
  3. class tuple {
  4. public:
  5. constexpr tuple(const Types&...);
  6. ...
  7. };
  8. };

如下声明:

  1. std::tuple t{42, 'x', nullptr};

将推导出类型std::tuple<int, char, std::nullptr_t>

你也可以推导非类型模板参数。 例如,我们可以根据传入的参数同时推导数组的元素类型和元素数量:

  1. template<typename T, int SZ>
  2. class MyClass {
  3. public:
  4. MyClass (T(&)[SZ]) {
  5. ...
  6. }
  7. };
  8. MyClass mc("hello"); // 推导出T为const char,SZ为6

这里我们推导出SZ6,因为传入的字符串字面量有6个字符。

你甚至可以推导用作基类的lambda来实现重载 或者推导auto模板参数。

9.1.1 默认以拷贝方式推导

类模板参数推导过程中会首先尝试以拷贝的方式初始化。 例如,首先初始化一个只有一个元素的std::vector

  1. std::vector v1{42}; // 一个元素的vector<int>

然后使用这个vector初始化另一个vector,推导时会解释为创建一个拷贝:

  1. std::vector v2{v1}; // v2也是一个std::vector<int>

而不是创建一个只有一个元素的vector<vector<int>>

这个规则适用于所有形式的初始化:

  1. std::vector v2{v1}; // v2也是vector<int>
  2. std::vector v3(v1); // v3也是vector<int>
  3. std::vector v4 = {v1}; // v4也是vector<int>
  4. auto v5 = std::vector{v1}; // v5也是vector<int>

注意这是花括号初始化总是把列表中的参数作为元素这一规则的一个例外。 如果你传递一个只有一个vector的初值列来初始化另一个vector, 你将得到一个传入的vector的拷贝。然而,如果用多于一个元素的初值列来初始化的话 就会把传入的参数作为元素并推导出其类型作为模板参数(因为这种情况下无法解释为创建拷贝):

  1. std::vector vv{v1, v2}; // vv是一个vector<vector<int>>

这引出了一个问题就是对可变参数模板使用类模板参数推导时会发生什么:

  1. template<typename... Args>
  2. auto make_vector(const Args&... elems) {
  3. return std::vector{elem...};
  4. }
  5. std::vector<int> v{1, 2, 3};
  6. auto x1 = make_vector(v, v); // vector<vector<int>>
  7. auto x2 = make_vector(v); // vector<int>还是vector<vector<int>>?

目前不同的编译器会有不同的行为,这个问题还在讨论之中。

9.1.2 推导lambda的类型

通过使用类模板参数推导,我们可以用lambda的类型(确切的说是lambda生成的 闭包类型 ) 作为模板参数来实例化类模板。例如我们可以提供一个泛型类,对一个任意回调函数进行包装并统计调用次数:

  1. #include <utility> // for std::forward()
  2. template<typename CB>
  3. class CountCalls
  4. {
  5. private:
  6. CB callback; // 要调用的回调函数
  7. long calls = 0; // 调用的次数
  8. public:
  9. CountCalls(CB cb) : callback(cb) {
  10. }
  11. template<typename... Args>
  12. decltype(auto) operator() (Args&&... args) {
  13. ++calls;
  14. return callback(std::forward<Args>(args)...);
  15. }
  16. long count() const {
  17. return calls;
  18. }
  19. };

这里构造函数获取一个回调函数并进行包装,这样在初始化时会把参数的类型推导为CB。 例如,我们可以使用一个lambda作为参数来初始化一个对象:

  1. CountCalls sc{[](auto x, auto y) { return x > y; }};

这意味着排序准则sc的类型将被推导为CountCalls<TypeOfTheLambda>。 这样,我们可以统计出排序准则被调用的次数:

  1. std::sort(v.begin(), v.end(), // 排序区间
  2. std::ref(sc)); // 排序准则
  3. std::cout << "sorted with " << sc.count() << " calls\n";

这里包装过后的lambda被用作排序准则。注意这里必须要传递引用,否则std::sort()将会 获取sc的拷贝作为参数,计数时只会修改该拷贝内的计数器。

然而,我们可以直接把包装后的lambda传递给std::for_each(), 因为该算法(非并行版本)最后会返回传入的回调函数,以便于获取回调函数最终的状态:

  1. auto fo = std::for_each(v.begin(), v.end(), CountCalls{[](auto i) {
  2. std::cout << "elem: " << i << '\n';
  3. }});
  4. std::cout << "output with " << fo.count() << " calls\n";

输出将会如下(排序准则调用次数可能会不同,因为sort()的实现可能会不同):

  1. sorted with 39 calls
  2. elem: 19
  3. elem: 17
  4. elem: 13
  5. elem: 11
  6. elem: 9
  7. elem: 7
  8. elem: 5
  9. elem: 3
  10. elem: 2
  11. output with 9 calls

如果计数器是原子的,你也可以使用并行算法:

  1. std::sort(std::execution::par, v.begin(), v.end(), std::ref(sc));

9.1.3 没有类模板部分参数推导

注意,不像函数模板,类模板不能只指明一部分模板参数,然后指望编译器去推导剩余的部分参数。 甚至使用<>指明空模板参数列表也是不允许的。例如:

  1. template<typename T1, typename T2, typename T3 = T2>
  2. class C
  3. {
  4. public:
  5. C (T1 x = {}, T2 y = {}, T3 z = {}) {
  6. ...
  7. }
  8. ...
  9. };
  10. // 推导所有参数
  11. C c1(22, 44.3, "hi"); // OK:T1是int,T2是double,T3是const char*
  12. C c2(22, 44.3); // OK:T1是int,T2和3是double
  13. C c3("hi", "guy"); // OK:T1、T2、T3都是const char*
  14. // 推导部分参数
  15. C<string> c4("hi", "my"); // ERROR:只有T1显式指明
  16. C<> c5(22, 44.3); // ERROR:T1和T2都没有指明
  17. C<> c6(22, 44.3, 42); // ERROR:T1和T2都没有指明
  18. // 指明所有参数
  19. C<string, string, int> c7; // OK:T1、T2是string,T3是int
  20. C<int, string> c8(52, "my"); // OK:T1是int,T2、T3是string
  21. C<string, string> c9("a", "b", "c"); // OK:T1、T2、T3都是string

注意第三个模板参数有默认值,因此只要指明了第二个参数就不需要再指明第三个参数。

如果你想知道为什么不支持部分参数推导,这里有一个导致这个决定的例子:

  1. std::tuple<int> t(42, 43); // 仍然ERROR

std::tuple是一个可变参数模板,因此你可以指明任意数量的模板参数。 在这个例子中,并不能判断出只指明一个参数是一个错误还是故意的。

不幸的是,不支持部分参数推导意味着一个常见的编码需求并没有得到解决。 我们仍然不能简单的使用一个lambda作为关联容器的排序准则或者无序容器的hash函数:

  1. std::set<Cust> coll([] (const Cust& x, const Cust& y) { // 仍然ERROR
  2. return x.getName() > y.getName();
  3. });

我们仍然必须指明lambda的类型。例如:

  1. auto sortcrit = [] (const Cust& x, const Cust& y) {
  2. return x.getName() > y.getName();
  3. };
  4. std::set<Cust, decltype(sortcrit)> coll(sortcrit); // OK

仅仅指明类型是不行的,因为容器初始化时会尝试用给出的lambda类型创建一个lambda。 但这在C++17中是不允许的,因为默认构造函数只有编译器才能调用。 在C++20中如果lambda不需要捕获任何东西的话这将成为可能。

9.1.4 使用类模板参数推导代替快捷函数

原则上讲,通过使用类模板参数推导,我们可以摆脱已有的几个快捷函数模板, 这些快捷函数的作用其实就是根据传入的参数实例化相应的类模板。

一个明显的例子是std::make_pair(),它可以帮助我们避免指明传入参数的类型。 例如,在如下声明之后:

  1. std::vector<int> v;

我们可以这样:

  1. auto p = std::make_pair(v.begin(), v.end());

而不需要写:

  1. std::pair<typename std::vector<int>::iterator, typename std::vector<int>::iterator>
  2. p(v.begin(), v.end());

现在这种场景已经不再需要std::make_pair()了,我们可以简单的写为:

  1. std::pair p(v.begin(), v.end());

或者:

  1. std::pair p{v.begin(), v.end());

然而,从另一个角度来看std::make_pair()也是一个很好的例子, 它演示了有时便捷函数的作用不仅仅是推导模板参数。 事实上std::make_pair()会使传入的参数退化 (在C++03中以值传递,自从C++11起使用特征)。 这样会导致字符串字面量的类型(字符数组)被推导为const char*

  1. auto q = std::make_pair("hi", "world"); // 推导为指针的pair

这个例子中,q的类型为std::pair<const char*, const char*>

使用类模板参数推导可能会让事情变得更加复杂。 考虑如下这个类似于std::pair的简单的类的声明:

  1. template<typename T1, typename T2>
  2. struct Pair1 {
  3. T1 first;
  4. T2 second;
  5. Pair1(const T1& x, const T2& y) : first{x}, second{y} {
  6. }
  7. };

这里元素以引用传入,根据语言规则,当以引用传递参数时模板参数的类型不会退化。 因此,当调用:

  1. Pair1 p1{"hi", "world"}; // 推导为不同大小的数组的pair,但是……

T1被推导为char[3]T2被推导为char[6]。 原则上讲这样的推导是有效的。然而,我们使用了T1T2来声明成员 firstsecond,因此它们被声明为:

  1. char first[3];
  2. char second[6];

然而使用一个左值数组来初始化另一个数组是不允许的。它类似于尝试编译如下代码:

  1. const char x[3] = "hi";
  2. const char y[6] = "world";
  3. char first[3] {x}; // ERROR
  4. char second[6] {y}; // ERROR

注意如果我们声明参数时以值传参就不会再有这个问题:

  1. tempalte<typename T1, typename T2>
  2. struct Pair2 {
  3. T1 first;
  4. T2 second;
  5. Pair2(T1 x, T2 y) : first{x}, second{y} {
  6. }
  7. };

如果我们像下面这样创建新对象:

  1. Pair2 p2{"hi", "world"}; // 推导为指针的pair

T1T2都会被推导为const char*

然而,因为std::pair<>的构造函数以引用传参, 所以下面的初始化正常情况下应该不能通过编译:

  1. std::pair p{"hi", "world"}; // 看似会推导出不同大小的数组的pair,但是……

然而你,事实上它能通过编译,因为std::pair<>推导指引 , 我们将在下一小节讨论它。

9.2 推导指引

你可以定义特定的 推导指引 来给类模板参数添加新的推导或者修正构造函数定义的推导。 例如,你可以定义无论何时推导Pair3的模板参数,推导的行为都好像参数是以值传递的:

  1. template<typename T1, typename T2>
  2. struct Pair3 {
  3. T1 first;
  4. T2 second;
  5. Pair3(const T1& x, const T2& y) : first{x}, second{y} {
  6. }
  7. };
  8. // 为构造函数定义的推导指引
  9. tempalte<typename T1, typename T2>
  10. Pair3(T1, T2) -> Pair3<T1, T2>;

->的左侧我们声明了我们 想要推导什么 。 这里我们声明的是使用两个以值传递且类型分别为T1T2的对象 创建一个Pair3对象。 在->的右侧,我们定义了推导的结果。 在这个例子中,Pair3以类型T1T2实例化。

你可能会说这是构造函数已经做到的事情。 然而,构造函数是以引用传参,两者是不同的。 一般来说,不仅是模板,所有以值传递的参数都会 退化 ,而以引用传递的参数不会退化。 退化 意味着原生数组会转换为指针,并且顶层的修饰符例如const或者引用将会被忽略。

如果没有推导指引,对于如下声明:

  1. Pair3 p3{"hi", "world"};

参数x的类型是const char(&)[3],因此T1被推导为char[3], 参数y的类型是const char(&)[6],因此T2被推导为char[6]

有了推导指引后,模板参数就会退化。这意味着传入的数组或者字符串字面量会退化为相应的指针类型。 现在,如下声明:

  1. Pair3 p3{"hi", "world"};

推导指引会发挥作用,因此会以值传参。因此,两个类型都会退化为const char*, 然后被用作模板参数推导的结果。上面的声明和如下声明等价:

  1. Pair3<const char*, const char*> p3{"hi", "world"};

注意构造函数仍然以引用传参。推导指引只和模板参数的推导相关, 它与推导出T1T2之后实际调用的构造函数无关。

9.2.1 使用推导指引强制类型退化

就像上一个例子展示的那样,重载推导规则的一个非常重要的用途就是确保模板参数T在 推导时发生 退化 。考虑如下的一个经典的类模板:

  1. template<typename T>
  2. struct C {
  3. C(const T&) {
  4. }
  5. ...
  6. };

这里,如果我们传递一个字符串字面量"hello",传递的类型将是 const char(&)[6],因此T被推导为char[6]

  1. C x{"hello"}; // T被推导为char[6]

原因是当参数以引用传递时模板参数不会 退化 为相应的指针类型。

通过使用一个简单的推导指引:

  1. template<typename T> C(T) -> C<T>;

我们就可以修正这个问题:

  1. C x{"hello"}; // T被推导为const char*

推导指引以值传递参数因此"hello"的类型T会退化为const char*

因为这一点,任何构造函数里传递引用作为参数的模板类都需要一个相应的推导指引。 C++标准库中为pair和tuple提供了相应的推导指引。

9.2.2 非模板推导指引

推导指引并不一定是模板,也不一定应用于构造函数。例如,为下面的结构体添加的推导指引也是有效的:

  1. template<typename T>
  2. struct S {
  3. T val;
  4. };
  5. S(const char*) -> S<std::string>; // 把S<字符串字面量>映射为S<std::string>

这里我们创建了一个没有相应构造函数的推导指引。推导指引被用来推导参数T, 然后结构体的模板参数就相当于已经被指明了。

因此,下面所有初始化代码都是正确的,并且都会把模板参数T推导为std::string

  1. S s1{"hello"}; // OK,等同于S<std::string> s1{"hello"};
  2. S s2 = {"hello"}; // OK,等同于S<std::string> s2 = {"hello"};
  3. S s3 = S{"hello"}; // OK,两个S都被推导为S<std::string>

因为传入的字符串字面量能隐式转换为std::string,所以上面的初始化都是有效的。

注意聚合体需要列表初始化。下面的代码中参数推导能正常工作, 但会因为没有使用花括号导致初始化错误:

  1. S s4 = "hello"; // ERROR:不能不使用花括号初始化聚合体
  2. S s5("hello"); // ERROR:不能不使用花括号初始化聚合体

9.2.3 推导指引VS构造函数

推导指引会和类的构造函数产生竞争。类模板参数推导时会根据重载情况选择最佳匹配的构造函数/推导指引。 如果一个构造函数和一个推导指引匹配优先级相同,那么将会优先使用推导指引。

考虑如下定义:

  1. template<typename T>
  2. struct C1 {
  3. C1(const T&) {
  4. }
  5. };
  6. C1(int)->C1<long>;

当传递一个int时将会使用推导指引,因为根据重载规则它的匹配度更高。

因此,T被推导为long

  1. C1 x1{42}; // T被推导为long

然而,如果我们传递一个char,那么构造函数的匹配度更高(因为不需要类型转换), 这意味着T会被推导为char

  1. C1 x3{'x'}; // T被推导为char

在重载规则中,以值传参和以引用传参的匹配度相同的。 然而在相同匹配度的情况下将优先使用推导指引。 因此,通常会把推导指引定义为以值传参(这样做还有类型退化的优点)。

9.2.4 显式推导指引

推导指引可以用explicit声明。 当出现explicit不允许的初始化或转换时这一条推导指引就会被忽略。例如:

  1. template<typename T>
  2. struct S {
  3. T val;
  4. };
  5. explicit S(const char*) -> S<std::string>;

如果用拷贝初始化(使用=)将会忽略这一条推导指引。 这意味着下面的初始化是无效的:

  1. S s1 = {"hello"}; // ERROR(推导指引被忽略,因此是无效的)

直接初始化或者右侧显式推导的方式仍然有效:

  1. S s2{"hello"}; // OK,等同于S<std::string> s2{"hello"};
  2. S s3 = S{"hello"}; // OK
  3. S s4 = {S{"hello"}}; // OK

另一个例子如下:

  1. template<typename T>
  2. struct Ptr
  3. {
  4. Ptr(T) { std::cout << "Ptr(T)\n"; }
  5. template<typename U>
  6. Ptr(U) { std::cout << "Ptr(U)\n"; }
  7. };
  8. template<typename T>
  9. explicit Ptr(T) -> Ptr<T*>;

上面的代码会产生如下结果:

  1. Ptr p1{42}; // 根据推导指引推导出Ptr<int*>
  2. Ptr p2 = 42; // 根据构造函数推导出Ptr<int>
  3. int i = 42;
  4. Ptr p3{&i}; // 根据推导指引推导出Ptr<int**>
  5. Ptr p4 = &i; // 根据构造函数推导出Ptr<int*>

9.2.5 聚合体的推导指引

泛型聚合体中也可以通过使用推导指引来支持类模板参数推导。例如,对于:

  1. template<typename T>
  2. struct A {
  3. T val;
  4. };

在没有推导指引的情况下尝试使用类模板参数推导会导致错误:

  1. A i1{42}; // ERROR
  2. A s1("hi"); // ERROR
  3. A s2{"hi"}; // ERROR
  4. A s3 = "hi"; // ERROR
  5. A s4 = {"hi"}; // ERROR

你必须显式指明参数的类型T

  1. A<int> i2{42};
  2. A<std::string> s5 = {"hi"};

然而,如果有如下推导指引的话:

  1. A(const char*) -> A<std::string>;

你就可以像下面这样初始化聚合体:

  1. A s2{"hi"}; // OK
  2. A s4 = {"hi"}; // OK

注意你仍然需要使用花括号(像通常的聚合体初始化一样)。 否则,类型T能成功推导出来,但初始化会错误:

  1. A s1("hi"); // ERROR:T是string,但聚合体不能初始化
  2. A s3 = "hi"; // ERROR:T是string,但聚合体不能初始化

std::array的推导指引是一个有关聚合体推导指引的进一步的例子。

9.2.6 标准推导指引

C++17标准在标准库中引入了很多推导指引。

pair和tuple的推导指引

正如在推导指引的动机中介绍的一样,std::pair需要推导指引来确保 类模板参数推导时会推导出参数的退化类型:

  1. namespace std {
  2. template<typename T1, typename T2>
  3. struct pair {
  4. ...
  5. constexpr pair(const T1& x, const T2& y); // 以引用传参
  6. ...
  7. };
  8. template<typename T1, typename T2>
  9. pair(T1, T2) -> pair<T1, T2>; // 以值推导类型
  10. }

因此,如下声明:

  1. std::pair p{"hi", "wrold"}; // 参数类型分别为const char[3]和const char[6]

等价于:

  1. std::pair<const char*, const char*> p{"hi", "world"};

可变参数类模板std::tuple也使用了相同的方法:

  1. namespace std {
  2. template<typename... Types>
  3. class tuple {
  4. public:
  5. constexpr tuple(const Types&...); // 以引用传参
  6. template<typename... UTypes> constexpr tuple(UTypes&&...);
  7. ...
  8. };
  9. template<typename... Types>
  10. tuple(Types...) -> tuple<Types...>; // 以值推导类型
  11. }

因此,如下声明:

  1. std::tuple t{42, "hello", nullptr};

将会推导出t的类型为std::tuple<int, const char*, std::nullptr_t>

从迭代器推导

为了能够从表示范围的两个迭代器推导出元素的类型, 所有的容器类例如std::vector<>都有类似于如下的推导指引:

  1. // 使std::vector<>能根据初始的迭代器推导出元素类型
  2. namespace std {
  3. template<typename Iterator>
  4. vector(Iterator, Iterator) -> vector<typename iterator_traits<Iterator>::value_type>;
  5. }

下面的例子展示了它的作用:

  1. std::set<float> s;
  2. std::vector v1(s.begin(), s.end()); // OK,推导出std::vector<float>

注意这里必须使用圆括号来初始化。如果你使用花括号:

  1. std::vector v2{s.begin(), s.end()}; // 注意:并不会推导出std::vector<float>

那么这两个参数将会被看作一个初值列的两个元素(根据重载规则初值列的优先级更高)。 因此,它等价于:

  1. std::vector<std::set<float>::iterator> v2{s.begin(), s.end()};

这意味着我们初始化的vector有两个元素,第一个元素是一个指向首元素的迭代器, 第二个元素是指向尾后元素的迭代器。

另一方面,考虑:

  1. std::vector v3{"hi", "world"}; // OK,推导为std::vector<const char*>
  2. std::vector v4("hi", "world"); // OOPS:运行时错误

v3的声明会初始化一个拥有两个元素的vector(两个元素都是字符串字面量), v4的初始化会导致运行时错误,很可能会导致core dump。 问题在于字符串字面量被转换成为字符指针,也算是有效的迭代器。 因此,我们传递了两个 不是 指向同一个对象的迭代器。换句话说,我们指定了一个无效的区间。 我们推导出了一个std::vector<const char>,但是根据这两个字符串字面量在内存中的 位置关系,我们可能会得到一个bad_alloc异常, 也可能会因为没有距离而得到一个core dump, 还有可能得到两个位置之间的未定义范围内的字符。

总而言之,使用花括号是最佳的初始化vector的 元素 的方法。 唯一的例外是传递单独一个vector(这时会优先进行拷贝)。 当传递别的含义的参数时,使用圆括号会更好。

在任何情况下,对于像std::vector<>或其他STL容器一样拥有复杂的构造函数的类模板, 强烈建议不要使用类模板参数推导 ,而是显式指明类型。

std::array<>推导

有一个更有趣的例子是关于std::array<>的。 为了能够同时推导出元素的类型和数量:

  1. std::array a{42, 45, 77}; // OK,推导出std::array<int, 3>

而定义了下面的推导指引(间接的):

  1. // 让std::array<>推导出元素的数量(元素的类型必须相同):
  2. namespace std {
  3. template<typename T, typename... U>
  4. array(T, U...) -> array<enable_if_t<(is_same_v<T, U> && ...), T>, (1 + sizeof...(U))>;
  5. }

这个推导指引使用了折叠表达式

  1. (is_same_v<T, U> && ...)

来确保所有参数的类型相同。

因此,下面的代码是错误的:

  1. std::array a{42, 45, 77.7}; // ERROR:元素类型不同

注意类模板参数推导的初始化甚至可以在编译期上下文中生效:

  1. constexpr std::array arr{0, 8, 15}; // OK,推导出std::array<int, 3>

(Unordered) Map推导

想让推导指引正常工作是非常困难的。 可以通过给关联容器 (mapmultimapunordered_mapunordered_multimap) 定义推导指引来展示其复杂程度。

这些容器里元素的类型是 std::pair<const keytype, valuetype>。 这里const是必需的,因为元素的位置取决于key的值,这意味着如果能修改key的值的话 会导致容器内部陷入不一致的状态。

在C++17标准中为std::map

  1. namespace std {
  2. template<typename Key, typename T, typename Compare = less<Key>,
  3. typename Allocator = allocator<pair<const Key, T>>>
  4. class map {
  5. ...
  6. };
  7. }

想出的第一个解决方案是,为如下构造函数:

  1. map(initializer_list<pair<const Key, T>>, const Compare& = Compare(),
  2. const Allocator& = Allocator());

定义了如下的推导指引:

  1. namespace std {
  2. template<typename Key, typename T, typename Compare = less<Key>,
  3. typename Allocator = allocator<pair<const Key, T>>>
  4. map(initializer_list<pair<const Key, T>>, Compare = Compare(), Allocator = Allocator())
  5. -> map<Key, T, Compare, Allocator>;
  6. }

所有的参数都以值传递,因此这个推导指引允许传递的比较器和分配器 像之前讨论的一样发生退化。 然而,我们在推导指引中直接使用了和构造函数中完全相同的元素类型, 这意味着初值列的key的类型必须是const的。 因此,下面的代码不能工作 (如同Ville Voutilainen在https://wg21.link/lwg3025中指出的一样):

  1. std::pair elem1{1, 2};
  2. std::pair elem2{3, 4};
  3. ...
  4. std::map m1{elem1, elem2}; // 原来的C++17推导指引会ERROR

这是因为elem1elem2会被推导为std::pair<int, int>, 而推导指引需要pair中的第一个元素是const的类型,所以不能成功匹配。 因此,你仍然要像下面这么写:

  1. std::map<int, int> m1{elem1, elem2}; // OK

因此,推导指引中的const必须被删掉:

  1. namespace std {
  2. template<typename Key, typename T, typename Compare = less<Key>,
  3. typename Allocator = allocator<pair<const Key, T>>>
  4. map(initializer_list<pair<Key, T>>, Compare = Compare(), Allocator = Allocator())
  5. -> map<Key, T, Compare, Allocator>;
  6. }

然而,为了继续支持比较器和分配器的退化,我们还需要为const key类型的pair定义一个 重载版本。否则当传递一个const key类型的参数时将会使用构造函数来推导类型, 这样会导致传递const key和非const key参数时推导的结果会有细微的不同。

智能指针没有推导指引

注意C++标准库中某些你觉得应该有推导指引的地方实际上没有推导指引。

你可能会希望共享指针和独占指针有推导指引,这样你就不用写:

  1. std::shared_ptr<int> sp{new int(7)};

而是直接写:

  1. std::shared_ptr sp{new int(7)}; // 不支持

上边的写法是错误的,因为相应的构造函数是一个模板, 这意味着没有隐式的推导指引:

  1. namespace std {
  2. template<typename T> class shared_ptr {
  3. public:
  4. ...
  5. template<typename Y> explicit shared_ptr(Y* p);
  6. ...
  7. };
  8. }

这里YT是不同的模板参数, 这意味着虽然能从构造函数推导出Y,但不能推导出T。 这是一个为了支持如下写法的特性:

  1. std::shared_ptr<Base> sp{new Derived(...)};

假如我们要提供推导指引的话,那么相应的推导指引可以简单的写为:

  1. namespace std {
  2. template<typename Y> shared_ptr(Y*) -> shared_ptr<Y>;
  3. }

然而,这可能导致当分配数组时也会应用这个推导指引:

  1. std::shared_ptr sp{new int[10]}; // OOPS:推导出shared_ptr<int>

就像经常在C++遇到的一样,我们陷入了一个讨厌的C问题:就是一个对象的指针和一个对象的数组 拥有或者退化以后拥有相同的类型。

这个问题看起来很危险,因此C++标准委员会决定不支持这么写。 对于单个对象,你仍然必须这样调用:

  1. std::shared_ptr<int> sp1{new int}; // OK
  2. auto sp2 = std::make_shared<int>(); // OK

对于数组则要:

  1. std::shared_ptr<std::string> p(new std::string[10],
  2. [] (std::string* p) {
  3. delete[] p;
  4. });

或者,使用实例化原生数组的智能指针的新特性,只需要:

  1. std::shared_ptr<std::string[]> p{new std::string[10]};