8、C++11 核心特性:可变参数模板,让模板函数接受任意数量参数。

说实话,C++98 时代的模板有个挺尴尬的地方——你没法写一个能接受任意数量参数的模板函数。想实现类似 printf 那样的功能?要么重载到吐,要么用 va_list 那套 C 风格的宏,类型安全基本就别想了。

C++11 引入的可变参数模板,说白了就是把这个短板彻底补上了。它允许你定义一种模板,参数个数可以是 0 到任意多个。我个人觉得,这是 C++11 对泛型编程最重要的贡献之一。

核心语法:三个点 (...) 的魔法

可变参数模板的核心符号就是三个点 ...,它出现在两个地方:

  • 模板参数包template<typename... Args>,这里的 Args 就是一个类型参数包,可以绑定零个或多个类型。
  • 函数参数包void func(Args... args),这里的 args 就是函数参数包,可以绑定零个或多个值。

看个最简单的例子:

#include <iostream>

// 基础版本:没有参数时直接结束
void print() {
    std::cout << "空参数调用" << std::endl;
}

// 可变参数模板:一个参数 + 剩余参数包
template<typename T, typename... Args>
void print(const T& first, const Args&... rest) {
    std::cout << first << " ";
    print(rest...);  // 递归展开
}

int main() {
    print(1, 2.5, "hello", 'A');
    // 输出:1 2.5 hello A
    return 0;
}

这里有个关键点:递归展开。每次调用 print(rest...) 时,编译器会把参数包拆成「第一个参数 + 剩余参数包」,直到剩余参数包为空,调用无参的 print() 结束递归。

核心理解:可变参数模板本质上是一种编译期递归。编译器在编译时生成对应参数个数的重载版本,运行时没有任何额外开销。

sizeof... 运算符:获取参数个数

有时候你想知道参数包里到底有多少个参数。C++11 提供了 sizeof... 运算符:

template<typename... Args>
void count_args(Args... args) {
    std::cout << "参数个数: " << sizeof...(Args) << std::endl;
    std::cout << "参数个数(另一种写法): " << sizeof...(args) << std::endl;
}

count_args(1, 2, 3);      // 输出 3
count_args("hello");      // 输出 1
count_args();             // 输出 0

注意 sizeof... 既可以作用于类型参数包,也可以作用于函数参数包,结果是一样的。

包展开的两种模式

我在项目中见过不少新手把包展开搞混。其实就两种模式:

  1. 递归展开:上面 print 的例子就是,通过递归调用逐层剥离参数。
  2. 逗号表达式展开:利用初始化列表或数组,一次性展开所有参数。

第二种方式在某些场景下更简洁:

template<typename... Args>
void print_all(const Args&... args) {
    // 利用初始化列表展开,注意最后的 0 是为了避免空参数包时出错
    int dummy[] = { (std::cout << args << " ", 0)... };
    (void)dummy;  // 消除未使用变量警告
}

print_all(1, 2.5, "hello");  // 输出:1 2.5 hello

这里 (std::cout << args << " ", 0)... 会把每个参数依次展开,逗号表达式返回 0,用来初始化数组。嗯,这个技巧有点 hack 的味道,但确实好用。

折叠表达式(C++17 增强)

虽然这是 C++11 的课程,但我还是想提一句 C++17 的折叠表达式,因为它让可变参数模板的写法更优雅了。C++11 时代我们得写递归或逗号表达式,C++17 可以直接这样:

// C++17 一元右折叠
template<typename... Args>
auto sum(Args... args) {
    return (args + ...);  // 展开为 ((a + b) + c) + ...
}

// C++17 一元左折叠
template<typename... Args>
void print_all_cpp17(Args... args) {
    (std::cout << ... << args) << std::endl;  // 左折叠
}

如果你现在可以用 C++17,建议优先用折叠表达式,代码可读性高很多。

实战:实现一个类型安全的日志函数

我曾经在一个嵌入式项目中需要写一个日志系统,要求类型安全、支持任意参数、并且能自动添加时间戳。用可变参数模板实现起来很干净:

