C/C++ 模板元编程学习
模板
在C++中使用模板(templates)来进行泛型编程,它允许程序员编写与数据类型无关的代码,模板可以是函数模板或类模板。
模板形式
1 |
|
函数模板可以自动推导参数类型,如上例中的func(1.1)
。
模板参数也可以有默认值,此时调用者在调用时没有指定时,会使用默认值。
在函数模板中,为了一些特殊情况,可以添加一个同名函数,它可以与函数模板重载:
1 |
|
整数也可以作为模板参数,比如template <int N>
,但模板参数只支持整数类型(包括enum
),浮点类型、指针类型,不能声明为模板参数
整数作为函数参数和模板参数的区别:
1 |
|
template
一个编译期优化例子是函数中需要根据debug
参数控制是否输出调试信息。
如果将参数作为函数参数,那么无论是否为true
,每次调用时都要进行判断,影响性能;而作为模板参数,编译器会生成两份函数func<true>
和func<false>
,前者保留了调试用的打印语句,后者则完全为性能优化而可以去掉打印语句。
模板的难题
编译期常量
调用模板时,填入的参数为编译期常量,不能通过运行时变量组成的表达式来指定。
可以通过constexpr
关键字定义编译期常量,比如constexpr int i = 1
,当然,定义时=
右边的值也必须是编译期常量。
分文件编写
如果像使用传统函数一样分离函数模板的声明和定义,比如:
1 |
|
此时编译则会报如下错误:
1 |
|
这是因为编译器对模板的编译是惰性的,只有在定义模板的.cpp
文件中去使用该模板时,该模板才会被真正实例化。
在上述例子中,my_sum.cpp
由于并未使用到定义的函数模板,所以单独编译后并没有函数符号int my_sum<true>(int)
,所以在链接阶段main.cpp
中调用时找不到相应的函数实现,所以会报链接错误。
解决办法:在定义模板的.cpp
文件增加显式编译模板的声明,比如上述例子可以加一行template int my_sum<true>(int);
。
可以看出,我们每在main.cpp
中使用一款“新的”模板实例,都需要显式地去在定义处更改,使得编译出这个实例,这显然不合理。所以一般使用模板时会将声明和定义一起放在头文件中,引用时就相当于将整份代码复制过来,调用时在产生新的模板实例。
从这一点也能知道,stl头文件不是像以前C中的头文件一样,只有声明,在编译时链接动态库;而是将如何定义的全写在头文件中了。
模板元编程
Metaprogramming is the writing of computer programs:
- That write or manipulate other programs (or themselves) as their data, or
- That do… work at compile time that would otherwise be done at runtime”
C++ Template Metaprogramming uses template instantiation to drive compile-time evaluation:
When we use the name of a template where a (function, type, variable) is expected, the compiler will instantiate (create) the expected entity from that template.
模板元编程能够带来以下好处:
- 提高源代码的灵活性
- 提高运行时性能
使用模板元编程时,有一些限制需要注意:
- 不可变性(No Mutability)
- 无虚函数(No Virtual Functions)
- 无运行时类型信息(No RTTI)
总结来说,模板元编程的核心思想是将工作从运行时转移到编译时。
示例:编译时计算绝对值(Compile-time Absolute Value)
下面是一个简单的编译时绝对值元函数(metafunction
)的实现:
1 |
|
这个元函数功能类似于一个constexpr
函数:
1 |
|
但作为struct
,元函数提供了更多的功能,例如:
- Public member
type declarations
(e.g., typedef or using). - Public member
data declarations
(static const/constexpr, each initialized via a constant expression). - Public member
function declarations
andconstexpr member function definitions
. - Public member templates, static_asserts, and more!
示例:编译时递归与特化
另一个编译时数值计算的例子是计算最大公约数(GCD)。我们可以通过编译时递归和特化来实现这一点。
1 |
|
解释:
- 模板的定义:我们需要先在
template
后给出实例化时的参数,随后可以定义相应的函数模板、类模板等。 - 模板特化:特化模板需要放在已定义的模板之后。在
template
后给出尚未确定的参数,在模板名称后用<>
按照primary template
定义的格式进行实例化,并给出特定情况下的模板定义。
模板的使用本质上是模板实例化,就像函数调用一样将“实参”填入形参。这类似于模式匹配,当出现多个匹配时,特化模板的匹配优先级较高,最终选择最“特别”的模板来进行实例化。因此,多个特化模板都位于主模板的范围内,但每个实例化就像if
语句一样,而主模板则作为最后的默认情况。
将type
作为参数
sizeof
是 C++ 提供的一个接受 type
作为参数的内置操作符。类似地,我们可以通过编写自定义的元函数,来实现类型相关的操作。
示例:获取array
类型的rank
1 |
|
该例子说明:
- 可以将
type
作为参数,存在type metafunction
- 递归操作不仅可以在主模板(
primary
)中进行,也可以在模板特化(specialization
)中进行。
将type
作为结果
许多元函数需要一个或多个type
,然后返回一个type
。
示例:返回移除掉const
的相同类型
1 |
|
C++ 元函数的约定#1:struct中的type
- 元函数有类型结果的话用type命名
示例:An identity metafunction可以通过继承(inheritance)使用上面的简单元函数(以之前移除1
2
3
4
5// 返回输入类型的类型
template <class T>
struct type_is {
using type = T;
};const
为例):1
2
3
4
5
6template <class T>
struct remove_const : type_is<T> {};
template <class T>
struct remove_const<T const> : type_is<T> {};
template <class T>
using remove_const_t = typename remove_const<T>::type;
Compile-time decision-making
如果一个元函数可以根据某个条件在编译期返回不同的类型,比如
1 |
|
那么可以让我们写出self-configuring code
:
1 |
|
IF
可以被实现如下:
1 |
|
而对于单类型的判断,也就是“如果true
,返回该类型;否则,无返回”,则实现如下:
1 |
|
此时,若实例化时匹配到特化部分,也就是发生enable_if<false, ...>::type
,也不一定就会发生编译错误,这就是SFINAE特性。
SFINAE
SFINAE: Substitution Failure is Not An Error.
在模板实例化时,也就是发生模板调用时,会发生:
- 获取模板参数:
- 调用时直接给出
- 函数模板可以通过函数参数来推断
- 使用模板默认参数
- 对相应的模板占位参数进行替换
此时,如果得到正确的代码,则实例化成功;但如果结果代码是不合法的(视为替换失败(Substitution Failure)),则会被静默丢掉(be silently discarded),继续寻找下一个。
应用SFINAE的例子:
1 |
|
对于以上函数模板,在调用f<>()
时,如果val
是整型,则会调用第一个;若是浮点型,则会调用第二个;若都不是(比如字符串),这时,两个模板都替换失败,才会报出编译器错误。
C++20出现了concept
,它可以去除上述“别扭”的写法,上述的目的就是在函数调用时,根据参数类型的不同来实例化不同的模板实现,做法则是在一个实现中通过元函数来保证只有类型满足某某条件后才能生成正确的代码,让不满足的出错继而另寻他路,也就是对类型本身的限制条件。可以把这些限制和约束抽象成concept
,那写法变成如下:
1 |
|
C++ 元函数的约定#2:struct中的value
A metafunction with a value result has:
- A
static constexpr
member,value
, giving its result, and… - A few convenience member types and
constexpr
functions.
一个规范的value-returning元函数如下:
1 |
|
其他value-returning元函数可以继承该类。
将原先的rank
改写如下:
1 |
|
一些派生的类型别名:
1 |
|
value-returning元函数的不同调用方法:
1 |
|
同时利用继承、特化的一些例子:给一个类型,判断是否是 void
1 |
|
除了这种方式,也可以委托给其他元函数来实现这个需求。
1 |
|
在元函数中使用参数包(parameter pack
)
一些元函数中,我需要任意长的参数列表(比如需要任意多个类型参数),此时可以借助参数包实现。
示例:判断某个类型是否与一堆类型中的某个相同
1 |
|
可以再次实现is_void
:
1 |
|
Unevaluated operands
Operands of sizeof
, typeid
, decltype
, and noexcept
are never evaluated, not even at compile time:
- Implies that no code is generated (in these contexts) for such operand expressions, and…
- Implies that we need only a declaration, not a definition, to use a (function’s or object’s) name in these contexts.
An unevaluated function call (e.g., to foo) can usefully map one type to another:
- decltype( foo(declval
()) )
std::declval
是函数模板,但只有声明,没有定义,无法真正调用它;std::declval<T>()
被声明为给出类型T的右值结果(std::declval<T&>()给出左值);但因为没有定义,所以并不会真正返回,这正好可以用在这种unevaluated的场景下,它的作用是假装这里有一个这种类型的值。
此时,整体考虑上述的decltype( foo(declval<T>()) )
:declval<T>()
看起来是一个函数调用,但因为在decltype
下,不会求值,所以假装返回了一个T类型的值(这里的重点只是有个这样的类型);其后,到函数foo
,它看到传给他一些T类型,那我会返回什么类型就由decltype
给出。
使用这种操作符的例子:在编译时检查一个类型是否支持拷贝赋值操作
1 |
|
这个例子中判断了能否std::declval<U &>() = std::declval<U const &>()
,但也应该判断赋值结果类型是否是T&
。
在没有decltype
时,可以使用sizeof
也构造出一个Unevaluated的场景,但这时type
无法由decltype
得到,为了得到type
,可以让两种函数返回不同大小的值,然后再用sizeof
判断。
1 |
|
void_t
1 |
|
对于void_t
,它是void
的别名,但用起来也是一个元函数调用(void_t<int, float, ...>
),它接受任意数量的类型参数,给出一个void
类型。
它无视了所有你给出的参数,然后如同什么都没有一样返回了一个void
(就好像直接使用void
),但它却可以有奇妙的用处。
示例:检测一个类中是否有一个类型成员(T::type
)
1 |
|
在该例子中,如果类型中存在类型成员type
,那在调用has_type_membe<T>
时,会发现特化版本has_type_member<T, void_t<typename T::type>>
是合法的,尽管好像是用了一种奇怪的方法来写void
,那么它将会被选中。
当然,假设不存在,那特化版本不合法,从而转向false_type
。
利用这个void_t
可以重写is_copy_assignment
:
1 |
|
而当把<T const &>
改为<T &&>
,其他不变时,就可以实现is_move_assignable
。
结语
泛型编程是一种在C++中广泛使用的编程范式,它允许程序员编写与数据类型无关的代码。这种编程方式通过使用模板来实现,模板可以是函数模板或类模板。
C++ 模板最初是为实现泛型编程设计的,但人们发现模板的能力远远不止于那些设计的功能。一个重要的理论结论就是:C++ 模板是图灵完备的(可以用 C++ 模板模拟图灵机)。
理论上说 C++ 模板可以执行任何计算任务,但实际上因为模板是编译期计算,其能力受到具体编译器实现的限制(如递归嵌套深度)。C++ 模板元编程是“意外”功能,而不是设计的功能,这也是 C++ 模板元编程语法丑陋的根源。
参考: