前言
标题里的「变量参数」在中文技术语境中通常指参数个数可变,也就是可变参数(variadic arguments)。它和「默认参数」(default arguments)是两套完全不同的机制:前者让一个函数接受任意多个实参,后者让调用方在省略实参时自动补上一个值。二者又经常一起出现在日志、格式化输出、构造函数这类接口里,所以放在一篇里对照讲是合理的。
初学者在这里容易踩三类坑。第一,把 C 风格的...当成万能工具,结果传错一个类型就得到随机值——va_arg完全不做类型检查,类型不匹配是未定义行为(undefined behavior, UB)。第二,以为默认参数会在函数定义处「保存」一个值,实际上默认实参是在调用点每次求值的表达式,写一个昂贵的默认值会带来意想不到的开销。第三,以为虚函数的默认参数会跟着动态类型走——默认参数是静态绑定的,看的是指针或引用的静态类型,这一点和虚调用恰好相反。
本文先讲默认参数的语法规则和求值语义,再讲 C 风格可变参数的正确用法与局限,最后给出 C++11 变参模板的现代写法,示例以 C++17 为基准。
一、默认参数的规则
1. 只能从右往左省略
默认参数必须放在参数列表的末尾。一旦某个参数有了默认值,它右边的所有参数都必须有默认值。下面的写法无法通过编译:
// 适用于 C++17
void bad(int a = 1, int b); // 错误:a 有默认值,b 没有
void good(int a, int b = 2); // 正确
void also_good(int a = 1, int b = 2);原因是调用时的实参按位置匹配。如果允许bad(, 3)这种写法,语法上无法表达;C++ 没有命名实参,所以只能靠「尾部省略」来消除歧义。
2. 声明与定义的分工
默认实参通常写在声明里,定义处不再重复。同一个作用域内对同一个参数重复指定默认值是错误的;在不同作用域(比如头文件的声明和源文件的定义)重复写,也容易引发重定义错误。
// 适用于 C++17
// widget.h
void render(int width, int height = 600, bool vsync = true);
// widget.cpp
void render(int width, int height, bool vsync) {
// 定义处不要再写 = 600 / = true
}默认实参可以在后续声明中追加,但不能修改已经生效的值。比如先声明void f(int a);,再声明void f(int a = 3);是允许的(在后者可见的调用点生效);反过来先有默认值再改成另一个值则不允许。
3. 调用点求值
默认实参不是「编译期常量」,而是调用发生时被求值的表达式,每次调用都会重新计算。因此默认值里可以写函数调用、new、字符串构造,也可以引用全局变量,但不能引用局部变量、非静态数据成员或this。
// 适用于 C++17
#include <string>
std::string default_name() {
return "anonymous";
}
void greet(const std::string& name = default_name()) {
// 每次不传实参的调用都会执行一次 default_name()
}
struct Widget {
int width_ = 10;
// void resize(int w = width_); // 错误:默认实参不能用非静态成员
void resize(int w = 10); // 正确:用字面量或静态成员
};最后一条限制常被忽略:默认实参的求值环境在类作用域之外,没有隐式的this,所以不能直接写成员名。需要「按当前成员取默认值」时,只能用重载:提供一个无参版本,在函数体里读取成员。
4. 默认参数不属于函数类型
函数类型只由返回类型和参数类型构成,默认实参不参与。把函数赋给函数指针后,通过指针调用必须提供全部实参。
// 适用于 C++17
void show(int a, int b = 5);
void (*fp)(int, int) = &show; // 类型是 void(int, int)
// fp(1); // 错误:通过指针调用不能用默认值
fp(1, 2); // 正确5. 虚函数:默认参数静态绑定
这是默认参数最容易出错的地方。虚函数决定「调用哪个函数体」是动态的,但默认实参由静态类型决定。派生类可以声明不同的默认值,于是同一个调用可能进入派生类的函数体,却使用基类声明的默认值。
// 适用于 C++17
#include <iostream>
struct Base {
virtual ~Base() = default;
virtual void draw(int size = 10) const {
std::cout << "Base " << size << '\n';
}
};
struct Derived : Base {
void draw(int size = 99) const override {
std::cout << "Derived " << size << '\n';
}
};
void call(const Base& b) {
b.draw(); // 静态类型是 Base,默认值取 10;函数体是 Derived::draw
}call里b的静态类型是const Base&,编译器按Base::draw的声明补上10,再虚调用到Derived::draw。所以派生类里写的99在这个调用路径上永远不会生效。正确的做法是:默认值只在基类声明一次,派生类不要重复指定,避免读者误解。
二、C 风格可变参数
C++ 继承了 C 的...机制,头文件是<cstdarg>。核心是四个东西:va_list、va_start、va_arg、va_end。
// 适用于 C++17
#include <cstdarg>
#include <iostream>
int sum_ints(int count, ...) {
va_list ap;
va_start(ap, count); // 最后一个具名参数
int total = 0;
for (int i = 0; i < count; ++i) {
total += va_arg(ap, int); // 每次取一个 int
}
va_end(ap); // 必须配对
return total;
}
int main() {
std::cout << sum_ints(4, 1, 2, 3, 4) << '\n';
return 0;
}这套机制有几个硬性约束:
- 必须知道参数个数或结束标记。
va_arg不知道还剩几个参数,函数要么额外传一个count,要么用哨兵值(比如最后一个传nullptr),要么靠格式串里的%d数量推算。 - 类型必须精确匹配。
va_arg(ap, int)要求实参经过默认实参提升后就是int。传char、short会被提升为int,用va_arg(ap, char)取就是未定义行为;传float会被提升为double,用va_arg(ap, float)取也是 UB。 - 不要传类类型。带有非平凡拷贝构造或析构函数的类型通过
...传递,在标准层面是条件支持的,跨编译器行为不一致,应当避免。字符串请传const char*,或者干脆改用变参模板。 va_start/va_end必须配对,且va_end之后不能再使用该va_list。把va_list传给另一个函数后,是否还能继续使用取决于平台(有些平台传递后原对象失效),需要va_copy时要用va_copy复制。
C 风格...的唯一优势是能和 C 接口、printf家族对接;在纯 C++ 代码里,它几乎没有理由出现在新代码中。
三、C++11 变参模板
变参模板(variadic templates)在编译期展开参数包,类型安全、支持完美转发,是 C++ 里做可变参数的正道。C++17 的折叠表达式(fold expression)又让展开写法短了很多。
// 适用于 C++17
#include <iostream>
#include <sstream>
#include <string>
#include <utility>
template <class... Args>
void log_line(const std::string& tag, Args&&... args) {
std::ostringstream os;
os << '[' << tag << ']';
((os << ' ' << std::forward<Args>(args)), ...); // 逗号折叠
std::cout << os.str() << '\n';
}
int main() {
log_line("info", "user", 42, 3.5, std::string("ok"));
log_line("empty");
return 0;
}要点如下:
Args&&...是转发引用包,配合std::forward<Args>(args)...保持实参的值类别;传左值时不会多做一次拷贝,传右值时可以移动。sizeof...(Args)可以在编译期得到参数个数,常用来做static_assert或选择重载。- 逗号折叠
((expr), ...)依次对每个参数求值,适合「逐个输出」这类场景;(+ ... + args)这类一元折叠适合求和。 - 参数包为空时,逗号折叠的结果是
void(),上面的log_line("empty")仍然合法。
如果参数类型相同,还可以用std::initializer_list,写法更简单,但它要求所有元素能转换为同一类型,且不支持完美转发:
// 适用于 C++17
#include <initializer_list>
int sum(std::initializer_list<int> values) {
int total = 0;
for (int v : values) {
total += v;
}
return total;
}
// 调用:sum({1, 2, 3})四、把两者放在一起:一个带默认级别的日志函数
下面的例子把默认参数和变参模板组合起来。级别有默认值,消息部分任意数量、任意类型。
// 适用于 C++17
#include <iostream>
#include <sstream>
#include <string>
#include <utility>
enum class Level { debug, info, warning, error };
const char* to_string(Level level) {
switch (level) {
case Level::debug: return "DEBUG";
case Level::info: return "INFO";
case Level::warning: return "WARN";
case Level::error: return "ERROR";
}
return "UNKNOWN";
}
template <class... Args>
void log(Level level, const std::string& message, Args&&... args) {
std::ostringstream os;
os << '[' << to_string(level) << "] " << message;
((os << ' ' << std::forward<Args>(args)), ...);
std::cout << os.str() << '\n';
}
// 默认参数放在最后:调用方可以只传 message
template <class... Args>
void log_info(const std::string& message, Args&&... args) {
log(Level::info, message, std::forward<Args>(args)...);
}
int main() {
log_info("server started");
log_info("listening on", "127.0.0.1", 8080);
log(Level::error, "open failed", "code", -1);
return 0;
}注意这里没有用「默认参数 + 变参」直接混写。C++ 允许void f(int a = 1, ...)这种签名,但可变参数部分无法获得默认值,实际意义有限;更清晰的做法是像上面这样,用一个固定参数的转发函数表达默认行为。to_string的switch覆盖了全部枚举值,末尾的return "UNKNOWN"是为了让编译器不报警告,逻辑上不可达。
常见坑点
- ❌
void f(int a = 1, int b);把有默认值的参数放在前面。
✅ 默认参数一律靠右:void f(int a, int b = 1);。
- ❌ 在头文件和源文件里各写一遍默认值,链接或编译报重定义。
✅ 默认值只写在声明处,定义处省略。
- ❌ 在类成员函数里写
void f(int x = member_);。
✅ 默认实参不能使用非静态成员;改为重载一个无参版本,在函数体内读取成员。
- ❌ 以为派生类重写虚函数时改默认值会生效。
✅ 默认参数按静态类型绑定;只在基类声明一次,或干脆不用默认参数而显式传值。
- ❌
va_arg(ap, char)或va_arg(ap, float)。
✅ 使用提升后的类型:int、double;更推荐直接用变参模板。
- ❌ 用
...传std::string对象,再在函数里按值取回。
✅ 传const char*或std::string_view;类类型不要穿过 C 风格可变参数。
- ❌ 默认值写成开销很大的表达式却没意识到每次调用都会执行。
✅ 记住默认实参在调用点求值;昂贵初始化改为函数内惰性计算或重载。
- ❌ 通过函数指针调用时省略实参,指望默认值补上。
✅ 默认实参不属于函数类型,通过指针调用必须给全实参。
总结
| 特性 | 默认参数 | C 风格... | 变参模板 |
|---|
| 引入版本 | C++98 | 继承自 C | C++11 |
| 类型安全 | 是 | 否,类型不匹配是 UB | 是 |
| 参数个数 | 固定,尾部可省 | 任意,需自行计数 | 任意 |
| 求值时机 | 调用点,每次求值 | 调用点 | 编译期展开 |
| 与虚函数 | 静态绑定,易误解 | 不适用 | 不适用 |
| 推荐场景 | 可选配置项 | 对接 C API | 新代码的通用可变参数 |
默认参数的关键词是「静态」:静态位置、静态类型、调用点求值。可变参数的关键词是「类型」:C 风格那条路完全不检查类型,能不用就不用;变参模板把检查提前到编译期,再配合折叠表达式,几乎可以覆盖所有需要「任意多个参数」的场合。把这两套机制分清楚,就不会再把...当成省事的万能入口。