#include <iostream>
#include <sstream>
#include <chrono>
#include <iomanip>

// 基础函数:处理最后一个参数
void log_impl(std::ostringstream& oss) {
    // 什么也不做,只是结束递归
}

template<typename T, typename... Args>
void log_impl(std::ostringstream& oss, const T& value, const Args&... rest) {
    oss << value << " ";
    log_impl(oss, rest...);
}

template<typename... Args>
void log(const Args&... args) {
    std::ostringstream oss;
    
    // 添加时间戳
    auto now = std::chrono::system_clock::now();
    auto time_t = std::chrono::system_clock::to_time_t(now);
    oss << "[" << std::put_time(std::localtime(&time_t), "%H:%M:%S") << "] ";
    
    log_impl(oss, args...);
    std::cout << oss.str() << std::endl;
}

// 使用示例
log("温度:", 25.5, "°C", "湿度:", 60, "%");
// 输出:[14:30:25] 温度: 25.5 °C 湿度: 60 %

避坑指南:我曾经在递归展开时忘记写基础版本(无参版本),导致编译器报一堆看不懂的错误。记住,递归展开必须有一个终止条件,要么是空参数的重载,要么用 SFINAE 做特化。

可变参数模板的应用场景

除了日志系统,可变参数模板在以下几个地方特别有用:

场景 说明 典型例子
工厂函数 创建对象时传递任意参数给构造函数 make_shared<T>(args...)
委托/信号槽 回调函数支持任意参数 Qt 的信号槽机制
元组实现 std::tuple 的核心就是可变参数模板 std::tuple<int, double, string>
访问者模式 对多个不同类型对象执行同一操作 std::visit
参数转发 完美转发到其他函数 std::forward<Args>(args)...

可变参数模板与完美转发的结合

这是 C++11 里非常经典的一个组合。你想想看,如果既要支持任意参数个数,又要保持参数的左值/右值属性,怎么办?

template<typename T, typename... Args>
std::unique_ptr<T> make_unique(Args&&... args) {
    return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}

// 使用
auto ptr = make_unique<std::string>(5, 'A');  // 创建 "AAAAA"

这里 Args&&... 是转发引用包,std::forward<Args>(args)... 是带转发的包展开。每个参数都会保持它传入时的值类别——左值保持左值,右值保持右值。

注意:不要写成 std::forward<Args...>(args...),这是错误的。正确的写法是 std::forward<Args>(args)...,每个参数单独转发。

可变参数模板的局限性

没有银弹。可变参数模板也有几个让我头疼过的地方:

  • 编译错误信息难读:一旦写错,编译器能吐出一屏的模板实例化信息,找 bug 全靠眼力。
  • 不能直接遍历参数包:没法用 for 循环遍历,必须用递归或展开技巧。
  • 参数包必须在参数列表末尾template<typename... Args, typename T> 这种写法是不允许的。
  • 不能对参数包做重载:你不能写两个只有参数包不同的重载函数。

知识结构图

下面这张图梳理了可变参数模板的核心知识点和它们之间的关系:

可变参数模板知识体系 可变参数模板 语法:typename... Args / Args... args 展开方式:递归展开 / 逗号表达式展开 sizeof... 获取参数个数 应用场景 工厂函数 | 委托/信号槽 | 元组实现 | 访问者模式 | 完美转发 C++17 增强 折叠表达式 (args + ...)

总结

可变参数模板是 C++11 给模板编程注入的一剂强心针。它让 C++ 的泛型能力真正做到了「参数个数无关」。我个人觉得,掌握好递归展开和包展开的写法,再配合完美转发,基本就能应对 90% 的场景了。

嗯,最后提醒一句:别为了炫技而滥用。如果你的参数个数是固定的(比如最多 3 个),用重载或者默认参数可能更清晰。可变参数模板最适合那些参数个数确实不确定的场景。

一句话总结:可变参数模板 = 编译期递归 + 类型安全 + 零运行时开销。它是现代 C++ 泛型编程的基石之一。


公众号:蓝海资料掘金营,微信deep3321