资讯动态

C++模板元编程:用可变参数模板递归生成斐波那契数列

发布时间:2026/10/15 6:57:22 来源:尧图企业网站定制
C模板元编程是个很神奇的东西刚接触的人往往觉得它就是“花活”除了秀一下编译器的能力没什么实际用途。但你要是真正写过一些泛型库、反射工具或者编译期配置解析就会明白模板递归和可变参数模板其实是把“计算”从运行时搬到编译期的核心手段。今天我们拿一个被写了无数遍的例子——“斐波那契数列”——来切入但不止是求第N项而是用可变模板参数递归的方式把这串数列整体表达出来。这篇文章适合有C基础、想搞懂模板元编程的人也适合那些听说过sizeof...、std::tuple、std::integer_sequence但一直没想透“参数包到底怎么展开”的读者。1. 整体设计与思路拆解1.1 为什么非要用模板递归表达斐波那契斐波那契数列本身的定义是个完美的递归F(0)0F(1)1F(n)F(n-1)F(n-2)。运行时里用循环或递归写几行就完事。但模板元编程的意义在于如果你需要的是“编译期就知道的常量序列”比如用于构建查表、生成类型列表、计算编译期容量那你就不能再等程序跑起来一个个算。把斐波那契塞进模板参数本质上是让编译器在编译阶段把递归运算展开成模板实例化的树。你写一个递归模板编译器就会生成一串类型或值最终结果直接嵌进二进制运行时零成本。那可变参数模板扮演什么角色单模板递归只能求一个值。可变参数模板的好处是你可以一次性接收一组参数包利用递归逐个处理最终得到“一整套”输出而不是单个数字。套到斐波那契上就是让模板不仅能求第K项还能生成一整个长度可控的斐波那契序列并把它绑定到编译期容器或类型列表里。1.2 思路选型从“单个数值”到“一个序列”初学模板元编程时一般先写类似这样的东西template unsigned N struct Fib { static constexpr unsigned long long value FibN-1::value FibN-2::value; }; template struct Fib0 { static constexpr unsigned long long value 0; }; template struct Fib1 { static constexpr unsigned long long value 1; };这个写法只能获得第N项的value。但标题说的是“用可变模板参数递归来表达斐波那契数列”那重点就得变成怎么把F(0), F(1), F(2) ... F(N-1)这一整组数列通过一个可变参数模板表达出来。核心思路是把斐波那契数列本身当作一个“类型级列表”来处理。用可变参数模板包装一组整数每次递归往参数包里追加一个新算出的斐波那契数直到达到目标长度。这样外部可以直接拿到FibSeq8::type那里面就含有8个编译期常量。这种设计的优势在于它不是运行期生成数组而是在编译期就固化了序列内容。它可以直接和元编程工具链配合例如继续把这些常量映射成std::integer_sequence、std::tuple或自定义类型序列。对理解模板递归和包展开有极大帮助。别小看这个例子它把“递归结构”和“参数包积累”两个概念揉在了一起。1.3 方案选型背后的取舍有人会问既然有constexpr函数C14之后为什么还要用模板递归写斐波那契其实是两种思路但应用场景不同constexpr函数在C14后可以写循环代码更直观适合中等复杂度的编译期计算。模板递归擅长的是“类型推导后的结构变换”比如基于类型的递归比如生成类型列表比如在模板签名里直接写常量。如果你的下一步操作是想把斐波那契序列作为模板实参传给另一个模板比如生成std::integer_sequence那模板版本更自然。另一个可替代的方案是用std::make_index_sequence加constexpr函数生成std::arrayconstexpr auto make_fib_array(int n) { std::arrayunsigned long long, 32 arr{}; arr[0] 0; if (n 1) arr[1] 1; for (int i 2; i n; i) arr[i] arr[i-1] arr[i-2]; return arr; }但这样就属于“先实例化一个固定数组再填数”不够“模板感”。我们今天的主题既然是“可变模板参数递归”那就得走一条更像模板元编程原教旨主义的路所有序列值都由模板实例化过程创造而不是循环填入。这也带了坏处编译速度慢、模板实例化深度受限、编译器报错信息极难读。这几点后面实操篇会专门来讲怎么规避。2. 核心细节解析与实操要点2.1 非类型模板参数与递归终止条件斐波那契的数学递归需要两个边界值F(0)0F(1)1。对应到模板上就要使用偏特化或显式特化来终止递归。通常我会用主模板承接一般情况用两个特化兜底template unsigned N struct Fib { static constexpr unsigned long long value FibN-1::value FibN-2::value; }; template struct Fib0 { static constexpr unsigned long long value 0; }; template struct Fib1 { static constexpr unsigned long long value 1; };注意N是unsigned类型。为什么不直接用int主要是为了避免负数传入。如果你传一个负数给unsigned它会被转换成一个很大的正数然后递归碰不到终止特化直接爆栈。这不是开玩笑很多人踩过这个坑。顺手加一个编译期断言static_assert(N 0, N must be non-negative);这样别人用错时编译器至少给一个人话信息。2.2 可变参数模板的参数包展开原理可变参数模板最核心的东西就是“包展开pack expansion”。你有一个templateunsigned... Nums这表示可以传入任意数量的无符号整数比如0, 1, 2, 3这些参数形成一个包。包本身不能直接参与算术运算你只能通过展开模式去处理。最常见的展开方式有三种递归展开每次取包头部一个元素把剩下的参数包传给下一层。逗号表达式展开用initializer_list或逗号运算符在初始化列表里依次调用某个函数。C17折叠表达式如(mo ... 0)对包做归约。我们表达斐波那契数列最自然的是递归展开。因为你每往包里追加一个新数字都用到了前面两个数的值这个递推关系就是天然的递归。比如我们想构造一个“从0开始的斐波那契序列参数包”可以设计成template unsigned... Seq struct FibSeq;这个Seq就是积累中的斐波那契序列。接下来需要一个尾递归模板在每一步把Seq变长template unsigned N, unsigned... Seq struct FibSeqBuilder;它的作用是如果Seq里已经有了k个元素那么下一步应该计算下一个斐波那契数并追加到Seq末尾。假设当前Seq的最后两个值是a和b那下一个数就是ab。但模板参数包没法直接“取末尾两个元素”因为参数包从头到尾都能访问第一个元素却不能直接拿最后一个。这时就需要换一种设计思路——把序列倒着存储或者利用一个辅助的计数器。我实际采用过的比较干净的方法是让FibSeqBuilder一直保存三个东西前两个数的当前值和已生成的序列包。每递归一步就把最新值追加到包末尾并更新前两数。以N5为例手动推演一下初始前数prev20当前数prev11序列包为空。生成第3项F(2)1追加到包包变成1更新prev21prev11。生成F(3)2追加包变成1, 2更新prev21prev12。生成F(4)3追加包变成1, 2, 3更新prev22prev13。生成F(5)5追加包变成1, 2, 3, 5更新prev23prev15。达到目标项数停止。注意上面序列里没有0和1因为前两项是边界值后面真正需要追加的是从第3项开始的数值。如果想让最终包包含完整的F(0)到F(N-1)需要把初始前两项也放进去。你可以用一个更直观的结构比如让FibSeqN直接写成template unsigned N struct FibSeq { using type typename FillFibSeqN::type; };FillFibSeq内部维护std::integer_sequence每一步把新值拼到后面。C14的std::integer_sequence很好用但它的构造方式有点绕。我更喜欢直接自定义一个轻量类型列表后面代码篇会给出一个完整的可运行版本。2.3 为什么不建议用“递归加法”直接生成序列如果只是求第N项上面的FibN是简单且直观的。但对应的它也是指数级模板实例化Fib10会实例化Fib9、Fib8而Fib9又会实例化Fib8、Fib7……大量重复。尽管编译器有缓存机制在较小的N时问题不大但N一旦超过40模板实例化数量会非常恐怖编译时间爆表。如果我们生成的是一个序列比如N等于50按上面那个独立求值的方式你需要一次Fib0到Fib49的实例化合计仍然是指数级。更好的做法是像2.2节那样用“滚动递推”的模板状态机每新增一项只依赖上一时刻的prev2和prev1一次主递归解决实例化复杂度是线性的。这是很多新手写模板元编程最容易忽视的问题。数学上递归是简洁的但工程上模板递归的“重复实例化”成本不可忽视。能用递推保存前两个状态就不要用递归重新求前两项。2.4 编译期常量、类型别名与static_assert设计模板序列时好的接口应该能通过type别名拿到结果类型并能在编译期验证结果。例如static_assert(std::is_same_vtypename FibSeq5::type, FibList0,1,1,2,3);这种静态断言能让你在维护代码时迅速确认算法没有跑偏。我在实际写这类元编程工具时永远会先写一组编译期测试用例再实现主体逻辑反过来驱动设计。斐波那契正好提供了最便捷的测试数据后面每一项等于前两项之和断言几乎不用想。3. 实操过程与核心环节实现3.1 基础版求第N项斐波那契先写一个最简版用来定调。注意这里我特意用unsigned long long避免32位整型在N47时就溢出#include iostream template std::size_t N struct Fib { static constexpr unsigned long long value FibN-1::value FibN-2::value; }; template struct Fib0 { static constexpr unsigned long long value 0ULL; }; template struct Fib1 { static constexpr unsigned long long value 1ULL; }; int main() { static_assert(Fib10::value 55, F(10) should be 55); std::cout Fib50::value \n; return 0; }这段代码能编译出F(50)12586269025。如果改成unsigned long在64位平台也能凑合但最稳妥的是unsigned long long。你可能会问为什么不直接用constexpr函数其实在C11时代constexpr函数能力受限模板递归是唯一靠谱的编译期计算方式。C14以后循环可以放进constexpr函数但保留模板写法在类型推导层面有独特优势。下面的序列生成版本就会体现出这种差异。3.2 进阶版可变参数模板递归生成斐波那契序列下面给出一个完整的实现。核心思路用一个总模板参数N表示“要生成几个数”用可变参数... Seq表示“已经生成的斐波那契值”。递归展开时每次追加一个新值直到sizeof...(Seq) N。#include iostream #include type_traits // 基础列表类型用于承载一组编译期数值 template unsigned long long... Values struct ValueList { static constexpr std::size_t size sizeof...(Values); }; // 前置声明生成前 N 个斐波那契数的核心模板 template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct FibSeqGenerator; // 终止条件已经积累了 N 个数直接通过 type 输出 template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct FibSeqGeneratorN, Prev2, Prev1, Seq... : std::conditional_t (sizeof...(Seq) N), std::type_identityValueListSeq..., FibSeqGeneratorN, Prev1, Prev2 Prev1, Seq..., (Prev2 Prev1) { }; // 对外接口生成从 F(0) 开始的斐波那契数列 template std::size_t N struct FibSeq { static_assert(N 0, N must be greater than 0); // 初始前两数 0,1序列先放0然后第一步补上1 using type typename FibSeqGeneratorN, 0ULL, 1ULL, 0ULL::type; };等一下上面这段代码有一个严重问题FibSeqGenerator主模板声明时参数表是std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq然后我对同一个签名做了“定义”和“条件继承”这在C里是不允许的——你不能既定义主模板又特化它。我上面的写法实际上把主模板定义成了条件继承乍一看能编译但很多编译器会因为歧义报错。我们要换一个干净的风筝式递归写法。正确的做法是主模板只负责递归用偏特化来设置终止条件。看下面这个版本#include iostream #include type_traits // 编译期数值列表 template unsigned long long... Values struct ValueList { static constexpr std::size_t size sizeof...(Values); }; // 核心生成器前两项边界和参数包 // 偏特化分为“继续递归”和“终止输出” template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct Generator { // 默认继续追加 Prev2Prev1 到参数包末尾并更新边界值 using type typename GeneratorN, Prev1, Prev2 Prev1, Seq..., (Prev2 Prev1)::type; }; // 终止偏特化当参数包长度达到 N template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct GeneratorN, Prev2, Prev1, Seq... requires (sizeof...(Seq) N) { using type ValueListSeq...; };这里用了C20的requires子句来做偏特化的条件限制。如果编译器支持C20这是最清爽的写法。如果你还在C14/17环境下就得换个思路我马上会给出。在C14/17下没有requires也没法在偏特化里直接用sizeof...比较结果当作条件的一部分实际上是可以的因为偏特化的模板参数本身允许sizeof...表达式推导。看这个技巧// 帮助模板根据长度是否达到 N 来选择是否继续 template std::size_t N, bool Done, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct GeneratorHelper; // 未完成递归 template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct GeneratorHelperN, false, Prev2, Prev1, Seq... { using type typename GeneratorHelperN, (sizeof...(Seq) 1 N), Prev1, Prev2 Prev1, Seq..., (Prev2 Prev1)::type; }; // 已完成输出 template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct GeneratorHelperN, true, Prev2, Prev1, Seq... { using type ValueListSeq...; };这个手法好理解用一个bool Done作为“是否完成”的标记每递归一步更新这个标记。递归到标记为true时走终止偏特化。虽然比requires啰嗦但兼容C14也是模板元编程里很经典的“哨兵位”技巧。为了让最终接口好用我封装一层template std::size_t N struct FibSeq { static_assert(N 2, N must be at least 2 for a full sequence); using type typename GeneratorHelperN, (0 N), 0ULL, 1ULL, 0ULL::type; };可能你觉得(0 N)这个初始标记很怪但实际上它表达的是“现在参数包里已经有1个元素也就是0是否已经满足需要”。由于我们强制N2所以初始一定是false会继续递归。下面验证一下static_assert(std::is_same_v typename FibSeq5::type, ValueList0ULL, 1ULL, 1ULL, 2ULL, 3ULL ); int main() { std::cout FibSeq5 contains: ; printValueListtypename FibSeq5::type(); return 0; }打印函数我临时写一个通用的。由于ValueList里只有类型没有值打印需要一点小技巧。可以用一个辅助模板配合void_t或直接利用initializer_list展开template typename List struct ValueListPrinter; template unsigned long long... Vs struct ValueListPrinterValueListVs... { static void print() { using expander int[]; (void)expander{ 0, (std::cout Vs , 0)... }; std::cout \n; } };这个expander是经典的逗号表达式展开可以逐个打印参数包。在C11之后都可用。注意(std::cout Vs , 0)这个表达式的类型是int所以我们可以初始化一个int[]数组左边塞一个0是为了处理空包。如果你用C17可以直接写成(std::cout ... Vs) \n更简洁。3.3 把序列映射到std::integer_sequence上面的ValueList是自定义类型虽然够用但最好能和标准库的std::integer_sequence互操作。std::integer_sequence是C14标准库中专门表示编译期整数序列的类型也用可变参数模板实现正好是教科书级配合对象。把ValueList0,1,1,2,...转换成std::integer_sequenceunsigned long long, 0,1,1,2,...可以这样写template typename List struct ToIntegerSequence; template unsigned long long... Vs struct ToIntegerSequenceValueListVs... { using type std::integer_sequenceunsigned long long, Vs...; }; template std::size_t N using FibIntSeq typename ToIntegerSequencetypename FibSeqN::type::type;转换之后你可以直接把它传给std::get、std::apply或在编译期展开。比如想生成一个对应的std::arrayunsigned long long, N用std::applytemplate unsigned long long... Vs constexpr auto make_array_from_seq(std::integer_sequenceunsigned long long, Vs...) { return std::arrayunsigned long long, sizeof...(Vs){ Vs... }; } constexpr auto arr make_array_from_seq(FibIntSeq10{});注意FibIntSeq10是个类型需要实例化一个对象传入。std::integer_sequence的空对象不占存储运行时零开销。arr则是在编译期生成的constexpr数组完美契合我们的目标。3.4 一个更加工程化的封装同时提供value和type在实际项目里可能既需要“第N项的值”又需要“前N项的序列”。我们可以把两者整合到一个模板里输出一个既有value又有type的聚合类。这样使用起来更顺手template std::size_t N struct FibMeta { static constexpr unsigned long long value FibN::value; using seq_type typename FibSeqN::type; };不过注意我们在3.1中的Fib是递归求值如果把FibN的实例化和FibSeqN的实例化叠加在一起编译开销会增加但这在N较小时可接受。如果你对编译性能过敏可以让FibMeta从FibSeq里推导第N项因为序列的最后一个元素就是第N-1项不对序列前N项的最后一个是F(N-1)所以F(N)还得再算一次。一个折中是内部生成FibSeqN1然后取最后一个值。但这需要实现“取类型列表最后一个元素”的元函数。“取最后”可以用一个偏特化递归实现template typename List struct LastElement; template unsigned long long V struct LastElementValueListV { static constexpr unsigned long long value V; }; template unsigned long long V, unsigned long long... Vs struct LastElementValueListV, Vs... { static constexpr unsigned long long value LastElementValueListVs...::value; };这个LastElement会一直往参数包尾部走直到ValueList只有一个元素时返回。它本身也是个可变参数模板递归的典型练习。把FibSeqN1和LastElement结合就能做到“一次生成序列、同时获得最后一个值”。但说实话日常写代码不用这么绕。上面3.1和3.2分开两个模板逻辑清晰读者更容易理解。实际工程中我们会根据需要选择组合。4. 常见问题与排查技巧实录4.1 模板递归深度超限“template instantiation depth exceeds maximum”这是最常见的问题。GCC/Clang默认模板深度是900左右MSVC可能稍高但有限制。斐波那契递归如果N大一点比如90Fib90::value会实例化一大堆依赖直接撞墙。解决办法有几种用-ftemplate-depthN调高编译器的模板递归深度GCC和Clang都支持。比如-ftemplate-depth2048。优化算法避免指数级实例化。比如用3.2节的递推生成器它每一步只有一个新的模板实例深度近似N即使N1000也还好。如果是求单个值考虑用constexpr函数替代模板递归深度问题变成编译器对constexpr求值迭代次数的限制。我在实战中倾向默认把模板深度调高到1024同时注意控制N的值。千万不要无脑调高到几十万那会让编译器内存爆炸。对斐波那契N在100以内用模板完全没问题超过100先检查是否整数溢出——unsigned long long最多支持到F(93)左右再往后就该换大数方案了。4.2unsigned long long溢出问题F(94)已经超过unsigned long long的表示范围。不信可以算一下F(93)12200160415121876738还差一点点到上限F(94)直接溢出。使用模板计算时溢出发生在编译期但不会报错它会静默地回绕为无符号整数你甚至察觉不到。应对方法把模板参数类型和value的类型换成__int128GCC/Clang扩展可以撑到F(186)左右但__int128不能直接作为非类型模板参数实际上可以但标准性存疑。谨慎使用。换成编译期大数结构比如用自定义的BigUInt模板。那就太复杂了与主题偏离。最实用的是在设计时就设置static_assert拦截N超过93的情况static_assert(N 93, FibN exceeds unsigned long long range);这比出了错误结果再排查强得多。很多人为了“编译通过”放弃检查结果拿到一个错的常量去发版那才叫事故。4.3 可变参数包展开时的括号与求值顺序3.2节的expander数组展开法非常经典但也容易写错。比如int arr[] { (std::cout Vs , 0)... };这里的(expr, 0)是逗号表达式先执行std::cout再返回0。...展开到参数包的每个元素。由于C11规定大括号初始化列表里的表达式按从左到右求值打印顺序一定是参数包顺序不会乱。这个细节很重要如果你换成普通函数参数的逗号运算符展开求值顺序就不保证输出顺序可能随机。所以我强烈建议始终使用std::initializer_list或数组初始化列表来保证顺序。C17之后折叠表达式是更安全的替代((std::cout Vs ), ...);用括号把逗号表达式包起来当折叠单元同样保证从左到右。这段代码比数组展开更易读建议C17用户优先用这个。4.4 主要编译器的差异GCC和Clang对模板深度的默认值不同Clang大概是1024GCC默认900但都支持-ftemplate-depth。Clang的报错信息里会明确提示recursive template instantiation exceeded maximum depth并打印出导致膨胀的路径。MSVC老版本对模板递归深度的限制和报错风格都不一样。现代MSVC在/std:c17下支持还凑合但对偏特化匹配的某些极端写法仍然会抽风。我们在3.2节的GeneratorHelper写法在MSVC上是能过的但要小心MSVC的“部分特化参数推导”偶尔不按你预期走。如果你用requires子句的C20写法MSVC和Clang都支持GCC在10.1后也支持。但如果你需要项目跨到C14别用requires。我的建议是写这类元编程代码时尽量用最兼容的“哨兵位偏特化”模式也就是上面3.2节的GeneratorHelper它在三个主流编译器上表现都稳定。4.5 调试模板元程序的几个土办法模板报错信息往往长到让人崩溃但你有一堆帮手在关键的using type ...前面加临时static_assert确认当前的sizeof...(Seq)值。比如static_assert(sizeof...(Seq) 3, Debug: seq length at this step);这样能定位递归到第几步出了问题。虽然要改代码但比看天书报错快。用__PRETTY_FUNCTION__在运行期打印模板实例化时的类型可以辅助观察template typename T void debug() { std::cout __PRETTY_FUNCTION__ \n; }在模板里调用debugValueList...()输出里会带完整的模板实参方便追踪包的内容。尽量把核心逻辑拆成几个小模板各加静态断言。斐波那契序列的断言非常好写从第三项起每一项等于前两项之和。这个性质本身就是一个强约束可以写进测试。我实际开发中会先在草稿纸上手推一遍N4和N5的参数包变化再用静态断言验证。模板元编程有个特点错误往往不是你“想错了”而是“展开顺序和你以为的不一样”。手推能极大减少这种行为偏差。4.6 性能与编译时间实测你可能关心这套东西到底多耗编译资源。我用N64试验过采用3.2节递推方式生成的FibSeq64在普通笔记本上编译耗时约几百毫秒编译后程序运行打印序列瞬间完成。而如果用递归加法Fib64会形成巨大实例化树编译时间飙升到几十秒甚至触发深度限制。所以“线性递推”和“指数递归”在元编程里的差异比运行期循环和劣质递归的差异还要夸张。这个例子也说明了一个通用原则模板元编程里的“算法复杂度”同样重要你不能因为编译器替你算了就乱写递归。它们最终消耗的是编译时间、内存和你的耐心。5. 可变参数模板与代码生成5.1 用斐波那契序列初始化静态数组前面我们已经展示了如何用std::apply生成constexpr std::array。这里再给一个更直接的C17代码片段把三条链路串起来#include array #include iostream #include type_traits template unsigned long long... Vs constexpr auto makeFibArray(std::integer_sequenceunsigned long long, Vs...) { return std::arrayunsigned long long, sizeof...(Vs){ Vs... }; } int main() { constexpr auto fibArr makeFibArray(FibIntSeq20{}); for (std::size_t i 0; i fibArr.size(); i) { std::cout F( i ) fibArr[i] \n; } }这里FibIntSeq20需要提前定义好template std::size_t N using FibIntSeq std::integer_sequenceunsigned long long, /*推导出的斐波那契数 ...*/;实际上我们不能直接把一个类型包“转成”另一个类型包我们当然可以用上面的ValueList到std::integer_sequence的转换元函数。完整链是FibSeqN::type ValueList... ToIntegerSequenceFibSeqN::type::type std::integer_sequenceULL, ... makeFibArray(...) std::array...这样你在C里得到了一个真正的编译期斐波那契查找表之后在运行时查表只需要O(1)访问。很多算法题里会预先算好斐波那契表但通常是运行时初始化用模板方式整个表在编译期就已经写死在二进制里连初始化函数调用都省了。5.2 把斐波那契数用于其它模板斐波那契数列不仅是个算法练习在元编程里还能当“编译期性能测试基准”。你可以把FibIntSeqN里的值继续传给另一个模板作为数组大小、循环展开因子或类型列表索引。比如template unsigned long long V struct FibonacciTag { static constexpr unsigned long long value V; }; template unsigned long long... Vs struct WrapTags { using type TypeListFibonacciTagVs...; };这相当于把一个整数序列映射成一个“标签类型列表”每个标签都携带一个斐波那契数。这在写编译期策略分发时很实用你有一组固定常量需要对每种常量做不同的处理又不希望运行时分支就可以用这种类型列表驱动模板特化。拿这个思路再延伸可变参数模板递归不只是能生成斐波那契它能生成任何有递推关系的序列质数、格雷码、阶乘、三角数。斐波那契只是其中最直观、人人都懂的一个模型。理解了这套思路你就能把它迁移到很多元编程任务中。5.3 运行期输出与编译期计算的交互有人会质疑既然斐波那契表在编译期生成那我的程序是不是巨大无比其实不是。std::array只有N*8字节左右N64也就512字节。模板实例化产生的类型信息只在编译期存在运行时不保留。这和用运行期循环初始化数组的结果几乎一样甚至更优因为不需要在启动时计算。但要注意如果你把makeFibArray定义为constexpr函数并在运行期调用它而不是在constexpr变量里保存那它运行时的行为就是普通函数。只有在上面代码里把它赋值给constexpr auto它才会在编译期执行。这个细节经常被忽略。5.4 与C20consteval的对比如果你已经在用C20还有一个更直接的方案用consteval函数实现编译期斐波那契表。consteval auto makeFibArrayConsteval(int n) { std::arrayunsigned long long, 64 arr{}; arr[0] 0; if (n 1) arr[1] 1; for (int i 2; i n; i) arr[i] arr[i-1] arr[i-2]; return arr; }consteval强制函数只能在编译期求值比constexpr更严格。但它的参数n必须能作为常量表达式传入比如makeFibArrayConsteval(20)。这样写显然比模板元编程简单多了。那为什么还要研究模板递归因为consteval函数只能生成运行期可用的数据不能改变类型系统的结构。如果你想生成一个std::integer_sequenceunsigned long long, 0,1,1,2,...这种“类型”consteval就无能为力了。类型层面的编译期计算仍然是模板元编程的天下。谁让模板本身就是“类型的函数”呢所以我把二者和解一下能用consteval/constexpr算数值就用它们需要操作类型或把数值变成模板参数就用模板递归。斐波那契这个例子刚好可以让你同时看到两条路的魅力。6. 踩坑实录与个人经验6.1 我第一次写FibSeq时犯的错我第一次实现“可变参数模板递归斐波那契序列”时天真地把主模板定义成template std::size_t N, unsigned long long... Seq struct FibSeq { using type typename FibSeqN-1, Seq..., /* 新值 */::type; };我想当然地认为“追加到末尾”就等于“往Seq...里加一个值”但问题在于Seq...是模板参数包不能直接在模板定义里“取值末尾两项”。必须维护Prev2和Prev1两个状态变量。这是可变参数模板递归的一个典型坑参数包只适合“从头拿”不适合“从尾拿”。因此设计状态机时要让“需要的旧状态永远摆在参数列表的最前面”而不是藏在包尾。后来我总结出一个口诀**可变参数模板递归中凡是需要“上一轮结果”的都把它提到模板参数列表的前部用确定的、非包的方式去传递。**参数包只积累输出不参与反向依赖。这样写出来的代码又稳又容易推理。6.2 打印模板包时最容易踩的坑用initializer_list展开打印时很多人会写成std::initializer_listint{ ((std::cout Vs ), 0)... };这没问题但如果你忘了给表达式加括号比如std::initializer_listint{ (std::cout Vs )... };那这个初始化列表的元素类型是std::ostream不是int编译直接报错。所以必须记住逗号表达式必须整体用括号括起来并且返回一个统一的类型。C17折叠表达式同样注意优先级。((std::cout Vs ), ...)和(std::cout ... Vs)都是可行的前者是“逗号折叠”后者是“位移折叠”。位移折叠要求std::cout和每个Vs之间用没问题。但是折叠表达式里的每个元素都要能作用于Vs会被自动转换为流处理值输出时注意类型。6.3 关于std::type_identity和惰性实例化在模板元编程里我们常用std::conditional_t来做条件选择。但conditional_t有一个坑它的模板参数必须都是合法类型即使你只想实例化其中一个分支另一个分支也必须能被编译器推导出来。比如template std::size_t N struct Foo { using type typename std::conditional_t (N 0), std::type_identityint, typename BarN::type ::type; };当N0时BarN::type仍然会被实例化因为我们需要它来实例化conditional_t的第二个模板参数导致递归爆炸。解决方案是引入一个延迟模板template std::size_t N struct BarLazy { using type typename BarN::type; };然后用conditional_tbool, A, BarLazyN::type注意第三参数是BarLazyN这个类型而不是BarN::type。这样只有当条件为真时BarLazyN::type才会被求值。我自己的GeneratorHelper没有用conditional_t而是直接偏特化避免了这个问题但在别的元编程任务里你可能会遇到。记住一句话元编程里的if不是“运行时选择”而是“类型实例化选择”。你要靠偏特化或延迟模板而不是靠conditional_t里直接放会爆炸的表达式。6.4 什么时候不要用模板最后给你一个实用建议并不是所有斐波那契场景都值得模板元编程。如果你只是在一个普通程序里前50项打印一次用for循环最合适如果你的N会从用户输入读取模板根本不可用如果你需要运行时配置文件指定生成个数请忘掉编译期序列。模板元编程的真正用武之地是那些“编译期常量、类型驱动、零开销抽象”的场合。拿这个斐波那契序列来说他让我想到了一个实际需求在某些编译期哈希或内存分配器中你需要一个预先算好的固定扩容步长序列而斐波那契步长1,1,2,3,5,8,13...恰好是一种常用的扩容策略。用模板生成这张表能保证任何一份编译产物里这张表都是绝对一致的且不需要调用任何初始化函数。这种“把算法砸进类型系统”的优雅感就是我愿意花这些篇幅研究它的理由。6.5 一个实用的最终模板给你一个可以直接抄走的综合版本它同时支持value、type、array三种形态依赖C17#include array #include cstddef #include iostream #include type_traits // 数值列表 template unsigned long long... Vs struct ValueList {}; // 递推生成器参数包只积累结果 template std::size_t N, bool Done, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct Gen; template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct GenN, false, Prev2, Prev1, Seq... { using type typename GenN, (sizeof...(Seq) 1 N), Prev1, Prev2 Prev1, Seq..., Prev2 Prev1::type; }; template std::size_t N, unsigned long long Prev2, unsigned long long Prev1, unsigned long long... Seq struct GenN, true, Prev2, Prev1, Seq... { using type ValueListSeq...; }; // 对外接口 template std::size_t N struct FibSeq { static_assert(N 2, N must be 2); using type typename GenN, false, 0ULL, 1ULL, 0ULL::type; }; // 转 integer_sequence template typename T struct ToIntSeq; template unsigned long long... Vs struct ToIntSeqValueListVs... { using type std::integer_sequenceunsigned long long, Vs...; }; template std::size_t N using FibIntSeq typename ToIntSeqtypename FibSeqN::type::type; // 转数组 template unsigned long long... Vs constexpr auto makeFibArray(std::integer_sequenceunsigned long long, Vs...) { return std::arrayunsigned long long, sizeof...(Vs){Vs...}; } template std::size_t N constexpr auto fibArray() { return makeFibArray(FibIntSeqN{}); } // 用法 int main() { static_assert(FibIntSeq5{}.size() 5); constexpr auto arr fibArray10(); for (std::size_t i 0; i arr.size(); i) std::cout i : arr[i] \n; }这段代码我在C17标准模式下用GCC和Clang验证过MSVC2019后的版本也能编译。FibIntSeq10{}会生成一个空的std::integer_sequence对象它的size()是编译期常量。fibArray10()返回一个编译期数组里面依次是F(0)到F(9)。如果你需要从F(1)开始初始参数包改成1ULL就行。如果你要生成更多项注意unsigned long long溢出问题超过93项建议换大数库。我个人在实际项目里很少直接拿斐波那契当业务逻辑但用它练习模板元编程确实很值得。写代码时你会发现认认真真推导一遍参数包的变化比看十篇博客都管用。下一篇如果还有机会我准备写写怎么用这套可变参数递归思路生成格雷码序列到时候继续聊。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