译自 https://medium.com/@barryrevzin/quirks-in-class-template-argument-deduction-ad08af42ebd6
在C++17之前,模板推导(基本上)只能用于两种场合:函数模板的参数推导和变量类型/函数返回类型中的auto
推导。过去无法推导类模板的模板参数。
结果就是:使用类模板的时候,只能(1)显式指定模板参数,或(2)写一个make_*
辅助函数来做参数推导。对于(1),要么是重复劳动/容易出错(如果模板参数就是提供给构造函数的参数的类型),要么根本是不可能的(如果参数是lambda)。对于(2),需要知道辅助函数到底叫什么……辅助函数的名字并不总是符合make_*
的模式。 标准库有make_pair
、make_tuple
、make_move_iterator
等符合模式的,但也有inserter
、back_inserter
这样不符合模式的。
类模板参数推导改变了这种情况,它允许根据类模板的构造函数或者根据推导指引(deduction guide)来推导类模板的参数。于是我们可以写出这样的代码:
pair p(1, 2.0); // pair<int, double>
tuple t(1, 2, 3.0); // tuple<int, int, double>
template<class Func>
class Foo() {
public:
Foo(Func f) : func(f) {}
void operator()(int i) const {
std::cout << "Calling with " << i << endl;
f(i);
}
private:
Func func;
};
for_each(vi.begin(), vi.end(), Foo([&](int i){...})); // Foo<some_lambda_type>
不需要显式指定类型。不需要make_*
,即使参数是lambda。
然而,类模板参数推导(以下简称CTAD)有两个值得注意的地方(坑)。
第一个地方是,头一次出现了“两个变量声明看起来声明同一个类型,实际上声明不同类型”的情况(←_←其实不是头一次)。
// 都使用auto,但我们并不期待它们对应同一个类型
auto a = 1;
auto b = 2.0;
// 都使用std::pair,它看上去像一个类型但实际上却不是
// 两个声明对应不同的类型
std::pair c(1, 2);
std::pair d(1, 2.0);
我们用auto
的时候,都知道auto
不是一个类型。但是我们用类模板的名字的时候,需要停下来想一下。 当然,对于std::pair
,明显它不是类型——大家都知道它是类模板。但如果是自定义的类型,可能就不那么明显了。在上面的例子中,c
和d
看上去都是std::pair
类型的对象——因此具有相同类型。但实际上它们分别是std::pair<int,int>
和std::pair<int,double>
类型的对象。
在C++20引入的Concepts有同样的问题。实际上YAACD paper(《另一种有制约声明的方法》)把CTAD作为支持Concept name = ...
的原因:
在变量声明中,省略auto
看来也是合理的:Constraint x = f2();
特别要注意的是,我们已经有一种语法可以进行(部分)推导,但是在语法中没有明确体现推导:std::tuple x = foo();
这种“使用看上去像类型但实际上不是类型的占位符”的问题不会消失。恰恰相反,它会变得更加普遍。所以只要牢记这个问题就好了。
第二个注意事项(坑),对我来说,是一个更大的问题。它是Concepts和CTAD在意义上的不同之处,来自于CTAD试图解决的问题。
在相关提案中,加入CTAD的动机可以概括成:我想构建一个类模板的实例(specialization),而不必显式指定模板参数——只要自动推导,不需要我写辅助工具或者查看这些参数是什么。也就是说,我想构建出新东西。
加入Concepts的动机更广,但对于有制约的变量声明(constrained variable declaration)而言,动机是:我想构建一个对象,其类型我不关心,但我想要表达对这个类型的一系列要求,而不是直接使用auto
完事。也就是说,我仍然在使用已有的类型,只是加上了一个标注。
至少我(←_←本文的原作者)是这么想的。
看起来这两个想法不冲突,但实际上它们就是冲突的。看起来我们不需要在两者之间做出选择,但实际上需要。最近的Twitter讨论串反映了这种冲突:
不要停下来啊,JF。
问题归结为:这段代码到底做了什么:
std::tuple<int> foo();
std::tuple x = foo();
auto y = foo();
声明变量x
的意图是什么?我们是在构建新东西(CTAD的目标)还是把std::tuple
作为一个标注以确保x
是std::tuple
而不是其他东西(Concepts的目标)?
STL指出大多数程序员期望x
和y
具有相同的含义。 但这样的标注并非是CTAD的目标。CTAD是关于构建新东西的——这表示虽然y
显然是std::tuple<int>
, 但x
应该是std::tuple<std::tuple<int>>
。毕竟,这就是我们所要求的。我们根据参数构建了一个新的类模板实例(class template specialization)。
在这个例子中,冲突表现得更加明显:
// The tuple case
std::tuple a(1); // unquestionably, tuple<int>
std::tuple b(a, a); // unquestionably, tuple<tuple<int>, tuple<int>>
std::tuple c(a); // ??
// The vector case
std::vector x{1}; // unquestionably, vector<int>
std::vector y{x, x}; // unquestionably, vector<vector<int>>
std::vector z{x}; // ??
这就是Casey所指出的。c
是tuple<int>
还是tuple<tuple<int>>
?z
是vector<int>
还是vector<vector<int>>
?
现在,如果我们将CTAD用于复制,则复制优先。 这表示单参和多参实际上遵循不同的规则。现在,c
是tuple<int>
,z
是vector<int>
。二者都只是复制构造自对应的参数。
换言之,如Casey所说,tuple(args...)
的类型不仅取决于参数数量,还取决于参数类型。也就是说:
- 如果
sizeof...(args) != 1
:tuple<decay_t<decltype(args)>...>
- 否则,如果
arg0
不是tuple
的实例:tuple<decay_t<decltype(arg0)>>
- 否则,
decay_t<decltype(arg0)>
这显然不简单。(←_←现实中的tuple
有5个推导指引,比这里列出的更复杂)
我(←_←本文的原作者)认为这是一个不幸和不必要的冲突——尤其是考虑到Concepts即将到来。Concepts将使我们能够轻松区分两种情况:
template <typename T, template <typename...> class Z>
concept Specializes = ...;
// The tuple case
tuple a(1); // unquestionably, tuple<int>
tuple b(a, a); // unquestionably, tuple<tuple<int>, tuple<int>>
tuple c(a); // tuple<tuple<int>>
Specializes<tuple> d(a); // tuple<int>
// The vector case
vector x{1}; // unquestionably, vector<int>
vector y{x, x}; // unquestionably, vector<vector<int>>
vector z{x}; // vector<vector<int>>
Specializes<vector> w{x}; // vector<int>
这样,我们就能使每种语言特性做其最擅长的事:CTAD构建新东西,Concepts限制已有的东西。
但这就是现有的规则,因此记住这些坑是很重要的。尤其是第二个坑——这意味着在泛型代码中使用CTAD时需要非常小心:
template <typename... Ts>
auto make_vector(Ts... elems) {
std::vector v{elems...};
assert(v.size() == sizeof...(elems)); // right??
return v;
}
auto a = make_vector(1, 2, 3); // ok
auto b = make_vector(1); // ok
auto c = make_vector(a, b); // ok
auto d = make_vector(c); // assert fires
(←_←虽然译者觉得不会有人傻到这么写——即使没有文中提到的坑,这写法也有问题,比如make_vector(1u, 2, std::allocator<int>{});
就能造成assertion failure。)