通常情况下,我们会认为代码和运行时的数据是差别的,毕竟我们使用代码来操纵数据。而元编程的本质,大概就是将代码也视为数据,操作程序自身的结构和行为来动态地生成、修改和扩展代码的能力。换言之,我们直接操纵的是代码本身,而不是程序运行时的数据。

C++ 的模板系统是图灵完备的,也就是说,该模型可以计算任何可计算的问题,甚至可以将其视为一门新的编程语言。当然,业务向开发的话或多或少属于奇技淫巧,只有必要的时候才建议使用,例如实现某些框架、编译器、解释器时,所以这些特性更适合于库的开发者。

基础

C++14 对 constexpr 进行了扩展,可以这样定义一个元函数:

1
2
3
template <int a>
constexpr int fun = a + 1;
// fun<4>

类型计算

下面代码中,输入和输出都是类型 T,传入 int 则设定为 unsigned int,如果是 long,则仍然是 long

1
2
3
4
5
6
7
8
9
10
11
12
template <typename T>
struct Fun_ { using type = T; };

template <>
struct Fun_<int> {
using type = unsigned int;
};

template <>
struct Fun_<long> {
using type = long;
};

有了这样的类型计算机制,就可以比较花哨(高端)地定义一个变量:

1
Fun_<int>::type h = 3; // unsigned int 类型

元函数还可以有多个值:

1
2
3
4
5
6
7
8
9
template <typename T>
struct Fun_ {
using type = T;
using reference_type = T&;
using const_reference_type = const T&;
};

// Fun_<double>::type h = 3.0;
// Fun_<double>::reference_type p = h;

<type_traits> 这是元编程必备的头文件了,提供类型变换、类型比较与判断功能。

1
2
std::remove_reference<int&>::type h1 = 3; // 将 int& 转换为 int
std::remove_reference_t<int&> h2 = 3; // 和上一行相同

模板型模板参数与容器模板

不止是简单类型,还可以用模板作为类型参数,某种意义上就获得更高阶的模板。

1
2
3
4
5
6
7
8
template <
template <typename> class T1,
typename T2
>
struct Fun_
{
using type = typename T1<T2>::type;
};

模板也可以作为输出,只是稍微显得复杂一点:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <type_traits>
using namespace std;

template <>
struct Fun_<true> {
template <typename T>
using type = add_lvalue_reference<T>; // T&
};

template <>
struct Fun_<false> {
template <typename T>
using type = remove_reference<T>;
};

template <typename T>
using Res_ = typename Fun_<false>::template type<T>;

// Res_<int&>::type h = 3;

这个同时也是某种类型的分支逻辑。

变长参数模板

1
2
3
4
5
6
template <int... Vals> struct IntContainer;
template <bool... Vals> struct BoolContainer;

template <typename...Types> struct TypeContainer;
template <template <typename> class...T> struct TemplateCont;
template <template <typename...> class...T> struct TemplateCont2;

元对象与数据域

1
2
3
4
5
6
7
8
template <typename T, size_t N>
struct Fun_
{
constexpr static size_t val = N > 10 ? N / 2 : N;
using ArrType = T[val];
};

using ResType = Fun_<int, 5>::ArrType;

可以在这个基础上仿照 C++ 类的访问控制,比如使用 private

元方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename T>
struct Wrapper
{
template <size_t N>
using method = T[N];
};

template <typename T>
struct Fun_
{
using type = Wrapper<remove_pointer_t<T>>;
};

template <typename T>
using Fun = typename Fun_<T>::type;

// Fun<int*>::method<10> arr;

从函数式编程的角度来理解这段代码,那么显然,FuncWrapper 都是高阶函数:前者返回一个元对象,而后者在传入参数 T 后会产生元方法 method

方法可以返回对象,对象可以继续调用相应的方法,以此类推就可以形成如下的调用链:

1
X().method1().method2()...

控制结构

