C++可变参数模板深度解析:从语法到实战疑难解决方案

发布时间:2026/8/8 5:24:20
C++可变参数模板深度解析:从语法到实战疑难解决方案 1. 项目概述为什么我们需要深入理解可变参数模板在C的模板元编程世界里可变参数模板Variadic Templates绝对是一个让人又爱又恨的特性。爱它是因为它提供了前所未有的灵活性和类型安全让像std::tuple、std::function、std::make_shared这样的现代库组件成为可能恨它是因为它的语法看起来像天书编译错误信息长得能绕地球三圈递归展开的逻辑一不小心就能把CPU和程序员一起搞懵。我第一次接触可变参数模板是在尝试实现一个简单的日志函数时想要一个能接受任意类型、任意数量参数的log函数就像printf那样方便但必须是类型安全的。当时对着typename... Args和Args... args这种语法发呆了半天好不容易写出来编译报错信息直接刷了几百行根本无从下手。我相信很多从C11/14开始接触现代C的开发者都有类似的经历。这个特性就像一把锋利无比的双刃剑用好了能极大提升代码的抽象能力和表达力用不好就会引入难以调试的编译期错误和晦涩难懂的代码。所以这篇内容不是一份简单的语法手册。市面上讲“如何使用”可变参数模板的文章已经很多了但真正深入剖析那些让人抓狂的“疑难杂症”和“陷阱”的却很少。我将结合自己这些年踩过的坑、调试过的诡异编译错误以及在实际项目比如实现自定义的序列化库、事件系统、工厂模式中积累的经验带你彻底搞懂可变参数模板。我们会从最基础的语法开始但重点会放在那些容易混淆的概念、常见的编译错误解析、以及高级应用场景下的最佳实践和解决方案上。无论你是正在学习现代C的中级开发者还是已经用过但总感觉心里没底的高级工程师这篇文章都能帮你把这块硬骨头啃下来。2. 可变参数模板核心语法与机制深度拆解2.1 参数包Parameter Pack的本质它到底是什么很多人第一次看到templatetypename... Ts会觉得这不就是接受任意多个类型参数嘛。这种理解对但太表面了。参数包Parameter Pack在编译器眼里其实是一个编译期的类型列表或值列表。它不是一个运行时容器如std::vector而是一个在编译时就知道其所有成员数量和类型的静态结构。这里有一个关键点参数包在声明时是“未展开”的。你可以把它想象成一个压缩文件.zip在没解压之前你只知道里面有一堆文件但不知道具体是哪些。typename... Ts就是这个压缩包。只有通过“包展开”Pack Expansion这个“解压”操作你才能访问到里面的具体内容一个个独立的typename。语法位置决定含义这是理解可变参数模板的第一个难点模板参数包出现在模板参数列表中如templatetypename... Types。它声明了一个可以接受零个或多个模板类型参数的包。函数参数包出现在函数参数列表中如void foo(Types... args)。它声明了一个可以接受零个或多个函数参数的包。注意args本身是一个包名代表所有参数的集合。一个常见的混淆点是下面这两种声明// v1 是一个普通的std::vector其元素类型是模板参数包Types...展开后的某种组合这通常不合法除非Types只有一个参数。 template typename... Types void func1(std::vectorTypes... v1); // v2 是一个函数参数包它代表了一系列std::vector对象每个vector的元素类型对应Types中的一个。 template typename... Types void func2(std::vectorTypes... v2);func1的意图通常是错误的因为它试图用多个类型Types...去实例化一个单一的std::vector而std::vector只能接受一个元素类型。func2才是正确的它表示func2接受N个参数每个参数都是一个std::vector第i个vector的元素类型是Types包中的第i个类型。2.2 包展开Pack Expansion的规则与上下文包展开是使用参数包的唯一方式。语法就是在参数包名字后面加上...。但...放在哪里展开的结果天差地别。1. 基础展开在函数调用中展开参数包。templatetypename... Args void foo(Args... args) { bar(args...); // 等价于 bar(arg1, arg2, arg3, ...) }这很好理解就是把包里的所有参数按顺序作为单独的参数传递出去。2. 复杂表达式展开这是强大也是容易出错的地方。包展开可以在任何允许逗号分隔列表的地方进行并且会对整个模式pattern进行展开。templatetypename... Args void foo(Args... args) { // 模式std::forwardArgs(args) // 展开std::forwardArg1(arg1), std::forwardArg2(arg2), ... bar(std::forwardArgs(args)...); }这里std::forwardArgs(args)...是一个模式。展开时编译器会分别将Args包和args包中的第一个元素、第二个元素...代入这个模式生成一个由逗号分隔的列表。一个极其重要的注意事项包展开的上下文必须是“包展开上下文”。以下是一些合法的上下文函数参数列表如上面的例子初始化列表Braced-init-listint dummy[] { (process(args), 0)... };利用逗号运算符和初始化列表保证顺序基类列表和成员初始化列表在模板类继承和构造时非常有用。折叠表达式C17这个我们后面会详细讲它提供了更优雅的处理方式。常见的编译错误来源试图在非展开上下文中单独使用参数包。例如你不能直接写std::cout args;因为args是一个包而运算符不知道如何处理一个包。你必须先展开它。2.3 sizeof... 运算符获取参数包的大小sizeof...(pack_name)是一个编译期运算符用于获取模板参数包或函数参数包中参数的数量。它返回一个std::size_t类型的常量表达式。templatetypename... Args void count(Args... args) { constexpr std::size_t num_types sizeof...(Args); constexpr std::size_t num_args sizeof...(args); static_assert(num_types num_args, “Sanity check”); std::cout “Number of arguments: “ num_args ‘\n’; }注意sizeof...(Args)和sizeof...(args)在函数模板中通常是相等的因为它们对应同一个参数包的不同表现形式类型 vs 值。实操心得sizeof...在编写泛型代码时非常有用特别是用于静态断言static_assert来约束参数包的大小或者用于分配编译期已知大小的数组尽管在可变参数模板中直接声明数组可能有点棘手通常结合std::array或std::index_sequence使用。3. 可变参数模板的经典实现模式与疑难解析掌握了基础语法我们来看看实际中怎么用它。可变参数模板最常见的实现模式是递归但这和运行时递归逻辑完全不同。3.1 递归模板函数终止函数与递归函数我们以经典的print函数为例但这次我们深入其编译过程。// 1. 终止函数处理参数包为空的情况 void print() { std::cout std::endl; } // 2. 单参数函数模板处理最后一个参数或只有一个参数的情况 template typename T void print(const T t) { std::cout t std::endl; } // 3. 可变参数函数模板处理一个及以上的参数 template typename First, typename... Rest void print(const First first, const Rest... rest) { std::cout first “, “; print(rest...); // 递归调用注意这里参数包rest被展开 }调用print(1, 2.0, “hello”)时发生了什么编译器首先尝试匹配最特化的版本。print(1, 2.0, “hello”)匹配第三个重载其中First推导为intRest...推导为double, const char()[6]。它输出1,然后调用print(rest...)即print(2.0, “hello”)。print(2.0, “hello”)再次匹配第三个重载First为doubleRest...为const char()[6]。输出2.0,然后调用print(“hello”)。print(“hello”)此时它既可以匹配第二个重载单参数模板也可以匹配第三个重载此时Rest...为空包。重载决议规则在这里至关重要。对于print(T)T推导为const char()[6]。对于print(First, Rest...)First推导为const char()[6]Rest...为空包。两个版本都是可行的。通常非可变参数模板比可变参数模板更特化more specialized。因此编译器会选择第二个重载单参数版本。它输出hello并换行。如果调用print()则直接匹配第一个重载非模板函数只输出换行。疑难问题1为什么需要终止函数在上面的例子中当Rest...为空时print(rest...)实际上变成了print()。如果没有第一个print()重载这个调用就会找不到匹配的函数导致编译错误。因此终止函数或称为基案例是递归模板展开的终点。疑难问题2递归实例化与代码膨胀每次递归调用都会实例化一个新的函数模板。print(1, 2.0, “hello”)会导致实例化printint, double, const char[6]、printdouble, const char[6]和printconst char[6]。虽然编译器会进行优化但在复杂场景下这可能导致编译时间显著增加和二进制文件变大。这是使用可变参数模板递归模式需要权衡的一点。3.2 折叠表达式C17告别递归的利器C17引入的折叠表达式Fold Expressions极大地简化了可变参数模板的操作它可以直接对参数包进行二元运算无需递归。四种折叠形式( pack op ... )一元右折叠。(args ...)等价于arg1 (arg2 (arg3 ...))。( ... op pack )一元左折叠。(... args)等价于((arg1 arg2) arg3) ...。( init op ... op pack )二元右折叠。( pack op ... op init )二元左折叠。用折叠表达式重写print函数templatetypename... Args void print(Args... args) { ( (std::cout std::forwardArgs(args) “ “), ... ) std::endl; }一行搞定( (std::cout args “ “), ... )是一个使用了逗号运算符的一元右折叠。它展开为(std::cout arg1 “ “), (std::cout arg2 “ “), ...。逗号运算符会按顺序执行每个表达式并返回最后一个表达式的值这里我们忽略返回值。折叠表达式不仅代码更简洁而且通常能生成更高效的代码因为它避免了多层函数调用。疑难问题折叠表达式的求值顺序对于二元运算符左折叠和右折叠的求值顺序不同这可能会影响有副作用的操作。例如对于减法templatetypename... Args auto subtract_right(Args... args) { return (args - ...); } // 右折叠1 - (2 - (3 - 4)) -2 templatetypename... Args auto subtract_left(Args... args) { return (... - args); } // 左折叠((1 - 2) - 3) - 4 -8结果完全不同在实现需要特定顺序的聚合操作时必须小心选择折叠方向。3.3 完美转发与可变参数模板这是可变参数模板在库开发中的核心应用之一。目标是创建一个函数将其所有参数原封不动地保持值类别左值/右值const/volatile修饰转发给另一个函数。std::make_shared和std::make_unique就是典型例子。templatetypename T, typename... Args std::unique_ptrT make_unique(Args... args) { return std::unique_ptrT(new T(std::forwardArgs(args)...)); }Args...这是一个转发引用Forwarding Reference包。当Args被推导时Args会根据传入实参的值类别推导出左值引用或右值引用。这是实现完美转发的关键。std::forwardArgs(args)...这是包展开的完美应用。它对每个参数分别调用std::forward确保左值保持左值右值被移动。疑难问题std::forward的误用一个常见的错误是写成std::forwardArgs...(args...)这是非法的。必须对包中的每个元素单独应用std::forward所以正确的模式是std::forwardArgs(args)...。另一个错误是忘记std::forward直接传递args...这会导致右值被当作左值处理失去移动语义的机会。4. 可变参数类模板与继承体系中的高级应用可变参数模板不仅用于函数也用于类这为设计带来了巨大的灵活性。4.1 实现一个简化的 std::tuplestd::tuple是可变参数类模板的典范。其核心思想是递归继承一个存储Head并继承自一个存储Tail...的基类。// 前向声明 templatetypename... Types class Tuple; // 终止特化空元组 template class Tuple {}; // 通用定义递归继承 templatetypename Head, typename... Tail class TupleHead, Tail... : private TupleTail... { public: Tuple(const Head head, const Tail... tail) : TupleTail...(tail...), head_(head) {} // 为了方便这里省略了右值引用版本的构造函数和 get 方法实现 Head get() { return head_; } const Head get() const { return head_; } // 通过基类获取其他元素需要类型转换 templatetypename T T get() { return static_castTupleT, OtherTail...*(this)-get(); // 简化逻辑实际更复杂 } private: Head head_; };这个简化的Tuple通过递归继承将每个元素存储在不同的继承层级中。Tupleint, double, string的继承链是Tupleint, double, string-Tupledouble, string-Tuplestring-Tuple。疑难问题递归继承与空基类优化EBCO这种递归继承模式的一个好处是可能触发空基类优化。如果Tuple是空的通常如此并且编译器支持EBCO那么存储一个空基类不会增加派生类的大小。这使得Tuple的实现可以非常空间高效。然而复杂的继承层次也可能带来访问元素时代价较高的类型转换需要通过static_cast在继承链中向上或向下转换。4.2 混入Mixins与策略模式可变参数模板可以用于实现编译期的混入模式动态组合多个基类的功能。templatetypename... Mixins class Widget : public Mixins... { public: // 构造函数需要转发参数给所有基类 Widget(const Mixins... mixins) : Mixins(mixins)... {} // 可以使用using声明引入基类方法避免歧义 // using Mixins::foo...; // C17 支持包展开在using声明中 }; // 使用 struct Label { void draw() { std::cout “Label\n”; } }; struct Button { void click() { std::cout “Clicked\n”; } }; struct Border { void decorate() { std::cout “Border\n”; } }; WidgetLabel, Button, Border w(Label{}, Button{}, Border{}); // w 拥有 draw(), click(), decorate() 三个方法疑难问题钻石继承与歧义如果混入的多个基类有同名成员就会产生歧义。需要在派生类中显式解决例如通过using声明C17支持在using声明中包展开或通过强制类型转换来指定调用哪个基类的方法。设计混入类时应尽量避免命名冲突。4.3 使用 std::index_sequence 进行编译期迭代有时我们需要按索引访问参数包中的元素或者对参数包执行需要索引的操作。运行时循环不行因为模板参数是编译期的。这时就需要std::index_sequence。假设我们想用一个参数包初始化一个std::array。templatetypename T, typename... Args std::arrayT, sizeof...(Args) make_array(Args... args) { // 我们需要将 args... 展开到 array 的初始化列表中 { args[0], args[1], ... } // 但这需要索引。我们可以创建一个索引序列。 return make_array_impl(std::forwardArgs(args)..., std::make_index_sequencesizeof...(Args){}); } templatetypename T, typename... Args, std::size_t... Is std::arrayT, sizeof...(Args) make_array_impl(Args... args, std::index_sequenceIs...) { // 关键使用折叠表达式和逗号运算符结合索引访问“想象中的”元素。 // 实际上我们需要将参数包捕获到一个元组或引用数组中然后通过索引解引用。 // 更常见的模式是直接构造 return { std::forwardArgs(args)... }; // 其实这里不需要index_sequence也能工作。 // index_sequence 更常用于需要按索引处理的情况例如 // std::arrayT, sizeof...(Args) arr; // ((arr[Is] std::forwardArgs(args)), ...); // 使用折叠表达式和索引赋值 } // 但一个更典型的、需要索引的例子是将元组解包为函数参数。 templatetypename Func, typename Tuple, std::size_t... Is auto apply_impl(Func func, Tuple tup, std::index_sequenceIs...) { // 使用 std::getIs 来获取元组中的第Is个元素 return std::invoke(std::forwardFunc(func), std::getIs(std::forwardTuple(tup))...); } templatetypename Func, typename Tuple auto apply(Func func, Tuple tup) { constexpr auto size std::tuple_size_vstd::decay_tTuple; return apply_impl(std::forwardFunc(func), std::forwardTuple(tup), std::make_index_sequencesize{}); }std::index_sequence0, 1, 2, ...是一个编译期的整数序列。通过模板推导我们将序列展开为Is...然后在包展开中使用std::getIs来按索引访问元组元素。这是实现std::apply等工具的核心技术。疑难问题理解索引序列的生成与传递std::make_index_sequenceN会生成一个std::index_sequence0, 1, 2, ..., N-1。我们通常通过一个辅助函数如make_array_impl或apply_impl来捕获这个序列。编译器会推导出Is...的具体值。在包展开std::getIs...中Is会依次被0,1,2,...替换从而展开为std::get0, std::get1, ...。这个过程完全在编译期完成。5. 实战中高频疑难问题与解决方案实录理论说再多不如看看实际踩过的坑。下面是我在项目中遇到的几个典型问题及其解决思路。5.1 编译错误“包展开中参数包‘Args’没有展开”或“扩展模式‘...’包含未展开的参数包”这是新手最常遇到的错误。根本原因是包展开没有发生在合法的展开上下文中。错误示例1templatetypename... Args void foo(Args... args) { std::cout args; // 错误不能直接输出一个包 }解决方案必须展开包。可以使用折叠表达式C17或递归。// 方案1折叠表达式 (std::cout ... args) ‘\n’; // 一元左折叠 // 方案2递归C11/14 templatetypename T void print(T t) { std::cout t; } templatetypename First, typename... Rest void print(First first, Rest... rest) { std::cout first ‘ ‘; print(rest...); }错误示例2在lambda捕获列表中错误展开templatetypename... Args auto make_lambda(Args... args) { // 意图按值捕获所有参数 return [...args std::forwardArgs(args)]() { /* 使用 args... ? */ }; // 语法错误 }在C20之前lambda不能直接捕获参数包。C20引入了包展开捕获但语法是...args std::forwardArgs(args)且需要在lambda体内也使用包展开语法来使用args...。在C17及之前通常需要将参数包绑定到std::tuple中然后在lambda中捕获这个tuple。解决方案C17及之前templatetypename... Args auto make_lambda(Args... args) { auto tuple std::forward_as_tuple(std::forwardArgs(args)...); return [tuple std::move(tuple)]() { // 使用 std::apply 来使用 tuple 中的参数 std::apply([](auto... items) { ((std::cout items ‘ ‘), ...); }, tuple); }; }5.2 完美转发中的“万能引用”与转发引用混淆问题templatetypename T void foo(T arg)中的T是转发引用但templatetypename... Args void bar(Args... args)中的Args...也是转发引用吗是的它是一个转发引用包。但一个常见的错误是在函数内部需要将参数包存储起来时错误地使用了auto。错误示例templatetypename... Args auto bad_store(Args... args) { // 意图将参数存储到一个容器中比如 vector of variant auto stored_args {std::forwardArgs(args)...}; // 错误初始化列表会丢弃引用和值类别信息。 // stored_args 的类型是 std::initializer_list某种公共类型所有参数都被拷贝/转换了。 }解决方案使用std::tuple来完美转发并存储参数包因为tuple可以保存引用。templatetypename... Args auto good_store(Args... args) { // 使用 std::forward_as_tuple它会推导出引用类型 auto tuple std::forward_as_tuple(std::forwardArgs(args)...); // tuple 的类型是 std::tupleArgs...完美保持了每个参数的值类别。 return tuple; }如果需要长期存储超出当前作用域则需要小心处理引用生命周期问题。通常需要std::decay或std::remove_reference来获取值类型或者使用std::make_shared等分配内存。5.3 处理空参数包的特殊情况可变参数模板允许零个参数。这有时会导致意想不到的问题特别是当你的递归模板没有为空的参数包提供合适的终止条件时。问题场景你想实现一个编译期计算参数包和的函数。templatetypename... Args auto sum(Args... args) { return (args ...); // 使用折叠表达式 }如果调用sum()会发生什么对于一元折叠表达式(args ...)当参数包为空时对于大多数运算符除了,||,,是非法的。编译器会报错。解决方案为空的参数包提供一个显式的默认值或进行特化。// 方案1为折叠表达式提供初始值二元折叠 templatetypename... Args auto sum(Args... args) { return (args ... 0); // 二元左折叠空包时返回0 } // 方案2使用 if constexpr (C17) 在编译期分支 templatetypename... Args auto sum(Args... args) { if constexpr (sizeof...(Args) 0) { return 0; // 或者返回一个默认构造的类型 } else { return (args ...); } } // 方案3递归模板特化C11/14风格 templatetypename T T sum(T t) { return t; } templatetypename First, typename... Rest auto sum(First first, Rest... rest) { return first sum(rest...); } // 但递归模板也需要处理 sum() 的情况可以添加一个重载返回 0但类型不好确定。 // 更安全的是禁止空包使用 static_assert templatetypename... Args auto sum(Args... args) { static_assert(sizeof...(Args) 0, “sum requires at least one argument”); return (args ...); }选择哪种方案取决于你的需求。如果允许空包并希望返回一个合理的默认值如0方案1最简单。如果需要不同的空包逻辑方案2的if constexpr很灵活。5.4 调试可变参数模板解读“恐怖”的编译错误信息GCC或Clang在可变参数模板出错时产生的错误信息可能极其冗长。关键是从最后面开始往前看找到第一个“error:”开头的、你能看懂的部分。通常错误的根源会在模板实例化链的顶端最后报错的地方被揭示。技巧使用static_assert进行编译期检查在模板代码中加入static_assert可以提前、清晰地报出约束错误。templatetypename... Args void my_func(Args... args) { static_assert((std::is_integral_vArgs ...), “All arguments must be integral types!”); // ... }简化重现如果错误信息太复杂尝试创建一个最小的、能重现错误的代码片段。这能帮你隔离问题也方便向他人求助。关注类型推导很多错误源于类型推导失败。检查你的转发引用、const/引用修饰是否正确。使用std::is_same_vdecltype(arg), T之类的类型特征来在编译期打印类型通过static_assert触发错误信息显示类型。使用ConceptC20这是终极解决方案。Concept可以极大地简化模板约束并产生更清晰的错误信息。templatestd::integral... Args // 要求所有Args都是整型 void my_func(Args... args) { /* ... */ }当传入非整型参数时错误信息会直接指出“约束未满足”而不是展示一长串模板实例化过程。6. 性能考量、最佳实践与设计模式6.1 编译期开销与代码膨胀如前所述可变参数模板尤其是递归实现的会导致大量的模板实例化。每个不同的参数类型组合都会生成一份新的代码。这可能导致编译时间变长编译器需要实例化更多模板。二进制体积增大生成的机器码变多尽管链接器会去重一些完全相同的实例化。优化策略使用折叠表达式替代递归折叠表达式通常能生成更简洁、高效的代码减少实例化层次。将通用逻辑抽取为非模板函数如果可变参数模板函数中有一些不依赖于类型参数的公共操作将其抽取到独立的非模板或类型擦除的函数中。谨慎使用constexpr ifif constexpr可以避免实例化不使用的分支有助于控制实例化数量。例如在递归模板中可以用if constexpr (sizeof...(rest) 0)来替代单独的终止函数重载。6.2 类型安全与 SFINAE / Concepts可变参数模板增强了类型安全相比C风格的可变参数但你也可能想对参数包施加约束。C17之前使用SFINAE// 使用 std::enable_if 约束所有参数必须是同一类型 templatetypename T, typename... Args, typename std::enable_if_t(std::is_same_vT, Args ...) void homogeneous_func(T first, Args... rest) { /* ... */ }C20使用Concepts强烈推荐templatetypename T, std::same_asT... Args // 要求所有Args都与T相同 void homogeneous_func(T first, Args... rest) { /* ... */ } templatestd::constructible_fromstd::string... Args // 要求所有Args可转换为std::string void build_strings(Args... args) { /* ... */ }Concepts让意图更清晰错误信息更友好。6.3 设计模式应用工厂、访问者、委托工厂模式Factory可变参数模板完美支持任意参数构造。templatetypename Base, typename... Args class Factory { std::unordered_mapstd::string, std::functionstd::unique_ptrBase(Args...) creators_; public: templatetypename Derived void register(const std::string key) { creators_[key] [](Args... args) { return std::make_uniqueDerived(std::forwardArgs(args)...); }; } std::unique_ptrBase create(const std::string key, Args... args) { return creators_.at(key)(std::forwardArgs(args)...); } };访问者模式Visitor的编译期实现结合std::variant和std::visit可以创建类型安全的、可扩展的访问者而无需修改被访问的类层次结构。可变参数模板用于定义variant可以容纳的类型集合。委托与信号槽实现一个可以连接任意可调用对象的信号系统参数包用于定义信号的参数列表。templatetypename... Args class Signal { std::vectorstd::functionvoid(Args...) slots_; public: templatetypename Callable void connect(Callable cb) { slots_.emplace_back(std::forwardCallable(cb)); } void emit(Args... args) { for (auto slot : slots_) slot(args...); } };6.4 测试可变参数模板代码测试可变参数模板函数时要覆盖边界情况空参数包函数是否能正确处理单参数包递归或折叠逻辑是否在边界处工作正常混合类型测试包含内置类型、自定义类、指针、引用、const等混合参数的情况。移动语义如果函数涉及完美转发测试传入右值是否真的触发了移动构造。SFINAE/Concept约束测试符合约束和不符合约束的情况确保约束正确生效。使用像Google Test这样的框架可以方便地编写参数化测试来覆盖多种类型组合。可变参数模板是C迈向更高层次抽象的关键一步。它初看复杂但一旦理解了参数包、包展开、递归实例化这些核心概念并掌握了折叠表达式、index_sequence等工具你就会发现它能以极其优雅和类型安全的方式解决许多过去需要宏或类型擦除才能解决的问题。从printf到std::make_unique从元组到信号槽其设计哲学始终是将尽可能多的工作类型检查、代码生成交给编译器在编译期就确保正确性从而获得运行时的高效和安全。尽管编译错误信息可能令人望而生畏但结合static_assert、Concepts和逐步调试的方法这些障碍是可以克服的。我个人的经验是在设计和实现通用库、框架组件时可变参数模板几乎是不可或缺的工具而在日常应用开发中合理使用标准库提供的设施如std::make_shared,std::apply即可无需过度追求自己编写复杂的可变参数模板。