C++ 编程/模板/模板元编程
模板元编程 (TMP) 指的是使用 C++ 模板系统在代码中进行编译时计算。在很大程度上,它可以被认为是“用类型编程”——因为 TMP 使用的“值”主要是特定的 C++ 类型。使用类型作为基本计算对象允许使用类型推断规则的全部功能来进行通用计算。
预处理器允许在编译时执行某些计算,这意味着在代码编译完成后,决策已经做出,并且可以从编译后的可执行文件中排除。以下是一个非常人为的示例
#define myvar 17
#if myvar % 2
cout << "Constant is odd" << endl;
#else
cout << "Constant is even" << endl;
#endif
这种构造除了条件包含平台特定代码外,没有太多应用。特别是没有办法迭代,因此它不能用于通用计算。使用模板进行编译时编程的工作方式类似,但功能更强大,实际上是图灵完备的。
特质类是简单形式的模板元编程的常见示例:给定类型的输入,它们计算与该类型相关的属性作为输出(例如,std::iterator_traits<>
采用迭代器类型作为输入,并计算属性,例如迭代器的 difference_type
、value_type
等等)。
模板元编程比普通惯用的 C++ 更接近函数式编程。这是因为 '变量' 都是不可变的,因此必须使用递归而不是迭代来处理集合中的元素。这对学习 TMP 的命令式程序员来说增加了另一层挑战:除了学习它的机制,他们还必须学习用不同的方式思考。
由于模板元编程是从模板系统的意外使用中发展而来的,因此它经常很繁琐。通常很难让维护人员清楚地了解代码的意图,因为所用代码的自然含义与它的使用目的截然不同。最有效的处理方法是依赖成语;如果你想成为一名高效的模板元程序员,你将必须学会识别常见的成语。
它也挑战了旧编译器的能力;一般来说,2000 年左右及以后的编译器能够处理大部分实际的 TMP 代码。即使编译器支持它,编译时间也可能非常长,并且在出现编译错误的情况下,错误信息往往难以理解。有关模板实例化调试器,请参阅 TempLight。
一些编码标准甚至可能禁止模板元编程,至少在 Boost 等第三方库之外。
从历史上看,TMP 有点像是意外;在标准化 C++ 语言的过程中,人们发现它的模板系统碰巧是图灵完备的,即原则上能够计算任何可计算的东西。第一个具体的证明是由 Erwin Unruh 编写的程序,它计算了素数尽管它实际上没有完成编译:素数列表是编译器在尝试编译代码时生成的错误消息的一部分。[1] 从那时起,TMP 已经有了很大的进步,现在是 C++ 库构建者的实用工具,但它的复杂性意味着它通常不适用于大多数应用程序或系统编程环境。
#include <iostream>
template <int p, int i>
class is_prime {
public:
enum { prim = ( (p % i) && is_prime<p, i - 1>::prim ) };
};
template <int p>
class is_prime<p, 1> {
public:
enum { prim = 1 };
};
template <int i>
class Prime_print { // primary template for loop to print prime numbers
public:
Prime_print<i - 1> a;
enum { prim = is_prime<i, i - 1>::prim };
void f() {
a.f();
if (prim)
{
std::cout << "prime number:" << i << std::endl;
}
}
};
template<>
class Prime_print<1> { // full specialization to end the loop
public:
enum { prim = 0 };
void f() {}
};
#ifndef LAST
#define LAST 18
#endif
int main()
{
Prime_print<LAST> a;
a.f();
}
TMP 中的 '变量' 实际上不是变量,因为它们的值不能改变,但你可以拥有命名值,就像你在普通编程中使用变量一样。在用类型编程时,命名值是类型定义
struct ValueHolder
{
typedef int value;
};
你可以将其视为“存储”int
类型,以便可以在 value
名称下访问它。整数值通常存储为 enum
中的成员
struct ValueHolder
{
enum { value = 2 };
};
这再次存储了该值,以便可以在 value
名称下访问它。这两个示例本身都没有用,但它们构成了大多数其他 TMP 的基础,因此它们是必须注意的重要模式。
函数将一个或多个输入参数映射到一个输出值。TMP 中的类似物是模板类
template<int X, int Y>
struct Adder
{
enum { result = X + Y };
};
这是一个将两个参数相加并将结果存储在 result
enum
成员中的函数。你可以在编译时使用类似 Adder<1, 2>::result
的方法调用它,它将在编译时展开,并在你的程序中与文字 3
完全相同。
条件分支可以通过编写模板类的两个替代特化来构造。编译器将选择适合所提供类型的特化,然后可以访问实例化类中定义的值。例如,考虑以下部分特化
template<typename X, typename Y>
struct SameType
{
enum { result = 0 };
};
template<typename T>
struct SameType<T, T>
{
enum { result = 1 };
};
这告诉我们它实例化的两个类型是否相同。这可能看起来没什么用,但它可以看穿类型定义,否则这些类型定义可能会掩盖类型是否相同,并且它可以用于模板代码中的模板参数。你可以像这样使用它
if (SameType<SomeThirdPartyType, int>::result)
{
// ... Use some optimised code that can assume the type is an int
}
else
{
// ... Use defensive code that doesn't make any assumptions about the type
}
上面的代码不太惯用:由于类型可以在编译时识别,因此 if()
块将始终具有一个微不足道的条件(它将始终解析为 if (1) { ... }
或 if (0) { ... }
)。但是,这确实说明了可以实现的这种类型的事情。
由于在使用模板编程时无法使用可变变量,因此无法迭代值序列。在标准 C++ 中可能通过迭代完成的任务必须用递归重新定义,即一个函数调用自身。这通常采用模板类的形式,其输出值递归地引用自身,并且一个或多个特化会为其提供固定值以防止无限递归。您可以将其视为上面描述的函数和条件分支思想的结合。
计算阶乘自然地通过递归完成:,对于 ,。在 TMP 中,这对应于一个名为“factorial”的类模板,其通用形式使用递归关系,并且其特化终止递归。
首先,通用(未特化)模板表示 factorial<n>::value
由以下给出:n*factorial<n-1>::value:
template <unsigned n>
struct factorial
{
enum { value = n * factorial<n-1>::value };
};
接下来,零的特化表示 factorial<0>::value
计算为 1
template <>
struct factorial<0>
{
enum { value = 1 };
};
现在,一些在编译时“调用”factorial 模板的代码
int main() {
// Because calculations are done at compile-time, they can be
// used for things such as array sizes.
int array[ factorial<7>::value ];
}
观察到 factorial<N>::value
成员是根据 factorial<N>
模板表示的,但这不能无限期地继续:每次它被评估时,它都会使用一个越来越小的(但非负)数字调用自身。这最终必须达到零,此时特化生效,评估不会进一步递归。
示例:编译时“If”
[edit | edit source]以下代码定义了一个名为“if_”的元函数;这是一个类模板,可用于根据编译时常量在两种类型之间进行选择,如以下 main 中所示
template <bool Condition, typename TrueResult, typename FalseResult>
class if_;
template <typename TrueResult, typename FalseResult>
struct if_<true, TrueResult, FalseResult>
{
typedef TrueResult result;
};
template <typename TrueResult, typename FalseResult>
struct if_<false, TrueResult, FalseResult>
{
typedef FalseResult result;
};
int main()
{
typename if_<true, int, void*>::result number(3);
typename if_<false, int, void*>::result pointer(&number);
typedef typename if_<(sizeof(void *) > sizeof(uint32_t)), uint64_t, uint32_t>::result
integral_ptr_t;
integral_ptr_t converted_pointer = reinterpret_cast<integral_ptr_t>(pointer);
}
在第 18 行,我们使用真值评估 if_
模板,因此使用的类型是提供的第一个值。因此,整个表达式 if_<true, int, void*>::result
计算为 int
。类似地,在第 19 行,模板代码计算为 void *
。这些表达式与在源代码中将类型写为字面量值的效果完全相同。
第 21 行开始变得巧妙:我们定义了一个依赖于平台相关 sizeof
表达式的值的类型。在指针为 32 位或 64 位的平台上,这将在编译时选择正确的类型,无需任何修改,也无需预处理器宏。一旦类型被选中,它就可以像任何其他类型一样使用。
为了比较,这个问题在 C90 中最好用以下方法解决
# include <stddef.h>
typedef size_t integral_ptr_t;
typedef int the_correct_size_was_chosen [sizeof (integral_ptr_t) >= sizeof (void *)? 1: -1];
碰巧的是,库定义的类型 size_t
应该是任何平台上此特定问题的正确选择。为了确保这一点,第 3 行用作编译时检查,以查看所选类型是否确实足够大;如果不是,数组类型 the_correct_size_was_chosen
将使用负长度定义,导致编译时错误。在 C99 中,<stdint.h>
可能定义类型 intptr_t
和 uintptr_t
。
调试 TMP
[edit | edit source]截至 2017 年,这无法以任何有意义的方式完成。通常,抛弃模板并重新开始比尝试破译由模板元程序中单个字节的错误造成的编译器输出的复杂迷宫更容易。
请考虑来自 C++ 标准化委员会秘书 Herb Sutter 的以下观察结果
Herb: Boost.Lambda, is a marvel of engineering… and it worked very well if … if you spelled it exactly right the first time, and didn’t mind a 4-page error spew that told you almost nothing about what you did wrong if you spelled it a little wrong. …
Charles: talk about giant error messages… you could have templates inside templates, you could have these error messages that make absolutely no sense at all.
Herb: oh, they are baroque.
来源:https://ofekshilon.com/2012/09/01/meta-programming-is-still-evil/