C/C++ 模板元编程学习

模板

在C++中使用模板(templates)来进行泛型编程,它允许程序员编写与数据类型无关的代码,模板可以是函数模板或类模板。

模板形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>

// 函数模板
template <class T, int i = 76>
T func(T t) {
std::cout << i << std::endl;
return t * 2;
}

// 类模板
template <class T>
class MyClass {
public:
static void func(T t) { std::cout << t << std::endl; }
};

int main() {
// 调用函数模板
std::cout << func<int>(1) << std::endl;
std::cout << func(1.1) << std::endl;

// 调用类模板
MyClass<int>::func(10);
return 0;
}

函数模板可以自动推导参数类型,如上例中的func(1.1)

模板参数也可以有默认值,此时调用者在调用时没有指定时,会使用默认值。

在函数模板中,为了一些特殊情况,可以添加一个同名函数,它可以与函数模板重载:

1
2
3
4
5
6
7
template <class T>
T twice(T t) {
return t * 2;
}
std::string twice(std::string t) {
return t + t;
}

整数也可以作为模板参数,比如template <int N>,但模板参数只支持整数类型(包括enum),浮点类型、指针类型,不能声明为模板参数

整数作为函数参数和模板参数的区别:

1
2
template <int N> void func();
void func(int N);

template 传入的 N,是一个编译期常量,每个不同的 N,编译器都会单独生成一份代码,从而可以对他做单独的优化;而 func(int N),则变成运行期变量,编译器无法自动优化,只能运行时得知被调用参数 N 的不同。

一个编译期优化例子是函数中需要根据debug参数控制是否输出调试信息。
如果将参数作为函数参数,那么无论是否为true,每次调用时都要进行判断,影响性能;而作为模板参数,编译器会生成两份函数func<true>func<false>,前者保留了调试用的打印语句,后者则完全为性能优化而可以去掉打印语句。

模板的难题

编译期常量

调用模板时,填入的参数为编译期常量,不能通过运行时变量组成的表达式来指定。
可以通过constexpr关键字定义编译期常量,比如constexpr int i = 1,当然,定义时=右边的值也必须是编译期常量。

分文件编写

如果像使用传统函数一样分离函数模板的声明和定义,比如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* my_sum.h */
#pragma once
template <bool debug>
int my_sum(int n);

/* my_sum.cpp */
#include <iostream>
#include "my_sum.h"
template <bool debug>
int my_sum(int n) {
int res = 0;
for (int i = 1; i <= n; i++) {
res += i;
}
if constexpr (debug) {
std::cout << res << std::endl;
}
return res;
}

/* main.cpp */
#include <iostream>
#include "my_sum.h"
int main() {
std::cout << my_sum<true>(4) << std::endl;
return 0;
}

此时编译则会报如下错误:

