11、编译期算法:编译期 find、编译期 count、编译期 transform
说实话,很多人一听到「编译期算法」就觉得玄乎。其实没那么复杂。
你想想看,既然我们能在编译期算出一个值,那为什么不能算出一个序列?既然能算出一个序列,那为什么不能在这个序列上做查找、计数、变换?
嗯,这就是我们今天要聊的东西。
11.1 编译期算法到底在算什么?
先明确一个概念:编译期算法操作的对象,是类型列表(TypeList)或者值列表(ValueList)。
说白了,就是把运行时对数组做的那些操作——find、count、transform——搬到编译期来做。只不过操作的对象从「运行时变量」变成了「编译期类型或常量」。
我在项目中遇到过这样一个场景:需要根据某个类型在列表中的位置,决定是否启用某个特化版本。如果运行时做,就得用 RTTI,性能差不说,代码还丑。用编译期 find,几行模板搞定。
核心思想:编译期算法 = 模板递归 + 特化分支 + 类型萃取。没有循环,没有变量,只有递归展开。
11.2 编译期 find:在类型列表中查找
先来个最简单的:给定一个类型列表,查找某个类型是否存在,返回索引位置。
// 前置声明
template <typename T, typename List>
struct IndexOf;
// 递归终止:找到了
template <typename T, typename... Rest>
struct IndexOf<T, TypeList<T, Rest...>> {
static constexpr std::size_t value = 0;
};
// 递归步骤:没找到,继续往后找
template <typename T, typename First, typename... Rest>
struct IndexOf<T, TypeList<First, Rest...>> {
static constexpr std::size_t value =
1 + IndexOf<T, TypeList<Rest...>>::value;
};
// 没找到的情况
template <typename T>
struct IndexOf<T, TypeList<>> {
static constexpr std::size_t value = static_cast<std::size_t>(-1);
};
用法很简单:
using MyList = TypeList<int, double, char, float>;
constexpr auto idx = IndexOf<double, MyList>::value; // idx = 1
个人习惯:我一般把没找到的返回值设成 -1(即 size_t 的最大值),这样和 STL 的 npos 风格一致。当然你也可以用 std::optional 包装一下,但编译期用起来会麻烦一点。
11.3 编译期 count:统计类型出现次数
find 是找位置,count 是数个数。实现思路类似,但递归时要做加法。
template <typename T, typename List>
struct Count;
template <typename T>
struct Count<T, TypeList<>> {
static constexpr std::size_t value = 0;
};
template <typename T, typename... Rest>
struct Count<T, TypeList<T, Rest...>> {
static constexpr std::size_t value =
1 + Count<T, TypeList<Rest...>>::value;
};
template <typename T, typename First, typename... Rest>
struct Count<T, TypeList<First, Rest...>> {
static constexpr std::size_t value =
Count<T, TypeList<Rest...>>::value;
};
注意第三个特化:当第一个元素不匹配时,不加 1,继续递归。
我曾经踩过一个坑:忘记处理「第一个元素不匹配」的情况,结果递归永远走不到终止条件,编译直接爆栈。嗯,模板递归的终止条件一定要覆盖所有分支。
11.4 编译期 transform:对每个类型做变换
transform 就更有意思了。它把一个类型列表里的每个类型,通过一个「元函数」映射成另一个类型,生成新的类型列表。
// 元函数:给类型加一层指针
template <typename T>
struct AddPointer {
using type = T*;
};
// transform 实现
template <template<typename> class F, typename List>
struct Transform;
template <template<typename> class F>
struct Transform<F, TypeList<>> {
using type = TypeList<>;
};
template <template<typename> class F, typename First, typename... Rest>
struct Transform<F, TypeList<First, Rest...>> {
using type = TypeList<
typename F<First>::type,
typename Transform<F, TypeList<Rest...>>::type
>;
};
用起来很直观:
using Original = TypeList<int, double, char>;
using Transformed = Transform<AddPointer, Original>::type;
// 结果:TypeList<int*, double*, char*>
关键点:transform 的「元函数」必须是一个模板模板参数(template<typename> class F),它接受一个类型,返回一个类型。这是编译期算法的核心抽象。
11.5 把这些算法串起来:一个实际例子
光讲理论没意思。我给你们看一个我在项目中用过的真实场景。
假设我们要实现一个「类型过滤器」:从一个类型列表里,挑出所有满足某个条件的类型,组成新列表。
// 条件:判断是否是整数类型
template <typename T>
struct IsInteger {
static constexpr bool value = false;
};
template <>
struct IsInteger<int> {
static constexpr bool value = true;
};
template <>
struct IsInteger<long> {
static constexpr bool value = true;
};
// 编译期 filter(简化版)
template <template<typename> class Pred, typename List>
struct Filter;
template <template<typename> class Pred>
struct Filter<Pred, TypeList<>> {
using type = TypeList<>;
};
template <template<typename> class Pred, typename First, typename... Rest>
struct Filter<Pred, TypeList<First, Rest...>> {
using type = typename std::conditional_t<
Pred<First>::value,
TypeList<First, typename Filter<Pred, TypeList<Rest...>>::type>,
typename Filter<Pred, TypeList<Rest...>>::type
>;
};
你看,这里用了 std::conditional_t 来做编译期的 if-else。如果满足条件,就把当前类型加入结果列表;否则跳过。
我建议:写这种递归模板时,先把终止条件写好,再写递归步骤。这样不容易漏分支。另外,可以用 static_assert 在编译期验证结果,省得运行时才发现问题。
11.6 编译期算法的性能真相
很多人觉得编译期算法「快」,其实要分两面看:
| 方面 | 说明 |
|---|---|
| 编译时间 | 递归深度越大,编译越慢。100 个类型的列表,递归 100 层,编译时间肉眼可见地涨。 |
| 运行时间 | 零开销。所有计算都在编译期完成,运行时直接拿结果。 |
| 代码体积 | 每个特化都会生成一份代码。如果列表很长,二进制体积会膨胀。 |
说白了,编译期算法是用「编译时间」换「运行时间」。如果你的列表只有几十个类型,完全没问题。但如果上千个,我建议你重新考虑设计。
11.7 知识体系图
下面这张图总结了编译期 find、count、transform 的核心逻辑和关系:
11.8 避坑指南
最后,分享几个我这些年积累的经验:
- 递归深度限制:编译器默认模板递归深度一般是 256 或 512。如果列表太长,可以用
-ftemplate-depth调整,但不建议超过 1024。 - 类型列表不要用 std::tuple:虽然 std::tuple 也能当类型列表用,但它的特化机制不如自己写的 TypeList 灵活。我建议自己定义一个简单的 TypeList。
- 注意编译错误信息:模板递归出错时,编译器报错能长达几百行。我的习惯是:先写一个最小用例验证递归逻辑,再逐步扩展。
- C++17 的 if constexpr:如果你用 C++17,可以用 if constexpr 简化递归实现,代码会清晰很多。但底层原理还是模板递归。
我曾经犯过一个低级错误:在递归步骤里忘了加 typename 关键字,结果编译器报了一堆看不懂的错误。嗯,模板里依赖类型的 typename 不能省,这是 C++ 的硬性规定。
编译期算法,说白了就是「用模板递归模拟循环」。掌握了 find、count、transform 这三个基础操作,你就能组合出更复杂的编译期算法。比如 filter、reduce、zip 等等。
这些内容,我们后面会陆续讲到。
公众号:蓝海资料掘金营,微信deep3321