第39章 用户自定义推导指引(C++17)

在第38章中,我们了解了一些关于类模板的模板实参推导的内容。不过,在介绍这部分内容的过程中我省略了一个重要的问题,为了解释这个问题我们首先需要实现一个自己的std::pair,由于标准库的std::pair比较烦琐,因此下面实现了一个精简版:

template<typename T1, typename T2>
struct MyPair {
  MyPair(const T1& x, const T2& y) 
      : first(x), second(y) {}
  T1 first;
  T2 second;
};

这份代码虽然非常简单,但已经能满足基本的要求。接下来,我们利用类模板的模板实参推导来实例化这个模板:

MyPair p(5, 11.7);

代码顺利地通过编译,没有任何问题。我们再对代码做一点修改:

MyPair p1(5, "hello");

编译出错了,编译器提示T2是一个char [6]类型。这一点和我们预测的结果有所不同,要知道使用std::pair或者std::make_pair推导出的T2都是const char *类型:

auto p3 = std::make_pair(5, "hello");      // T2 = const char*
std::pair p4(5, "hello");                  // T2 = const char*

为什么会出现这种情况呢?读者首先能想到的应该是“数组类型衰退为指针”。没错,原因就是这个。由于std::pairMyPair构造函数的形参都是引用类型,因此从构造函数的角度它们都无法触发数组类型的衰退。但无论是std::make_pair还是std::pair,都有自己的办法让数组类型衰退为指针。对于std::make_pair来说,从C++11开始它使用std::decay主动让数组类型衰退为指针,而在C++11之前,它用传值的办法来达到让数组类型衰退为指针的目的。当然,我们可以仿造std::make_pair写出自己的make_mypair

template<typename T1, typename T2>
inline MyPair<T1, T2>
make_mypair(T1 x, T2 y)
{
  return MyPair<T1, T2>(x, y);
}

auto p5 = make_mypair(5, "hello");

接下来的问题是std::pair如何让数组类型衰退?我们在std::pair的实现代码中并不能发现任何一个按值传参的构造函数。

想解决上面的问题就需要用到用户自定义推导指引了。仔细阅读标准库会发现这么一句简单的代码:

template<typename _T1, typename _T2> pair(_T1, _T2) -> pair<_T1, _T2>;

这是一条典型的用户自定义推导指引,其中template<typename _T1, typename _T2> pair是类模板名,(_T1, _T2)是形参声明,pair<_T1, _T2>是指引的目标类型。它在语法上有点类似函数的返回类型后置,只不过以类名代替了函数名。用户自定义推导指引的目的是告诉编译器如何进行推导,比如这条语句,它告诉编译器直接推导按值传递的实参,更直观地说,编译器按照pair(_T1, _T2)的形式推导std::pair p4(5, "hello"),由于_T2并非引用,因此_T2推导出的是"hello"经过衰退后的const char*,编译器最终推导出的类型为pair<int, const char*>。虽然std::pair的代码中没有按值传参的构造函数,但是用户自定义推导指引强行让编译器进行了这种推导。值得注意的是,用户自定义推导指引并不会改变类模板本身的定义,只是在模板的推导阶段起到引导作用,也就是说std::pair中依旧不会存在按值传参的构造函数。

了解了这些之后,接下来的事情就容易多了,我们只需要给MyPair加上一句类似的用户自定义推导指引即可:

template<typename T1, typename T2> MyPair(T1, T2)->MyPair<T1, T2>;
MyPair p6(5, "hello");

实际上,用户自定义推导指引的用途并不局限于以上这一种,我们可以根据实际需要来灵活使用,请看下面的例子:

std::vector v{ 1, 5u, 3.0 };

以上代码的目的很简单,它希望将15u3.0都装进std::vector类型的容器中,但是显然std::vector的容器是无法满足需求的,因为初始化元素的类型不同。为了让上述代码能够合法使用,添加用户自定义推导指引是一个不错的方案:

namespace std {
  template<class … T> vector(T&&…t)->vector<std::common_type_t<T…>>;
}
std::vector v{ 1, 5u, 3.0 };

在这条用户自定义推导指引的作用下,编译器将15u3.0的类型intunsigned intdouble交给std::common_type_t处理,并使用计算结果作为模板实参实例化类模板。最终v的类型为std::vector<double>

上面的两个例子用户自定义推导指引的对象都是模板,但事实上用户自定义推导指引不一定是模板,例如:

MyPair(int, const char*)->MyPair<long long, std::string>;
MyPair p7(5, "hello");

在上面这段代码中,p7的类型为MyPair<long long, std::string>,因为初始化列表中5hello符合指引的形参声明,所以按照自定义的规则该类模板应该被实例化为MyPair<long long, std::string>

值得注意的是,在语法上用户自定义推导指引还支持explicit说明符,作用和其他使用场景类似,都是要求对象显式构造:

explicit MyPair(int, const char*)->MyPair<long long, std::string>;

MyPair p7_1(5, "hello");
MyPair p7_2{ 5, "hello" };
MyPair p7_3 = { 5, "hello" };

explicit说明符的作用下p7_3无法编译成功,这是因为p7_3并非显式构造,所以无法触发用户自定义推导指引。

通过上述这些例子读者应该能看出来,用户自定义推导指引声明的前半部分就如同一个构造函数声明,这就引发了一个新的问题,当类模板的构造函数和用户自定义推导指引同时满足实例化要求的时候编译器是如何选择的?接下来,我对MyPair的构造函数进行了一些修改以解答这个问题:

template<typename T1, typename T2>
struct MyPair {
  MyPair(T1 x, T2 y)
      : first(x), second(y) {}
  T1 first;
  T2 second;
};

MyPair(int, const char*)->MyPair<long long, std::string>;

MyPair p8(5u, "hello");
MyPair p9(5, "hello");

在上面的代码中,MyPair的构造函数的形参被修改为按值传递的方式。最终代码能够顺利地编译通过,但是编译器对p8p9的处理方式却不相同,对于p8,编译器使用了默认的推导规则,其推导类型为MyPair<unsigned int, const char *>;而对p9,编译器使用了用户自定义的推导规则MyPair<long long, std::string>。由此可见,当类模板的构造函数和用户自定义推导指引同时满足实例化要求的时候,编译器优先选择用户自定义推导指引。

在C++20标准发布之前聚合类型的类模板是无法进行模板实参推导的,例如:

template<class T>
struct Wrap {
  T data;
};

Wrap w1{ 7 };
Wrap w2 = { 7 };

在上面的代码中w1w2都会编译报错,错误信息提示w1w2的类型推导失败。为了让代码顺利地通过编译,一种方法是显式地指定模板实参:

Wrap<int> w1{ 7 };
Wrap<int> w2 = { 7 };

另一种方法就是为类模板Wrap编写一条用户自定义推导指引:

template<class T> Wrap(T)->Wrap<T>;

当然,如果代码的编译环境是C++20标准,那么上面这条用户自定义推导指引就不是必需的了。

以往C++程序员是无法控制模板的推导过程的,而本章介绍的用户自定义推导指引改变了这种情况。用户能够通过用户自定义推导指引指定编译器的推导结果,实例化出更多的实例。现在C++标准库中已经有越来越多的模块使用到了用户自定义推导指引,包括std::pairstd::arraystd::stringstd::regex等,读者可以通过搜索特性测试宏__cpp_deduction_guides来找到这些代码的位置。