第38章 类模板的模板实参推导(C++17 C++20)

在C++17标准之前,实例化类模板必须显式指定模板实参,例如:

std::tuple<int, double, const char*> v{5, 11.7, "hello world"};

可以看到这种写法十分冗长,幸运的是,由于函数模板可以通过函数的实参列表推导出模板实参,因此出现了std::make_pairstd::make_tuple这类函数,结合auto关键字,上面的代码可以简化为:

auto v = std::make_tuple(5, 11.7, "hello world");

虽然这种方法在一定程度上解决了问题,但是很明显在std::tuple的初始化阶段,编译器有条件通过v{5, 11.7, "hello world"}初始化列表中的实参推导出std::tuple的模板实参,这样就不必引入函数模板std::make_tuple了。

C++17标准支持了类模板的模板实参推导,上面的代码可以进一步简化为:

std::tuple v{ 5, 11.7, "hello world" };

实例化类模板也不再需要显式地指定每个模板实参,编译器可以通过对象的初始化构造推导出缺失的模板实参。典型的使用例子还包括:

std::mutex mx;
std::lock_guard lg{ mx };
std::complex c{ 3.5 };
std::vector v{ 5,7,9 };
auto v1 = new std::vector{ 1, 3, 5 };

在上面的代码中,lg的类型被推导为std::lock_guard<std::mutex>cv的类型分别被推导为std::complex<double>std::vector<int>。当然了,使用new表达式也能触发类模板的实参推导。除了以类型为模板形参的类模板,实参推导对非类型形参的类模板同样适用,下面的例子就是通过初始化,同时推导出类型模板实参char和非类型模板实参6的:

#include <iostream>

template<class T, std::size_t N>
struct MyCountOf
{
  MyCountOf(T(&)[N]) {}
  std::size_t value = N;
};

int main()
{
  MyCountOf c("hello");
  std::cout << c.value << std::endl;
}

对于非类型模板形参为auto占位符的情况也是支持推导的:

template<class T, auto N>
struct X
{
  X(T(&)[N]) {}
};

int main()
{
  X x("hello");
}

需要注意的是,不同于函数模板,类模板的模板实参是不允许部分推导的。比如:

template<class T1, class T2>
void foo(T1, T2) {}

int main()
{
  foo<int>(5, 6.8);
}

上面这段代码可以编译成功,虽然函数模板实例化的时候只显式指定了一个模板实参T1,但是由于模板实参T2可以通过函数实参列表推导,因此并不会影响编译器的正常工作,最终编译器正确将函数模板实例化为foo<int, double>(int, double)。但是这在类模板上是行不通的:

template<class T1, class T2>
struct foo
{
  foo(T1, T2) {}
};

int main()
{
  foo v1(5, 6.8);                     // 编译成功
  foo<> v2(5, 6.8);                   // 编译错误
  foo<int> v3(5, 6.8);                // 编译错误
  foo<int, double> v4(5, 6.8);        // 编译成功
}

在上面的代码中,v1v4可以顺利通过编译,其中v1符合类模板实参的推导要求,而v4则显式指定了模板实参。v2v3就没那么幸运了,它们都没有完整地指定模板实参,这是编译器不能接受的。

在类模板的模板实参推导过程中往往会出现这样两难的场景:

std::vector v1{ 1, 3, 5 };
std::vector v2{ v1 };

std::tuple t1{ 5, 6.8, "hello" };
std::tuple t2{ t1 };

这里读者不妨猜测一下v2t2的类型。v2std::vector<int>类型还是std::vector<std::vector<int>>类型,t2std::tuple<int, double, const char *>类型还是std::tuple<std::tuple<int, double, const char *>>类型?实际上,正如本节的标题所言,这里会优先解释为拷贝初始化。更明确地说,v2的类型为std::vector<int>t2的类型为std::tuple<int, double, const char *>

同理,下面的类模板也都会被实例化为std::vector<int>类型:

std::vector v3(v1);
std::vector v4 = {v1};
auto v5 = std::vector{v1};

请读者注意,使用列表初始化的时候,当且仅当初始化列表中只有一个与目标类模板相同的元素才会触发拷贝初始化,在其他情况下都会创建一个新的类型,比如:

std::vector v1{ 1, 3, 5 };
std::vector v3{ v1, v1 };

std::tuple t1{ 5, 6.8, "hello" };
std::tuple t3{ t1, t1 };

其中v3的类型为std::vector<std::vector<int>>t3的类型为std::tuple<std::tuple<int, double, const char *>, std::tuple<int, double, const char *>>。最后值得一提的是,虽然C++17标准的编译器现在一致表现为优先拷贝初始化,但是真正在标准中明确的是C++20。该语法补充是在2017年7月提出的,可惜那时候C++17标准已经发布了。

请读者思考一个问题,要将一个lambda表达式作为数据成员存储在某个对象中,应该如何编写这种类的代码?在C++17以前,大部分人想出的解决方案应该差不多是这样的:

#include <iostream>