顺序

在编译期,编译器会扫描两遍结构体中的代码:第 1 遍处理声明,第 2 遍才会深入函数的定义之中。所以,依赖的类型声明必须在前面,语句并不能随意调换。

1
2
3
4
5
6
7
8
template <template T>
struct RemoveReferenceConst_
{
private:
using inter_type = typename std::remove_reference<T>::type;
public:
using type = typename std::remove_const<inter_type>::type;
};

type 必须依靠 inter_type,所以并不能调换顺序。

分支

<type_traits> 实现了 std::conditionalstd::conditional_t,简单的用法如下:

1
2
std::conditional<true, int, float>::type x = 3;
std::conditional_t<false, int, float> y = 1.0f;

实现上述代码的一种基础实现可以是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
namespace Func
{
template <bool B, typename T, typename F>
struct conditional
{
using type = T;
};

template <typename T, typename F>
struct conditional<false, T, F>
{
using type = F;
};

template <bool B, typename T, typename F>
using conditional_t = typename conditional<B, T, T>::type;
}

conditionalconditional_t 的优点在于其定义比较直观,但缺点是表达能力不强。

部分特化实现分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct A; struct B;

template <typename T>
struct Fun_
{
constexpr static size_t value = 0;
};

template <>
struct Fun_<A>
{
constexpr static size_t value = 1;
};

template <>
struct Fun_<B>
{
constexpr static size_t value = 2;
};

constexpr size_t h = Fun_<B>::value;

C++14 之后可以进一步简化:

1
2
3
4
5
6
7
8
9
10
11
12
struct A; struct B;

template <typename T>
constexpr size_t Fun_ = 0;

template <>
constexpr size_t Fun_<A> = 1;

template <>
constexpr size_t Fun_<B> = 2;

constexpr size_t h = Fun_<B>;

还可以使用 std::enable_ifstd::enable_if_t 实现分支。

1
2
3
4
5
6
7
8
9
10
template<bool, typename _Tp = void>
struct enable_if
{ };

template<typename _Tp>
struct enable_if<true, _Tp>
{ typedef _Tp type; };

template<bool _Cond, typename _Tp = void>
using enable_if_t = typename enable_if<_Cond, _Tp>::type;

这里的时 _Tp 并不重要,重要的是 Btrue 时,返回类型。

可以在这个基础上构建分支,

1
2
3
4
5
6
7
8
9
10
11
12
13
template <bool IsFeedbackOut, typename T, std::enable_if_t<IsFeedbackOut>* = nullptr>
auto FeedbackOut_(T&&)
{
return 1;
}

template <bool IsFeedbackOut, typename T, std::enable_if_t<!IsFeedbackOut>* = nullptr>
auto FeedbackOut_(T&&)
{
return 2;
}
// FeedbackOut_<true, int>(5)
// FeedbackOut_<false, int>(5)

IsFeedbackOuttrue 时则第 1 个函数匹配成功,第 2 个则匹配失败。反之,IsFeedbackOutfalse 时,则第 2 个函数匹配成功,第 1 个函数匹配失败。

C++ 11 后明确有了一个特性:「匹配失败并非错误」(Substitution Failure Is Not AnError,SFINAE )。对于上面的代码来说,一个函数匹配失败,另一个函数匹配成功,则编译器会选择匹配成功的函数而不会报告错误。这里的分支实现也正是利用了这个特性。

SFINAE 规则

在 SFINAE 规则中,模板形参的替换有两个时机:

  1. 模板推导的最开始阶段,当明确地指定替换模板形参的实参时进行替换;
  2. 在模板推导的最后,模板形参会根据实参进行推导或使用默认的模板实参。

对于程序员而言,需要清楚的是哪些情况符合替换失败,而哪些情况会引发编译错误。标准委员会发现定义编译错误比替换失败更加容易,所以他们提出了编译错误的情况,而剩下的就是替换失败。

