模板元编程的目标是在编译期而不是运行时执行一些计算。模板元编程基本上是介于另一种语言的一种小型编程语言。下面首先介绍一个简单示例,这个例子在编译期计算一个数的阶乘,并在运行时能将计算结果用作简单的常数。
21.1 编译期阶乘
下面的代码演示了在编译期如何计算一个数的阶乘。代码使用了模板递归,我们需要一个递归模板和用于停止递归的基本模板。根据数学定义,0的阶乘是1,所以用作基本情形:
template <unsigned char f>
class Factorial {
public:
static const unsigned long long value = f * Factorial<f - 1>::value;
};
template <>
class Factorial<0> {
public:
static const unsigned long long value { 1 };
};
int main() {
cout << Factorial<6>::value << endl;
}
这将计算6的阶乘,数学表达式为 6!
。
注意
在编译期计算阶乘。在运行时,可通过::value
访问编译期计算出来的值,这是一个静态常量值。
上面这个具体实例在编译期计算一个数的阶乘,但未必要使用模板元编程。可不使用模板,写成C++20的consteval immediate函数形式。不过,模板实现仍然是实现递归模板的优秀示例。
constexpr unsigned long long factorial(unsigned char f) {
if (f == 0) { return 1; }
else { return f * factorial(f - 1); }
}
可以像调用其他函数一样调用factorial(),不同指出在于consteval函数保证在编译期执行。
21.2 循环展开
模板元编程的第二个例子是在编译期循环展开,而不是在运行时执行循环。注意 循环展开(loop unrolling) 应尽在需要时使用,因为编译器通常会自动展开可展开的循环。
这个例子再次使用了模板递归,因为需要在编译期在循环中完成一些事情。在每次递归中,Loop类模板都会通过i-1实例化自身。当到达0时,停止递归。
template <int i>
class Loop {
public:
template <typename FuncType>
static inline void run(FuncType func) {
Loop<i - 1>::run(func);
func(i);
}
};
template <>
class Loop<0> {
public:
template <typename FuncType>
static inline void run(FuncType /* func */) { }
};
可以像下面这样使用Loop模板:
void doWork(int i) { cout << "doWork(" << i << ")" << endl; }
int main() {
Loop<3>::run(doWork);
}
这段代码将导致编译器循环展开,并连续3次调用doWork()函数。这个程序的输出如下所示:
doWork(1)
doWork(2)
doWork(3)
21.3 打印元组
这个例子通过模板元编程来打印 std::tuple
的各个元素。与模板元编程的大部分情况一样,这个例子也使用了模板递归。tuple_print类模板接收两个模板参数:tuple类型和初始化为元组大小的整数。然后再构造函数中递归地实例化自身,每一次调用都将大小减小。当大小变成0时,tuple_print的一个部分特化停止递归。
template <typename TupleType, int n>
class TuplePrint {
public:
TuplePrint(const TupleType& t) {
TuplePrint<TupleType, n - 1> tp { t };
cout << get<n - 1>(t) << endl;
}
};
template <typename TupleType>
class TuplePrint<TupleType, 0> {
public:
TuplePrint(const TupleType& t) {}
};
int main() {
using MyTuple = tuple<int, string, bool>;
MyTuple t1 { 16, "Test", true};
TuplePrint<MyTuple, tuple_size<MyTuple>::value> tp { t1 };
}
引入自动推导模板参数的辅助函数模板可以简化这段代码。简化的实现如下:
template <typename TupleType, int n>
class TuplePrintHelper {
public:
TuplePrintHelper(const TupleType& t) {
TuplePrintHelper<TupleType, n - 1> tp { t };
cout << get<n - 1>(t) << endl;
}
};
template <typename TupleType>
class TuplePrintHelper<TupleType, 0> {
public:
TuplePrintHelper(const TupleType&) {}
};
template <typename T>
void tuplePrint(const T& t) {
tuplePrintHelper<T, tuple_size<T>::value> tph { t };
}
int main() {
tuple t1 { 167, "Testing"s, false, 2.3 };
tuplePrint(t1);
}
21.3.1 constexpr if
C++17引入了constexpr if。这些是在编译期执行的if语句。如果constexpr if语句的分支从未到达,就不会进行编译。这可用于简化大量的模板元变成技术。例如,可按如下方式使用constexpr if,简化前面的打印元组元素的代码。注意,不再需要模板递归基本情形,原因在于可通过constexpr if停止递归。
template <typename TupleType, int n>
class TuplePrintHelper {
public:
TuplePrintHelper(const TupleType& t) {
if constexpr (n > 1) {
TuplePrintHelper<TupleType, n - 1> { t };
}
cout << get<n - 1>(t) << endl;
}
};
template <typename T>
void tuplePrint(const T& t) {
tuplePrintHelper<T, tuple_size<T>::value> tph { t };
}
现在,甚至可以丢弃类模板本身,替换为简单的函数模板tuplePrintHelper():
template <typename TupleType, int n>
void tuplePrintHelper(const TupleType& t) {
if constexpr (n > 1) {
tuplePrintHelper<TupleType, n - 1>(t);
}
cout << get<n - 1>(t) << endl;
}
template <typename T>
void tuplePrint(const T& t) {
tuplePrintHelper<T, tuple_size<T>::value>(t);
}
可对其进一步简化,将两个函数合为一个:
template <typename TupleType, int n = tuple_size<TupleType>::value>
void tuplePrint(const TupleType& t) {
if constexpr (n > 1) {
tuplePrintHelper<TupleType, n - 1>(t);
}
cout << get<n - 1>(t) << endl;
}
仍然像前面那样进行调用:
tuple t1 { 167, "Testing"s, false, 2.3 };
tuplePrint(t1);
21.3.2 使用编译期整数序列和折叠
C++使用 std::integer_sequence
支持编译期整数序列。模板元编程的一个常见用例是生成编译期索引序列,即size_t类型的整数序列。此处,可使用辅助用的 std::index_sequence
。可使用 std::index_sequence_for()
,生成与给定的参数包等长的索引序列。
下面使用可变参数模板、编译期索引序列和折叠表达式,实现元组打印程序:
template <typename Tuple, size_t... Indices>
void tuplePrintHelper(const Tuple& t, std::index_sequence<Indices...>) {
((std::cout << get<Indices>(t) << endl), ...);
}
template <typename... Args>
void tuplePrint(const std::tuple<Args...>& t) {
tuplePrintHelper(t, std::index_sequence_for<Args...>{});
}
可按与前面相同的方式调用:
tuple t1 { 167, "Testing"s, false, 2.3 };
tuplePrint(t1);
调用时,tuplePrintHelper()函数模板的一元右折叠表达式为如下形式:
(((cout << get<0>(t) << endl;),
((cout << get<1>(t) << endl),
((cout << get<0>(t) << endl),
(cout << get<3>(t) << endl)))));
21.4 类型trait
通过类型trait可在编译时根据类型做出决策。例如,可验证一个类型是否从另一个类型派生而来、是否可以转换为另一个类型、是否是整型等。C++标准提供了大量可供选择的类型trait类。所有与类型trait相关的功能都定义在 <type_traits>
中。
类型trait是一个非常高级的C++功能,这里不可能解释类型trait的所有细节。下面列举几个例子,展示如何使用类型trait。
21.4.1 使用类型类别
在给出使用类trait的模板示例前,首先要了解一下诸如 std::is_integral
的类的工作方式。C++标准对integral_constant类的定义如下所示:
template <class T, T v>
struct integral_constant {
static constexpr T value { v };
using value_type = T;
using type = integral_constant<T, v>;
constexpr operator value_type() const noexcept { return value; }
constexpr value_type operator()() const noexcept { return value; }
};
这也定义了bool_constant, true_type和false_type类型别名:
template <bool B>
using bool_constant = integral_constant<bool, B>;
using true_type = bool_constant<true>;
using false_type = bool_constant<false>;
当调用 true_type::value
时,得到的值是true;调用 false_type::value
时,得到的值是false。还可调用 true_type::type
返回true_type类型。这同样适用于false_type。像is_integral这样检查类型是否为整型和is_class这样检查类型是否为类类型的类,都继承自true_type或者false_type。例如,is_integral为类型bool特化,如下:
template <> struct is_integral<bool> : public true_type {};
这样就可以编写 std::is_integral<bool>::value
,并返回true。注意,不需要编写这些特化,这些是标准库的一部分。
下面的代码演示了使用类型类别的最简单例子:
if (is_integral<int>::value) { cout << "int is integral" << endl; }
else { cout << "int is not integral" << endl; }
if (is_class<string>::value) { cout << "string is a class" << endl; }
else { cout << "string is not integral" << endl; }
output
int is integral
string is a class
对于每一个具有value的成员trait,C++添加了一个变量模板,它与trait同名,后跟_v。编写 some_trait_v<T>
,例如 is_integral_v<T>
和 is_const_v<T>
等。下面用变量模板重写了前面的例子:
if (is_integral_v<int>) { cout << "int is integral" << endl; }
else { cout << "int is not integral" << endl; }
if (is_class_v<string>) { cout << "string is a class" << endl; }
else { cout << "string is not integral" << endl; }
当然,你可能永远都不会采用这种方式使用类型trait。只有结合模板根据类型的某些属性生成代码时,类型trait才更有用。下面的模板示例演示了这一点。代码动议了函数模板processHelper()的两个重载版本,这个函数模板接收一种类型作为模板参数。第一个参数是一个值,第二个参数是true_type或false_type实例。process()函数模板接收一个参数,并调用processHelper()函数:
template <typename T>
void processHelper(const T& t, true_type) {
cout << t << " is an integral type." << endl;
}
template <typename T>
void processHelper(const T& t, false_type) {
cout << t << " is not an integral type." << endl;
}
template <typename T>
void process(const T& t) {
processHelper(t, typename is_integral<T>::type{});
}
processHelper()函数调用的第二个参数定义如下:
typename is_integral<T>::type{}
该参数使用is_integral判断T是否为整数类型。使用 ::type
访问结果integral_constant类型,可以是true_type或false_type。processHelper()函数需要true_type或false_type的一个实例作为第二个参数,这也是 ::type
后面有 {}
的原因。注意,processHelper()函数的两个重载版本使用了类型为true_type或false_type的匿名参数,因为在函数体内没有使用这些参数,这些参数仅用于函数重载解析。
前面的例子只是使用单个函数模板来编写,但没有说明如何使用类型trait,以基于类型选择不同的重载。
template <typename T>
void process(const T& t) {
if constexpr (is_integral_v<T>) {
cout << t << " is an integral type." << endl;
} else {
cout << t << " is not an integral type." << endl;
}
}
21.4.2 使用类型关系
有3中类型关系:is_same, is_base_of和is_convertible。下面给出一个例子来展示如何使用is_same。其余类型关系的工作原理类似。
下面的same()函数模板通过is_same类型trait特性判断两个给定参数是否类型相同,然后输出相应的信息。
template <typename T1, typename T2>
void same(const T1& t1, const T2& t2) {
bool areTypesTheSame { is_same_v<T1, T2> };
cout << format("'{}' and '{}' are {} types.", t1, t2,
(areTypesTheSame ? "the same" : "difference")) << endl;
}
int main() {
same(1, 32);
same(1, 3.01);
same(3.01, "test"s);
}
output
'1' and '32' are the same types.
'3.01' and 'test' are difference types.
'3.01' and 'test' are difference types.
21.4.3 使用条件类型trait
标准库辅助函数模板 std::move_is_noexcept()
可以根据移动构造函数是否标记为noexcept,来有条件地调用移动构造函数还是拷贝构造函数。标准库没有提供类似的辅助函数模板,根据移动赋值运算符是否标记为noexcept,选择调用移动赋值运算符还是拷贝赋值运算符。现在已经了解了模板元编程和类型trait,来看看如何实现自己的 move_assign_if_noexcept()
。
如果移动构造函数标记为noexcept,move_if_noexcept()只会将给定的引用转换为右值引用,否则将转换为const引用。move_assign_if_noexcept()需要做类似的事情,如果移动赋值运算符标记为noexcept,则将给定的引用转换为右值引用,否则将转换为const引用。
std::conditional
类型trait可用于实现条件,而 is_nothrow_move_assignable
类型trait可用于判断某个类型是否有标记为noexcept的移动赋值运算符。条件类型trait有3个模板参数:一个布尔型,一个布尔型标记为true的类型以及一个布尔型为false的类型。下面是整个函数模板:
template <typename T>
constexpr std::conditional<std::is_nothrow_move_assignable_v<T>,
T&&, const T&>::type
move_assign_if_noexcept(T& t) noexcept {
return std::move(t);
}
C++标准为具有类型成员(比如conditional)的trait定义了别名模板。它们与trait具有相同的名称,但是附加了 _t
。对于如下写法:
std::conditional<std::is_no_throw_move_assignable_v<T>, T&&, const T&>::type
可以改为这样写:
std::conditional_t<std::is_nothrow_move_assignment_v<T>, T&&, const T&>
可以对move_assign_if_noexcept()函数模板进行以下测试:
class MoveAssignable {
public:
MoveAssignable& operator=(const MoveAssignable&) {
cout << "copy assign" << endl; return *this;
}
MoveAssignable& operator=(MoveAssignable&&) {
cout << "move assign" << endl; return *this;
}
};
class MoveAssignableNoexcept {
public:
MoveAssignableNoexcept& operator=(const MoveAssignableNoexcept&) {
cout << "copy assign" << endl; return *this;
}
MoveAssignableNoexcept& operator=(MoveAssignableNoexcept&&) {
cout << "move assign" << endl; return *this;
}
};
int main() {
MoveAssignable a, b;
a = move_assign_if_noexcept(b);
MoveAssignableNoexcept c, d;
c = move_assign_if_noexcept(d);
}
output
copy assign
move assign
21.4.4 使用enable_if
使用enable_if需要了解 “替换失败不是错误”(Substitution Failure Is Not An Error, SFINAE) 特性。它规定,为一组给定的模板参数特化函数模板失败不会被视为编译错误。相反,这样的特化应该从函数重载集合中移除。下面仅讲解SFINAE的的基础知识。
如果有一组重载函数,就可以使用enable_if根据某些类型特性有选择地仅有某些重载。enable_if通常用于重载函数的返回类型。enable_if接收两个模板类型参数。第一个参数是布尔值,第二个参数是默认为void的类型。如果布尔值是true,enable_if类就有一种可使用 ::type
访问的嵌套类型,这种嵌套类型由第二个模板类型参数给定。如果布尔值是false,就没有嵌套类型。
通过enable_if,可将前面使用same()函数模板的例子重写为一个重载的checkType()函数模板。在这个版本中,checkType()函数根据给定值的类型是否相同,返回true或false。如果不希望checkType()返回任何内容,可删除return语句,可删除enable_if的第二个模板类型参数,或用void替换。
template <typename T1, typename T2>
enable_if_t<is_same_v<T1, T2>, bool>
checkType(const T1& t1, const T2& t2) {
cout << format("'{}' and '{}' are the same types.", t1, t2) << end;
return true;
}
template <typename T1, typename T2>
enable_if_t<!is_same_v<T1, T2>, bool>
checkType(const T1& t1, const T2& t2) {
cout << format("'{}' and '{}' are different types.", t1, t2) << endl;
return false;
}
int main() {
checkType(1, 32);
checkType(1, 3.01);
checkType(3.01, "test"s);
}
output
'1' and '32' are the same types.
'3.01' and 'test' are difference types.
'3.01' and 'test' are difference types.
上述代码定义了两个重载的checkType(),它们的返回类型都是enable_if的嵌套类型bool。首先,通过is_same_v检查给定的值的类型是否相同,然后通过enable_if_t获得结果。当enable_if_t的第一个参数为true时,enable_if_t的类型就是bool;当第一个参数为false时,将不会有返回类型。这就是SFINAE发挥作用的地方。
当编译器编译main()函数的第一行时,它试图找到接收两个整型值的checkType()函数。编译器会在源码中找到第一个重载的checkType()函数模板,并将T1和T2都设置为整数,以推断可使用这个模板的实例。然后,编译器会尝试确定返回类型。由于这两个参数是整数,因此是相同的类型,is_same_v<T1, T2>
将返回true,这导致 enable_if_t<true, bool>
返回类型bool。这样实例化时一切都很好,编译器可使用该版本的checkType()。
当编译器尝试编译main()函数的第二行时,编译器会再次尝试找到合适的checkType()函数。编译器从第一个checkType()开始,判断出可将T1设置为int类型,将T2设置为double类型。然后,编译器会尝试确定返回类型。由于这两个参数都是整数,这一次,T1和T2是不同的类型,checkType()函数不会有返回类型。编译器会注意到这个错误,但由于SFINAE,还不会产生真正的编译错误。编译器将正常回溯,并试图找到另一个checkType()函数。在这种情况下,第二个checkType()可以正常工作,因为 !is_same_v<T1, T2>
为true,此时 enable_if_t<true, bool>
返回类型bool。
如果希望在一组构造函数上使用enable_if,就不能将它用于返回类型,因为构造函数没有返回类型。此时可在带默认值的额外构造函数参数上使用enable_if。
建议慎用enable_if,仅在需要解析重载歧义时使用,即无法使用其他技术(例如特化、concepts等)解析重载歧义时使用。例如,如果只希望在对模板使用了错误类型时编译失败,应使用concepts,或者静态断言,而不是SFINAE。当然,enable_if有合法的用例。一个例子是为类似于自定义矢量的类特化复制函数,使用enable_if和is_trivially_copyable类型trait对普通的可复制类型执行按位复制(例如C函数memcpy())。
警告
依赖于SFINAE是一件很棘手和复杂的事。如果有选择地使用SFINAE和enable_if禁用重载集中地错误重载,就会得到奇怪的编译错误,这些错误很难跟踪。
21.4.5 使用constexpr if简化enable_if结构
某些情况下,C++17引入的constexpr if特性有助于极大地简化enable_if。
例如,假设有以下两个类:
class IsDoable {
public:
void doit() const { cout << "IsDoable::doit()" << endl; }
};
class Derived : public IsDoable {};
可创建一个函数模板callDoit()。如果方法可用,它调用doit方法;否则输出错误消息。为此,可使用enable_if,检查给定类型是否从IsDoable派生:
template <typename T>
enable_if_t<is_base_of_v<IsDoable, T>, void> callDoit(T& t) { t.doit(); }
template <typename T>
enable_if_t<!is_base_of_v<IsDoable, T>, void> callDoit(T& t) {
cout << "Cannot call doit()!" << endl;
}
对该实现进行测试:
Derived d;
callDoit(d);
callDoit(123);
output
IsDoable::doit()
Cannot call doit()!
使用constexpr if可极大地简化enable_if实现:
template <typename T>
void callDoit(const T& [[maybe_unused]] t) {
if constexpr (is_base_of_v<IsDoable, T>) {
t.doit();
} else {
cout << "Cannot call doit()!" << endl;
}
}
使用constexpr if语句,如果提供了并非从IsDoable派生的类型,t.doit()
一行甚至不会编译!
不使用is_base_of类型trait,也可使用is_invocable trait,这个trait可用于确定在调用给定函数时是否可以使用一组给定的参数。下面是is_invocable trait的callDoit()实现:
template <typename T>
void callDoit(const T& [[maybe_unused]] t) {
if constexpr(is_invocable_v<decltype(&IsDoable::doit), T>) {
t.doit();
} else {
cout << "Cannot call doit()!" << endl;
}
}
21.4.6 逻辑运算符trait
在3种逻辑运算符trait: 串联(conjunction)、分离(disjunction) 与 否定(negation)。以 _v
结尾的可变模板也可供使用。这些trait接收可变数量的模板类型参数,可用于在类型trait上执行逻辑操作,如下所示:
cout << conjunction_v<is_integral<int>, is_integral<short>> << " ";
cout << conjunction_v<is_integral<int>, is_integral<double>> << " ";
cout << disjunction_v<is_integral<int>, is_integral<double>,
is_integral<short>> << " ";
cout << negation_v<is_integral<int>> << " ";
output
1 0 1 0
21.4.7 静态断言
static_assert允许在编译期对断言求值。断言需要是true,如果断言是false,编译器就会报错。static_assert调用接收两个参数:编译期求值的表达式和字符串。当表达式为false时,编译期将给出包含指定字符串的错误提示。下例核实是否在使用64位编译器进行编译:
static_assert(sizeof(void*) == 8, "Requires 64-bit complication.");
如果编译器使用32位编译器,指针是4B,编译器将给出错误提示,如下所示:
test.cpp(3): error C2338: Requires 64-bit complication.
从C++17开始,字符串参数变为可选的,如下所示:
static_assert(sizeof(void*) == 8);
此时,如果表达式的计算结果是false,将得到与编译器相关的错误信息。
static_assert可以和类型trait结合使用。示例如下:
template <typename T>
void foo(const T& t) {
static_assert(is_integral_v<T>, "T should be an integral type.");
}
推荐使用C++20的concepts替代带有类型trait的static_assert()。例如:
template <std::integral T>
void foo(const T& t) {}
或者使用C++20简化的函数模板语法:
void foo(const std::integral auto& t) {}