1
undefined reference to `int my_sum<true>(int)'

这是因为编译器对模板的编译是惰性的,只有在定义模板的.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
2
3
4
5
template <int N>
struct my_abs {
static_assert(N != INT_MIN); // 避免绝对值计算溢出
static constexpr int value = (N < 0) ? -N : N;
};

这个元函数功能类似于一个constexpr函数:

1
constexpr int abs(int N) { return (N < 0) ? -N : N; }

但作为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 and constexpr member function definitions.
  • Public member templates, static_asserts, and more!

示例:编译时递归与特化

另一个编译时数值计算的例子是计算最大公约数(GCD)。我们可以通过编译时递归和特化来实现这一点。

1
2
3
4
5
6
7
8
9
10
11
// primary template
template <unsigned M, unsigned N>
struct my_gcd {
static constexpr int value = my_gcd<N, M % N>::value;
};
// partial specialization 部分特化作为递归的base
template <unsigned M>
struct my_gcd<M, 0> {
static_assert(M != 0);
static constexpr int value = M;
};

解释:

  • 模板的定义:我们需要先在template后给出实例化时的参数,随后可以定义相应的函数模板、类模板等。
  • 模板特化:特化模板需要放在已定义的模板之后。在template后给出尚未确定的参数,在模板名称后用<>按照primary template定义的格式进行实例化,并给出特定情况下的模板定义。

模板的使用本质上是模板实例化,就像函数调用一样将“实参”填入形参。这类似于模式匹配,当出现多个匹配时,特化模板的匹配优先级较高,最终选择最“特别”的模板来进行实例化。因此,多个特化模板都位于主模板的范围内,但每个实例化就像if语句一样,而主模板则作为最后的默认情况。

type作为参数

sizeof 是 C++ 提供的一个接受 type 作为参数的内置操作符。类似地,我们可以通过编写自定义的元函数,来实现类型相关的操作。

示例:获取array类型的rank

1
2
3
4
5
6
7
8
9
10
// primary template
template <class T>
struct rank {
static constexpr size_t value = 0u;
};
// partial specialization
template <class T, size_t N>
struct rank<T[N]> {
static constexpr size_t value = 1u + rank<T>::value;
};

该例子说明:

  • 可以将type作为参数,存在type metafunction
  • 递归操作不仅可以在主模板(primary)中进行,也可以在模板特化(specialization)中进行。

type作为结果

许多元函数需要一个或多个type,然后返回一个type

示例:返回移除掉const的相同类型

1
2
3
4
5
6
7
8
9
10
11
12
13
// primary
template <class T>
struct remove_const {
using type = T;
};
// partial specialization
template <class T>
struct remove_const<T const> {
using type = typename remove_const<T>::type;
};
// alias
template <class T>
using remove_const_t = typename remove_const<T>::type;

C++ 元函数的约定#1:struct中的type

  • 元函数有类型结果的话用type命名
    示例:An identity metafunction
    1
    2
    3
    4
    5
    // 返回输入类型的类型
    template <class T>
    struct type_is {
    using type = T;
    };
    可以通过继承(inheritance)使用上面的简单元函数(以之前移除const为例):
    1
    2
    3
    4
    5
    6
    template <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
2
template <bool p, class T, class F>
struct IF : type_is<...> {}; // p ? T : F

那么可以让我们写出self-configuring code:

1
2
3
4
5
6
7
8
// 比如代码中有一个常量q
constexpr int q = ...;
// k 被声明为两种类型之一
IF_t<(q < 0), int, unsigned> k;
//调用两个函数之一
IF_t<(q < 0), F, G>{}(...)
// 继承两个基类之一
class C: public IF_t<(q < 0), B1, B2> {...};

IF可以被实现如下:

1
2
3
4
5
6
// primary
template <bool p, class T, class F>
struct IF : type_is<T> {};
// specialization
template <class T, class F>
struct IF<false, T, F> : type_is<F> {};

而对于单类型的判断,也就是“如果true,返回该类型;否则,无返回”,则实现如下:

1
2
3
4
5
6
7
8
// primary
template <bool p, class T = void>
struct enable_if : type_is<T> {};
// specialization
template <class T>
struct enable_if<false, T> {};
template <bool p, class T = void>
using enable_if_t = typename enable_if<p, T>::type;

此时,若实例化时匹配到特化部分,也就是发生enable_if<false, ...>::type,也不一定就会发生编译错误,这就是SFINAE特性。

SFINAE

SFINAE: Substitution Failure is Not An Error.

在模板实例化时,也就是发生模板调用时,会发生:

  • 获取模板参数:
    • 调用时直接给出
    • 函数模板可以通过函数参数来推断
    • 使用模板默认参数
  • 对相应的模板占位参数进行替换
    此时,如果得到正确的代码,则实例化成功;但如果结果代码是不合法的(视为替换失败(Substitution Failure)),则会被静默丢掉(be silently discarded),继续寻找下一个。

应用SFINAE的例子:

1
2
3
4
5
6
7
8
9
10
template <class T>
enable_if_t<std::is_integral_v<T>, int> f(T val) {
std::cout << "using int" << std::endl;
return val;
}
template <class T>
enable_if_t<std::is_floating_point_v<T>, float> f(T val) {
std::cout << "using float" << std::endl;
return val;
}

对于以上函数模板,在调用f<>()时,如果val是整型,则会调用第一个;若是浮点型,则会调用第二个;若都不是(比如字符串),这时,两个模板都替换失败,才会报出编译器错误。

C++20出现了concept,它可以去除上述“别扭”的写法,上述的目的就是在函数调用时,根据参数类型的不同来实例化不同的模板实现,做法则是在一个实现中通过元函数来保证只有类型满足某某条件后才能生成正确的代码,让不满足的出错继而另寻他路,也就是对类型本身的限制条件。可以把这些限制和约束抽象成concept,那写法变成如下:

1
2
3
4
5
6
7
8
9
10
// 原写法:通过enable_if_t制造限制
template <class T>
enable_if_t<std::is_integral_v<T>, int> f(T val) {
...
};
// 新写法:定义一个concept `Integral`,写出受限的模板实现
template <Integral T>
int f(T val) {
...
};

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
2
3
4
5
6
template <class T, T v>
struct integral_constant {
static constexpr T value = v;
constexpr operator T() const noexcept { return value; }
constexpr T operator()() const noexcept { return value; }
};

其他value-returning元函数可以继承该类。

将原先的rank改写如下:

1
2
3
4
5
6
template <class T>
struct rank : integral_constant<size_t, 0u> {};
template <class T, size_t N>
struct rank<T[N]> : integral_constant<size_t, 1u + rank<T>::value> {};
template <class T>
struct rank<T[]> : integral_constant<size_t, 1u + rank<T>::value> {};

一些派生的类型别名:

1
2
3
4
5
template <bool b>
using bool_constant = integral_constant<bool, b>;

using true_type = bool_constant<true>;
using false_type = bool_constant<false>;

value-returning元函数的不同调用方法:

1
2
3
4
is_void<T>::value
bool(is_void<T>{}) // instantiate/cast
is_void<T>{}() // instantiate/call
is_void_v<T>

同时利用继承、特化的一些例子:给一个类型,判断是否是 void

1
2
3
4
5
6
7
8
9
10
11
12
// primary
template <class T>
struct is_void : false_type {};
// specialization 共4种
template <>
struct is_void<void> : true_type {};
template <>
struct is_void<void const> : true_type {};
template <>
struct is_void<void volatile> : true_type {};
template <>
struct is_void<void const volatile> : true_type {};

除了这种方式,也可以委托给其他元函数来实现这个需求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 给两种类型,判断是否相同 */
// primary
template <class T, class U>
struct is_same : false_type {};
// specialization
template <class T>
struct is_same<T, T> : true_type {};

/* 移除 const 和 volatile */
template<class T>
using remove_cv = remove_volatile<remove_const_t<T>>;
template<class T>
using remove_cv_t = typename remove_cv<T>::type;

/* 利用上述元函数实现 is_void */
template<class T>
using is_void = is_same<remove_cv_t<T>, void>;

在元函数中使用参数包(parameter pack

一些元函数中,我需要任意长的参数列表(比如需要任意多个类型参数),此时可以借助参数包实现。

示例:判断某个类型是否与一堆类型中的某个相同

1
2
3
4
5
6
7
8
9
10
11
12
// primary
template <class T, class... P0toN>
struct is_one_of;
// base #1: specialization recognizes empty list of types
template <class T>
struct is_one_of<T> : false_type {};
// base #2: specialization recognizes match at head of list of types
template <class T, class... P1toN>
struct is_one_of<T, T, P1toN...> : true_type {};
// base #3: specialization recogniazes mismatch at head of list of types
template <class T, class P0, class... P1toN>
struct is_one_of<T, P0, P1toN...> : is_one_of<T, P1toN...> {};

可以再次实现is_void

1
2
3
4
5
6
7
template <class T>
using is_void = is_one_of<T,
void,
void const,
void volatile,
void const volatile
>;

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
2
3
4
5
6
7
8
9
10
11
12
13
template <class T>
struct is_copy_assignment {
private:
// SFINAE
template <class U,
class = decltype(std::declval<U &>() = std::declval<U const &>())>
static true_type try_assignment(U &&);
// catch-all overload
static false_type try_assignment(...);

public:
using type = decltype(try_assignment(std::declval<T>()));
};

这个例子中判断了能否std::declval<U &>() = std::declval<U const &>(),但也应该判断赋值结果类型是否是T&

在没有decltype时,可以使用sizeof也构造出一个Unevaluated的场景,但这时type无法由decltype得到,为了得到type,可以让两种函数返回不同大小的值,然后再用sizeof判断。

1
2
3
4
5
6
7
// 定义两种返回类型
typedef char(&yes)[1];
typedef char(&no)[2];
// 得到返回值的大小
sizeof(try_assignment(std::declval<T>()))
// 得到type
typedef bool_constant<sizeof(try_assignment(std::declval<T>())) == sizeof(yes)> type;

void_t

1
2
template <class...>
using void_t = void;

对于void_t,它是void的别名,但用起来也是一个元函数调用(void_t<int, float, ...>),它接受任意数量的类型参数,给出一个void类型。
它无视了所有你给出的参数,然后如同什么都没有一样返回了一个void(就好像直接使用void),但它却可以有奇妙的用处。

示例:检测一个类中是否有一个类型成员(T::type

1
2
3
4
5
6
// primary
template <class, class = void>
struct has_type_member : false_type {};
// specialization
template <class T>
struct has_type_member<T, void_t<typename T::type>> : true_type {};

在该例子中,如果类型中存在类型成员type,那在调用has_type_membe<T>时,会发现特化版本has_type_member<T, void_t<typename T::type>>是合法的,尽管好像是用了一种奇怪的方法来写void,那么它将会被选中。
当然,假设不存在,那特化版本不合法,从而转向false_type

利用这个void_t可以重写is_copy_assignment

1
2
3
4
5
6
7
8
9
10
template <class T>
using copy_assignment_t =
decltype(std::declval<T &>() = std::declval<T const &>());
// primary
template <class, class = void>
struct is_copy_assignable : false_type {};
// specialization
template <class T>
struct is_copy_assignable<T, void_t<copy_assignment_t<T>>>
: is_same<copy_assignment_t<T>, T &> {};

而当把<T const &>改为<T &&>,其他不变时,就可以实现is_move_assignable

结语

泛型编程是一种在C++中广泛使用的编程范式,它允许程序员编写与数据类型无关的代码。这种编程方式通过使用模板来实现,模板可以是函数模板或类模板。
C++ 模板最初是为实现泛型编程设计的,但人们发现模板的能力远远不止于那些设计的功能。一个重要的理论结论就是:C++ 模板是图灵完备的(可以用 C++ 模板模拟图灵机)。
理论上说 C++ 模板可以执行任何计算任务,但实际上因为模板是编译期计算,其能力受到具体编译器实现的限制(如递归嵌套深度)。C++ 模板元编程是“意外”功能,而不是设计的功能,这也是 C++ 模板元编程语法丑陋的根源。

参考:

  1. 现代模板元编程 - Cppcon 2014 - Walter E. Brown
  2. 现代C++进阶:模板元编程与函数式
  3. 关于模板元编程,这是我见过最牛的文章了!

C/C++ 模板元编程学习
https://kaysonyu.github.io/2025/01/TemplateMetaprogramming/
Author
Yu Kang
Posted on
January 3, 2025
Licensed under