深入浅出C++模板元编程(1)
读者阅读本文可能需要的前置知识:类型别名、模板别名、模板基本语法、模板实例化。
前言
-
模板元编程把程序当成数据操作,我们可以通过模板元编程生成代码。比如,
std::vector是一个类模板,如果要使用std::vector<int>这个类,则编译器会根据前者生成后者的代码。 -
模板元编程的代码在编译时进行运算,而非运行时。我们可以利用这一特性在把一部分运行时计算挪到编译时进行,提高运行时效率。其中,文中会给出编译时计算阶乘的例子,这是直接的、普通的数值运算;除此之外,模板还能实现静态多态。
-
模板元编程具有不变性(immutability),它没有副作用(side effects)。在很长一段时间里,C++的核心语言对模板元编程的支持不足,导致其编程语法的晦涩。然而,由于编译时运算的“变量”都是常量,除去语法的晦涩,不变性使得模板元编程的代码能做到“所见即所得”。
下面我们通过两个标准库的类型萃取(type traits)元函数为例来说明以上几点。在C++中,模板元编程通常以元函数的形式进行。下面的例子展示了元函数std::is_copy_constructible和std::is_move_constructible的使用:
using uptr_int = std::unique_ptr<int>;
constexpr bool copyable = std::is_copy_constructible<uptr_int>::value;
constexpr bool movable = std::is_move_constructible<uptr_int>::value;
static_assert(!copyable);
static_assert(movable);
两个bool常量copyable和movable的值分别是false和true,因为std::unique_ptr是不能复制而只能移动构造的。进一步地,我们可以利用类型萃取来做类型限制,以下这个函数声明限定其参数必须能够移动构造:
template <typename T>
std::enable_if<std::is_move_constructible<T>>
accept_move_only(T t);
编写第一个元函数:identity
本文将从最简单的模板元函数开始,一点点构建元编程的大楼。元函数通常以struct的形式实现,并把结果存放在其公开的静态常量数据成员(通常命名为value)或成员类型(通常命名为type)中。下面,我们从类型和值的恒等函数写起:
template <typename T>
struct type_identity {
using type = T;
};
template <typename T, T v>
struct value_identity : type_identity<T> {
static constexpr T value = v;
};
可见,元函数的struct对应着一般意义的函数,模板参数对应着函数参数,而成员类型和数据成员对应着函数的返回值,而继承另一个元函数结构体可以对应为一个函数调用——value_identity通过公有继承type_identity拥有后者的成员类型type。
对于一些常见的基本常量,比如bool类的真、假两个取值,可以定义:
using true_type = value_identity<bool, true>;
using false_type = value_identity<bool, false>;
标准库中的恒等元函数是
std::integral_constant。
模板特例化的编程idiom:is_same
利用这两个常量类型,我们可以编写一个元函数来比较两个类型是否相同。我们先用非元编程的语法,编写这个函数的伪代码以帮助理解:
bool equals(T1, T2) {
if (T1 == T2) {
return true;
}
else {
return false;
}
}
要实现这个元函数,用到了模板部分特例化partial template specialization)的技巧。通例模板(以下称primary template)有两个类型参数,而特例化的模板(以下称specliazed template)被指定了两个相同的类型参数。
模板元编程的中文译名似乎没有完全一致的叫法。比如partial template specialization,我见过部分特例化、偏特化等译法,而primary template有译作原模板、模板通例(这种译法似乎对应着“特例化”)等。
// Primary template
template <typename T1, typename T2>
struct equals : false_type {};
// Specialized template
template <typename T> // 提供模板参数
struct equals<T, T> : true_type {};
模板特例化(template specialization)指的是根据模板参数自定义模板的代码。如果在特例化模板时,仅“部分地”指定模板参数,就称为模板的部分特例化。语法上,“部分地”可以这样理解:声明特例化模板时template关键字后面的方括号内容非空。
当模板进行实例化时,如果有多个版本可供选择,编译器需要在primary template和specialized template(可能有多个)间进行选择,而编译器会选择更加特化(more specialized)的版本。简单来说,“A比B特化程度更高”可以理解为:假设A接受的类型为集合set_a,B的是set_b,set_a是set_b的子集”。但仍然存在编译器发现歧义,不知选择哪个特例化模板的编译错误情况。
因此,如果我们编写这样的代码,cout将分别输出0和1(即false和true):
cout << equals<int, int const>::value << '\n'; // 0
// struct equals<int, const int> : false_type;
cout << equals<int, int>::value << '\n'; // 1
// struct equals<int, int> : true_type;
模板特例化的技巧可以当作条件语句来使用。当类型参数分别为int和const int时,编译器在模板参数推断(template argument deduction)后,发现这两个参数并不相同,因此选择了primary template;当参数都是int时,编译器会发现提供的参数更加“符合”specialized template,因为它的两个是参数相同的,因此编译器选择specialized template。
标准库中对应的元函数是
std::is_same。
看似平凡,实则微妙:is_void
在标准库的<type_traits>头文件中,定义了十四种primary type categories,C++中的任意一个类型都属于且仅属于这十四个类型类别中的一个。这十四个类型包括:void,null_pointer、整型、浮点数、数组、枚举、union、类、函数、指针、左值引用、右值引用、成员对象指针、成员函数指针。
其中is_void看起来是最简单,让我们来实现它。使用上节的继承和部分特例化技巧,很快能写出来:
template <typename T>
struct is_void : false_type {};
template <> #1
struct is_void<void> : true_type {};
然而,const和volatile(统称为cv-qualifiers)也可以用来限定void。所以,所以,当这个is_void的参数为cv-qualified的void(包括void const, void volatile, void const volatile)时,它的value将是false。而根据primary type categories的规则,任意一个类型都要属于十四个类别中的一个,这个naive实现需要包括上述cv-qualified的情况。
那么我们可以接着用模板特例化来处理它们:
template <> #2
struct is_void<void const> : true_type {};
template <> #3
struct is_void<void volatile> : true_type {};
template <> #4
struct is_void<void const volatile> : true_type {};
但是这代码太丑了,没有伸缩性——试想,如果要检查一个类型是否为整型,而C++中整型类型一共有十个,难道要写10*4=40个特例化模板吗?我们需要这样一个元函数:输入一个类型T,返回它去掉顶级cv-qualifiers后的类型。
关于“顶级cv-qualifiers”,考虑这样一个类型:int const * const,它是一个指向常量int的常量指针。其中,*左边的const修饰的是int,而*右边的const修饰的是指针本身。由于这个类型是一个指针类型,因此顶级qualifier便是*右边的const;而左边的修饰指针指向的数据本身,不能去除。
先来实现去除const的元函数:
template <typename T>
struct remove_const : type_identity<T>
{};
template <typename T>
struct remove_const<T const> : type_identity<T>
{};
又一次地,使用了模板部分特例化的技巧:如果T本身非const修饰的类型,则选择primary template,并直接返回T(成员类型为T);否则,通过部分特例化为其元函数提供一个T const,那么对于某个const修饰的类型,编译器便可以正确推断出该类型除去const的部分。
举例来说,如果我们实例化remove_const<int const>,编译器会查看remove_const的primary template和specialized template;当查看后者时,进行了如下模板参数推断,并确定后者是更匹配的模板:
template < typename T -> int>
struct remove_const<T const -> int const>;
remove_volatile的实现几乎是一样的,只需要把const改成volatile即可。最后,我们把这两个元函数链式调用即可去除一个类型的顶级cv-qualifiers了:
template <typename T> // Helper
using remove_const_t = typename remove_const<T>::type;
template <typename T>
using remove_cv = remove_volatile<remove_const_t<T>>;
template <typename T> // Helper
using remove_cv_t = remove_volatile<remove_const_t<T>>::type;
这里,我们为元函数添加了helper,调用时可以省去::type或::value(其他的元函数同理)。其中,由于编译器事先并不知道remove_const的成员type到底是个什么东西(type可能是数据成员),因此用typename关键字告诉编译器把type这个符号当成类型看待。那么现在is_void的实现如下:
template< class T >
struct is_void : equals<void, remove_cv_t<T>>
{};
下面再用remove_cv来编写另一个primary type categories元函数来说明它的用处:判断一个类型是否为浮点(整型同理):
template <typename T1, typename T2>
inline constexpr bool equals_raw_v = equals<remove_cv_t<T1>, remove_cv_t<T2>>::value;
template <typename T>
using is_floating_point = value_identity<bool,
equals_raw_v<T, float>
|| equals_raw_v<T, double>
|| equals_raw_v<T, long double>>;
递归
用模板编写循环结构并不方便,但是可以用递归达到类似的效果。下面的例子,能在编译时通过递归计算正整数阶乘,我们把这个元函数命名为factorial。为了帮助对照理解,先给出非元函数的版本:
unsigned factorial(unsigned n) {
if (n == 0 || n == 1) {
return n;
}
else {
return n * factorial(n - 1);
}
}
要编写元函数版本,我们在primary template中定义value成员的值为n * (n-1)!,而在特例化的模板中定义递归的终结和边界条件(n为1和0):
template <unsigned N>
struct factorial {
static constexpr unsigned value = N * factorial<N - 1>::value;
};
template <>
struct factorial<1> {
static constexpr unsigned value = 1;
};
template <>
struct factorial<0> {
static constexpr unsigned value = 0;
};
C++11还添加了参数包(prameter pack),即一个能接受零或任意多个模板实参的模板参数。通过结合使用参数包和递归,我们可以实现一个泛化的equals:is_any_of,它能检测一个类型是否属于一个类型集合,这个类型集合用参数包实现。照例先写出伪代码:
bool is_any_of(T, Us...) {
for (U : Us) {
if (equals(T, U)) { // #1
return true;
}
else { // #2
continue;
}
}
return false; // #3
}
再来写元函数版本。老规矩,先写出primary template——但这次我们并不实现它。因为该primary template只提供interface,实际调用会匹配到后续定义的specilized template。随后,我们通过部分特例化的模板实现#1~#3。值得注意的是,这一次我们在类继承处实现了递归。
// Primary template
template <typename T, typename... Us>
struct is_any_of;
// #1 匹配集合中的第一个类型
template <typename T, typename... U1_to_Un>
struct is_any_of<T, T, U1_to_Un...> : true_type {};
// #2 第一个类型不匹配
template <typename T, typename U0, typename... U1_to_Un>
struct is_any_of<T, U0, U1_to_Un...> : is_any_of<T, U1_to_Un...> {};
// #3 集合为空集
template <typename T>
struct is_any_of<T> : false_type {};
- 特例#1的情况是,类型
T与类型集合(即参数包Us)中的第一个类型相匹配,因此继承true_type。 - 特例#2的情况是,类型
T与类型集合(即参数包Us)中的第一个类型不匹配。 - 特例#3的情况是,类型集合为空集。这个特例匹配两种情况,一种是用户调用
is_any_of时就没有提供类型;另一种是特例#2一致递归到最后,遍历了Us所有的类型也没有找到相匹配的。此外,之所以primary template不会被选择,是因为C++标准规定...总是优先级最低的一种,因此Us为空集时会匹配到只接受一个模板参数特例#3,而非接受任意数量参数的primary template。
所以,is_void也可以这样实现(可以,但没必要):
template <typename T>
struct is_void :
is_any_of<remove_cv_t<T>
, void, void const, void volatile, void const volatile>
{};
小结
这篇文章简单介绍了模板元编程的概念、特性。并以实现标准库(但本文的代码不能替代标准库的实现哦)的type traits为例介绍了模板元编程的技巧:
- 通过类继承复用元函数,大部分元函数都以继承
true_type或false_type的方式来存放结果。 - 通过模板特例化实现条件选择,利用了编译器选择并实例化与模板实参最匹配的特例化模板的机制。
- 通过对类型系统的了解实现代码重构,如
remove_cv之于primary type categories元函数。 - 通过递归实现模板参数遍历。我们再次利用了编译器选择模板版本的机制来实现它,也侧面说明模板元编程很多情况下缺乏核心语言支持,因此需要兜着圈子来表达意图。
参考资料
CppCon 2014: Walter E. Brown "Modern Template Metaprogramming: A Compendium, Part I"
CppCon 2014: Walter E. Brown "Modern Template Metaprogramming: A Compendium, Part II"
Template Metaprogramming: Type Traits (part 1 of 2) - Jody Hagins - CppCon 2020

浙公网安备 33010602011771号