template<class T>
struct LambdaWarp
{
  LambdaWarp(T t) : func(t) {}
  template<class … Args>
  void operator() (Args&& … arg)
  {
       func(std::forward<Args>(arg) …);
  }
  T func;
};

int main()
{
  auto l = [](int a, int b) { 
       std::cout << a + b << std::endl; 
  };

  LambdaWarp<decltype(l)> x(l);
  x(11, 7);
}

在这份代码中,最关键的步骤是使用decltype获取lambda表达式l的类型,只有通过这种方法才能准确地实例化类模板。在C++支持了类模板的模板实参推导以后,上面的代码可以进行一些优化:

#include <iostream>

template<class T>
struct LambdaWarp
{
  LambdaWarp(T t) : func(t) {}

  template<class … Args>
  void operator() (Args&& … arg)
  {
       func(std::forward<Args>(arg) …);
  }
  T func;
};

int main()
{
  LambdaWarp x([](int a, int b) {
       std::cout << a + b << std::endl;
  });
  x(11, 7);
}

上面的代码不再显式指定lambda表达式类型,而是让编译器通过初始化构造自动推导出lambda表达式类型,简化了代码的同时也更加符合lambda表达式的使用习惯。

C++20标准支持了别名模板的类模板实参推导,顾名思义该特性结合了别名模板和类模板实参推导的两种特性。让我们看一看提案文档提供的示例代码:

template <class T, class U> struct C {
  C(T, U) {}
};

template<class V>
using A = C<V*, V*>;

int i{};
double d{};
A a1(&i, &i);      // 编译成功,可以推导为A<int>
A a2(i, i);        // 编译失败,i无法推导为V*
A a3(&i, &d);      // 编译失败,(int *, double *)无法推导为(V*, V*)

在上面的代码中,AC的别名模板,它约束C的两个模板参数为相同类型的指针V*。在推导过程中,A a1(&i, &i);可以编译成功,因为构造函数推导出来的两个实参类型都是int *符合V*,最终推导为A<int>。而对于A a2(i, i);,由于实参推导出来的不是指针类型,因此推导失败无法编译。同样,A a3(&i, &d);虽然符合实参推导结果为指针的要求,但是却违反了两个指针类型必须相同的规则,结果也是无法编译的。最后需要说明的是,到目前为止只有GCC对该特性做了支持。

除了上一节提到的别名模板,C++20标准还规定聚合类型也可以进行类模板的实参推导。例如:

template <class T>
struct S {
  T x;
  T y;
};

S s1{ 1, 2 }; //编译成功 S<int>
S s2{ 1, 2u }; // 编译失败

编译器会根据初始化列表推导出模板实参,在上面的代码中,S s1{ 1, 2 };推导出的模板实参均为int类型,符合单一模板参数T,所以可以顺利编译。相反,S s2{ 1, 2u };由于初始化列表的两个元素推导出了不同的类型intunsigned int,无法满足确定的模板参数T,因此编译失败。

除了以上简单的聚合类型,嵌套聚合类型也可以进行类模板实参推导,例如:

template <class T, class U>
struct X {
  S<T> s;
  U u;
  T t;
};

X x{ {1, 2}, 3u, 4 };

请注意,在上面的代码中模板形参T并不是被{1, 2}推导出来的,而是被初始化列表中最后一个元素4推导而来,S<T> s;不参与到模板实参的推导中。另外,如果显示指定S<T>的模板实参,则初始化列表的子括号可以忽略,例如:

template <class T, class U>
struct X {
  S<int> s;
  U u;
  T t;
};

X x{ 1, 2, 3u, 4 };

以上这部分特性到目前为止只在GCC中实现。

C++20标准还规定聚合类型中的数组也可以是推导对象,不过这部分特性至今还没有编译器实现,这里我们看一下提案文档的例子即可:

template <class T, std::size_t N>
struct A {
  T array[N];
};
A a{ {1, 2, 3} };

template <typename T>
struct B {
  T array[2];
};
B b = { 0, 1 };

在上面的代码中,类模板A需要推导数组类型和数组大小,根据初始化列表array被推导为int array[3],注意,这里初始化列表中的子括号是必须存在的。而对于类模板B而言,数组大小是确定的,编译器只需要推导数组类型,这时候可以省略初始化列表中的子括号。

本章主要介绍了类模板的模板实参推导,该特性让类模板可以像函数模板一样通过构造函数调用的实参推导出模板形参,比如,从前需要调用std::make_pairstd::make_tuple让编译器帮助我们推导pairtuple的具体类型,现在已经可以直接初始化构造了,这让使用类模板的体验更好。另外,对于是否有必要用此方法替代std::make_xxx这一系列函数,我认为在现代编译器优化技术的保证下std::make_xxx一类函数并不会产生额外的开销,所以继续使用std::make_xxx这类函数能够给代码带来更大的兼容性。而对于没有历史包袱的项目而言,直接使用类模板的模板实参推导显然会让代码看起来更加简洁清晰。