☰
实例讲解在C++的函数中变量参数及默认参数的使用
2026/10/22 7:42:38 网站建设 项目流程

前言


标题里的「变量参数」在中文技术语境中通常指参数个数可变,也就是可变参数(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"是为了让编译器不报警告,逻辑上不可达。


常见坑点



  1. ❌void f(int a = 1, int b);把有默认值的参数放在前面。


✅ 默认参数一律靠右:void f(int a, int b = 1);。



  1. ❌ 在头文件和源文件里各写一遍默认值,链接或编译报重定义。


✅ 默认值只写在声明处,定义处省略。



  1. ❌ 在类成员函数里写void f(int x = member_);。


✅ 默认实参不能使用非静态成员;改为重载一个无参版本,在函数体内读取成员。



  1. ❌ 以为派生类重写虚函数时改默认值会生效。


✅ 默认参数按静态类型绑定;只在基类声明一次,或干脆不用默认参数而显式传值。



  1. ❌va_arg(ap, char)或va_arg(ap, float)。


✅ 使用提升后的类型:int、double;更推荐直接用变参模板。



  1. ❌ 用...传std::string对象,再在函数里按值取回。


✅ 传const char*或std::string_view;类类型不要穿过 C 风格可变参数。



  1. ❌ 默认值写成开销很大的表达式却没意识到每次调用都会执行。


✅ 记住默认实参在调用点求值;昂贵初始化改为函数内惰性计算或重载。



  1. ❌ 通过函数指针调用时省略实参,指望默认值补上。


✅ 默认实参不属于函数类型,通过指针调用必须给全实参。


总结




特性默认参数C 风格...变参模板



引入版本C++98继承自 CC++11

类型安全是否,类型不匹配是 UB是

参数个数固定,尾部可省任意,需自行计数任意

求值时机调用点,每次求值调用点编译期展开

与虚函数静态绑定,易误解不适用不适用

推荐场景可选配置项对接 C API新代码的通用可变参数



默认参数的关键词是「静态」:静态位置、静态类型、调用点求值。可变参数的关键词是「类型」:C 风格那条路完全不检查类型,能不用就不用;变参模板把检查提前到编译期,再配合折叠表达式,几乎可以覆盖所有需要「任意多个参数」的场合。把这两套机制分清楚,就不会再把...当成省事的万能入口。




需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询