CTTCG 15 Template Argument Deduction
2023 年 5 月 17 日
推导上下文:Deduced Context
P272 书上
- 依赖于模板类型的子类型不是推导上下文。比如
typename X<N>::I
不是。而X<N>::*
这样的指向成员的指针没有用到子类型,所以是推导上下文。 - 非类型模板参数的非平凡表达式不是推导上下文。比如模板
S
和参数I
,S<I+1>
无法提供推导信息。
如何推导出模板参数
- 从参数列表中推导(推导能力是有限制的,比如不能从
typename T::iterator
参数中推出T
的类型) - 函数模板被取地址时,可以从要求的返回值类型推导:
template<typename T>
void f(T, T);
void (*pf)(char, char) = &f; // f的参数T由函数指针的类型确定
- 隐式转换操作符模板的类型参数 T 由需要转换时所需的类型决定。
模板尝试匹配的时候不适用 common type。 比如两个 T 类型分别被认为是 int 和 double 时,会导致模板替换失败,而不是把 T 认为是 double。比如
template <class T> T max(T a, T b) { return a > b ? a : b; }
调用 max(1, 3.0)
就会失败,而调用 max<double>(1, 3.0)
才能成功(模板参数被人为指定之后就允许隐式类型转换了)。
特例:初始化列表
初始化列表没有确定的类型,因此不能绑定在参数类型 T 上面。但是如果参数类型为 std::initializer_list<T>
,则能够尝试绑定(当所有元素类型相同时成功)。
#include <initializer_list>
// 如果把参数类型从初始化列表改成 T 就会推导失败
template<typename T> void f(std::initializer_list<T>) {}
int main()
{
f({2, 3, 5, 7, 9}); // OK: T is deduced to int
f({’a’, ’e’, ’i’, ’o’, ’u’, 42}); // ERROR: T deduced to both char and int
}
同时,auto primes = { 2, 3, 5, 7 };
创建的是 std::initializer_list<int>
类型的数据。在 C++17 之后,去掉其中的 =
就无法编译。C++17 对去掉 =
的 auto x{?};
初始化倾向于推导为单个元素,而之前统一推导为 std::initializer<?>
:
auto oops { 0, 8, 15 }; // ERROR in C++17. OK before C++17
auto val { 2 }; // OK: val has type int in C++17,
// or type std::intializer_list<int> before C++17
在函数中使用 auto
推导,同时又返回初始化列表,则无法编译,因为无法确定具体类型。
特例:变参列表/参数包
简单情景:匹配 0 或多个剩余参数。
pack expansion:
template<typename T, typename U> class pair { };
// **匹配能力**:第一参数相同的零个或多个 pair
template<typename T, typename... Rest>
void h1(pair<T, Rest> const&...);
// 由于 pack expansion 中有两个 parameter pack,因此 Ts 和 Rest 的参数数量必须一致
// **匹配能力**:任意参数的零个或多个 pair
template<typename... Ts, typename... Rest>
void h2(pair<Ts, Rest> const&...);
void foo(pair<int, float> pif, pair<int, double> pid, pair<double, double> pdd)
{
h1(pif, pid);
// OK: deduces T to int, Rest to {float, double}
h2(pif, pid);
// OK: deduces Ts to {int, int}, Rest to {float, double}
h1(pif, pdd);
// ERROR: T deduced to int from the 1st arg, but to double from the 2nd
h2(pif, pdd);
// OK: deduces Ts to {int, double}, Rest to {float, double}
}
传递变参,而不是做 pack expansion:
template<typename... Types> class Tuple { };
// **匹配能力**:参数完全相同的2个tuple
template<typename... Types>
bool f1(Tuple<Types...>, Tuple<Types...>);
// **匹配能力**:任意参数的2个tuple
template<typename... Types1, typename... Types2>
bool f2(Tuple<Types1...>, Tuple<Types2...>);
void bar(Tuple<short, int, long> sv,
Tuple<unsigned short, unsigned, unsigned long> uv)
{
f1(sv, sv); // OK: Types is deduced to {short, int, long}
f2(sv, sv); // OK: Types1 is deduced to {short, int, long},
// Types2 is deduced to {short, int, long}
f1(sv, uv); // ERROR: Types is deduced to {short, int, long} from the 1st arg, but
// to {unsigned short, unsigned, unsigned long} from the 2nd
f2(sv, uv); // OK: Types1 is deduced to {short, int, long},
// Types2 is deduced to {unsigned short, unsigned, unsigned long}
}
这个比上面一组代码容易理解一点。
还能为参数包显式指定前几个参数类型:
template void f(Ts ... ps) {}
int main() {
f<double, int>(1, 2, 3); // OK
}
这是一个偏特化。第一个参数被转换成了 1.0
。
特例:字面量操作符模板
字面量有四类:整数、浮点数、字符串、字符。字面量操作符匹配的前提是字面量本身合法(如果不是合法数字,就需要用字符串表示),匹配的优先级则如下:
整数 n
找接受参数
unsigned long long
的重载,若有则调用operator ""X(n ULL)
。找 a raw literal operator or a numeric literal operator template, but not both。
前者参数为
const char *
,调用形式为operator""X("n")
。后者模板参数是<char ...>
,调用形式为operator""X<'c1', 'c 2', 'c3'..., 'ck '>()
,也就是把字面量的每个字符拆开的变参模板。两者不得同时被找到,否则无法区分优劣。
类型匹配 > 当成字符串 = 拆成字符传入模板。
浮点数 f
- 找接受参数
long double
的重载,若有则调用operator ""X(f L)
。 - 剩余过程和整数的 2、3 相同。
字符串 str,长度为 len
- C++20:如果有参数能够和字符串字面量匹配,可以直接调用这个模板
operator ""X<str>()
。 - 尝试
operator ""X (str, len)
。
注意整数和浮点数可以匹配上形如 unsigned operator ""_w(const char*);
的重载,但是因为该重载没有长度参数,所以字符串匹配不上。
C++20 新增的匹配规则构造起来也比较困难:
#include <array>
#include <iostream>
template <size_t N>
struct string_literal {
std::array<char, N> arr_{};
constexpr string_literal(const char (&in)[N]) {
std::copy(in, in + N, arr_.begin());
}
};
template <string_literal str>
auto operator"" _len() {
static_assert(str.arr_.size() > 0);
return str.arr_.size() - 1;
}
int main() {
std::cout << "34"_len << '\n';
}
如果使用普通的数组去匹配字符串字面量,就匹配不上。(C++17 可以采用预先分配 constexpr 字符数组的方式进行一些模板操作,而且 C++17 也没有 linkage 要求,但是这不能被直接用在字面量上,所以操作起来很麻烦)。
其实这条新规则不重要吧?除非需要覆盖原规则,把第二条规则定义为 constexpr 就行了。(std::string
不能作为 consteval 函数的返回值,但是 std::string_view
可以)但是 C++20 这样就允许(用一种曲折的方式)把字符串字面量直接作为模板参数了。
字符 ch
尝试 operator ""X(ch)
。
特例:默认参数
默认参数不能用于推导参数:
引用类型
- 不可以直接引用引用:
int const& r = 42;
int const& & ref2ref = i; // ERROR: reference to reference is invalid
// 注意编译会认出这是对引用的引用,而不是认为它是右值引用
- 通过 decltype、类型别名、模板等方式得到的引用可以参与引用坍塌。
- 对引用类型的 cv 限定相当于不限定,并不会报错。(
const
/volatile
)
完美转发
类型推导
完美转发之前会对类型进行推导,这相当于禁止了类型之间的隐式转换,这可能会导致一些问题:
void g(int*);
void g(...); // 应当是最次的选择
template<typename T> void forwardToG(T&& x)
{
g(std::forward<T>(x)); // forward x to g()
}
void foo()
{
g(0); // calls g(int*)
forwardToG(0); // eventually calls g(...)
}
上面代码中经过模板捕获后参数 T
的类型认为是 int
,而 x
的类型被认为是 int &&
。int
类型不能和函数 g
的第一个重载版本匹配。这和直接调用 g(0)
效果不同。C++11 引入的 nullptr
本身具有类型,用在这种情况更加合适。
本小节提到的对 decltype(auto)
的使用可以参考前面的章节:
完美转发。
禁用完美转发
T&&
形式用来表达完美转发,那么怎么表达单纯的右值引用呢?可以用 std::enable_if
来判断:
template<typename T>
typename std::enable_if<!std::is_lvalue_reference<T>::value>::type
rvalues(T&&);
立即上下文(immediate context)
摘自原文:
在立即上下文中发生的替代错误不是错误(SFINAE),不在其中发生的错误被视作真正的错误,无法编译。
类模板定义不是立即上下文
template<typename T>
class Array {
public:
using iterator = T*;
};
template<typename T>
void f(typename Array<T>::iterator first, typename Array<T>::iterator last) {}
template<typename T>
void f(T*, T*) {}
int main()
{
f<int&>(0, 0); // ERROR
}
第一个 f
把 T
类型作为参数传给了另外一个模板 Array
,又由于这个模板可能被特化,因而不能确定其 iterator
的具体类型。这个模板声明中对 T
的类型没有任何约束,同时也不能从参数中推导出来 T 的类型,只有通过显式传入模板参数才可能启用。第二个 f
要求可以 T
类型有对应的指针类型。
上面的代码中手动选择了 int &
类型,该类型和函数模板的第二个形式无法匹配,却和第一个能够匹配(因为第一个 f
对 T
没有任何要求)。但是在实例化时又遇到了 int &*
这样的非法类型,因而会出现编译错误。这种出错的情况好像无论是用 enable_if 还是用 concept 都无法回避。 用 type traits 和 concept 能够处理的只有对 current instantiation 的判断。
如果添加一个和第二个 f
相似,但是参数列表改成 (...)
的函数模板,则也能够在给定参数类型时匹配(不给就同样无法推导类型),但是由于 varargs
的优先级太低,所以编译器仍然会选中第一个 f
,然后在实例化时编译错误。
异常声明不是立即上下文
template void f(T, int) noexcept(nonexistent(T())); // #1
// 用 throw(typename T::Nonexistent) 也是被选中但是不能编译
// 不过显式异常声明已经在 C++17 被移除了
template void f(T, ...); // #2 (C-style vararg function)
void test(int i) {
f(i, i); // ERROR: chooses #1 , but the expression nonexistent(T()) is ill-formed
}
上面代码中 #1
会被选中,因为异常声明不是立即上下文。
auto 关键字
C++17 允许在模板中使用 auto 关键字。就算不在模板中使用,auto
本身也多少带点模板推导的意味。
构造一个能够操作对象成员自增的 handle
强制 auto...
推导中所有参数类型一致
缺点是不能表达空参数。
decltype(auto)
#include <type_traits>
const int four = 4;
int main() {
auto f = []() -> int { return 5; };
std::add_const_t<decltype(f())> x = f();
decltype(auto) y = f();
// decltype(auto) const z = f(); // error
decltype(auto) a = (four);
decltype(auto) b = four;
}
decltype(auto)
只能直接使用,不能添加const
之类的修饰符(auto
可以)。- 加括号会相当于从
T
到T &
的变换。比如a
是const int &
类型,而b
是const int
类型。
使用一个 auto
声明多个变量
不推荐,因为会共享同一个参数 T
。
上面第三行推导失败,因为字符类型做算数运算会被整型提升,然后 e 和 f 的类型就不匹配了。
返回值用 auto
推导,但函数有多个分支
类型无法推导。
auto
类型推导构成循环依赖
结构化绑定(Structured Binding)
Structured binding declaration (since C++17) - cppreference.com
结构化绑定的要求比聚合类的限制要弱一些,需要满足三个条件之一:
- C-style 数组。
- 提供了模板方法重载,使得类型能够表现得像 tuple 一样的那些类。
- 其他满足条件的类:
- 不能有匿名 union 成员。
- 如果类型有非 public 成员,需要上下文有访问权限(e.g. 子类方法访 protected 成员,友元函数等)。
可以绑定数组或者对数组的引用:
可以绑定 std::tuple-like classes 。比如为类 M
添加绑定,需要增加:
template<> struct std::tuple_size<M> { ? value = ?; };
即std::tuple_size
类模板的特化,使用静态常量提供元组的长度。template<> struct std::tuple_element<0, M> { using type = ?; };
即为每个元素说明类型。这里只说明了第 0 个元素类型,后面的省略了。使用偏特化则可以同时说明多个类型。(不过一行一个类型复制粘贴真的很简单)- 特化
get
函数模板:
template<int> auto get(M);
template<> auto get<0>(M) { return 42; }
template<> auto get<1>(M) { return 7.0; }
实际代码看上去还是比较紧凑的:
Class template argument deduction (CTAD)
Class template argument deduction (CTAD) (since C++17) - cppreference.com
这让创建一些类型变得更加简单:
std::pair p(2, 4.5); // deduces to std::pair<int, double> p(2, 4.5);
std::tuple t(4, 3, 2.5); // same as auto t = std::make_tuple(4, 3, 2.5);
Deduction guide 可见: 02 Class Templates。
单括号初始化更偏向复制/移动构造
单元素的括号初始化(brace initializer)和多元素的括号初始化推导规则不同。单元素的会优先推导成模板类以确保复制或移动行为的发生:
std::vector v{1, 2, 3}; // vector<int>, not surprising
std::vector w2{v, v}; // vector<vector<int>>
std::vector w1{v}; // **vector<int>** !!!
原始模板类有隐式推导规则
每个原始类模板(primary class template)的构造函数都有隐式的 deduction guide,除非参数不在 deduced context 中。(即便显式声明了这样的推导规则,也无法匹配上;也可以认为是每个原始类模板都有规则,只是匹配失败了。)
Injected Class Names
在 C++14 中带注释行的 X
指的是被注入的类名。但是按照 C++17 的 CTAD
也可以指隐式推导的类名 X
,这样其参数就不一定和注入类名一致。为了不和原来的代码冲突,此时规定一律使用注入的类名。
Forwarding References
假设有两条规则:
template<typename T> Y(T const&) -> Y<T>; // #1
template<typename T> Y(T&&) -> Y<T>; // #2
在面对非 const
左值引用时,#2
的匹配度更高(#1
需要添加 const
属性),这有时候会有意外的结果。因此在 T
本身为类模板的参数时禁用了其对引用的匹配(P319):
The C++ standardization committee therefore decided to disable the special deduction rule for T&& when performing deduction for implicit deduction guides if the T was originally a class template parameter (as opposed to a constructor template parameter; for those, the special deduction rule remains).