SFINAE 的概念和规则描述起来多少有点复杂,但是其使用起来却十分自然,编译器基本上能按照我们预想的步骤进行编译。

编译器分支

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template <bool Check, std::enable_if_t<Check>* = nullptr>
auto func() {
return static_cast<int>(0);
}

template <bool Check, std::enable_if_t<!Check>* = nullptr>
auto func() {
return static_cast<double>(1);
}

template <bool Check>
auto warp() {
return func<Check>();
}

通过编译期的计算能力,实现了一种编译期能够返回不同类型的函数。当然,为了执行这个函数,我们还是需要在编译期指定模板参数值,从而将这个编译期的返回多种类型的函数蜕化为运行期的返回单一类型的函数。

可以使用 C++17 提供的 if constexpr 简化代码的编写:

1
2
3
4
5
6
7
8
9
template <bool Check>
auto warp() {
if constexpr (Check) {
return static_cast<int>(0);
}
else {
return static_cast<double>(1);
}
}

循环

运行期,我们可以使用一个简单的循环来实现上述示例。而在编译期,我们就需要使用递归来实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
template<unsigned char f>
class Factorial
{
public:
static const unsigned long long val = (f * Factorial<f - 1>::val);
};

template<>
class Factorial<0>
{
public:
static const unsigned long long val = 1;
};

// cout << Factorial<6>::val << endl;

编译期的循环本质上是通过分支对递归代码进行控制。

基于折叠表达式(C++17,fold expression)实现循环:

1
2
3
4
5
6
7
template <size_t... values>
constexpr size_t func()
{
return (0 + ... + values);
}

constexpr size_t res = func<1, 2, 3, 4>();

另一种经常要使用循环的场景是,给定输入序列,产生输出序列(例如函数式里的 map)。可以使用 C++ 提供的包展开来简化循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
template <size_t... I>
struct Cont {
Cont() {
((std::cout << I << " "), ...);
std::cout << "\n";
}
};

template <size_t... I>
using Fun = Cont<(I + 1)...>;

using Raw = Cont<1, 2, 3, 4, 5>;
using Res = Fun<1, 2, 3, 4, 5>;

包展开与折叠表达式的一些区别:

  • 包展开返回的是一个序列,而折叠表达式通常返回的是数值;
  • 包展开返回的可以是类型(这里就是一个可变长度数组类型),而折叠表达式通常返回一个数值;
  • 包展开可以返回类型,因此可以在函数体外使用包展开,但通常来说,我们只能在函数体内,或者为某个常量(变量)赋值时才能使用折叠表达式。

递归模板式(CRTP)

派生类会将本身作为模板参数传递给其基类。

1
2
3
template <typename D> class Base {};

class Derived: public Base<Derived> {};

CRTP 有很多应用场景,模拟虚函数是其典型应用之一。虚函数的执行需要运行期有相应的机制(虚函数表)支持。在一些情况下,我们所使用的函数无法声明为虚函数,例如对应的成员函数为函数模板时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template <typename D>
class Base {
template <typename TI>
void Fun(const TI& input)
{
D* ptr = static_cast<D*>(this);
ptr->Imp(input);
}
};

class Derived: public Base<Derived> {
template <typename TI>
void Imp(const TI& input)
{
cout << input << endl;
}
};

但这样肯定是一个糟糕的设计,毕竟父类不应该和子类耦合,而且也违反最少知识原则。

Imp 是一个函数模板,无法被声明为虚函数。类的静态成员函数也无法被声明为虚函数。此时借用CRTP,同样能达到类似虚函数的效果。

参考资料

  1. 李伟.动手打造深度学习框架.人民邮电出版社.2022
  2. [美] David Abrahams,[美] Aleksey Gurtovoy 著,荣耀译.C++模板元编程.机械工业出版社.2010
  3. 谢丙堃.现代C++语言核心特性解析.人民邮电出版社.2021