前言
缺省参数(default argument,也叫默认实参)和函数重载(function overloading)经常被放在一起讲,因为它们在调用形式上很像:都允许「少写几个参数」。但它们是两套完全不同的机制。缺省参数是编译器在调用点补上实参;函数重载是同名函数有多个候选,编译器按实参类型挑选最合适的一个。把两者混为一谈,就会写出「明明有默认值却报参数不足」「加了一个默认参数反而调用有歧义」之类的代码。
常见的误解包括:认为默认值属于函数类型的一部分(实际上不是,通过函数指针调用时默认值不生效);认为可以在任意位置给参数加默认值(只能从右往左连续给);认为返回值类型不同就能构成重载(不能,返回值不参与重载决议);认为虚函数的默认参数会随动态类型变化(默认参数按静态类型确定,函数体才按动态类型分派)。
本文分三部分:先讲缺省参数的语法规则与求值时机,再讲重载决议的筛选与排序过程,最后讨论两者组合时的歧义与虚函数陷阱。代码以 C++17 为基准,本机没有 C++ 工具链,所有示例只做标准语义推演。
一、缺省参数的规则与求值时机
1.1 只能从右往左连续声明
一旦某个参数有了默认值,它右边的所有参数都必须有默认值:
// 适用于 C++17
void draw(int x, int y = 0, int color = 0); // 正确
// void draw(int x = 0, int y, int color = 0); // 错误:y 没有默认值原因很直接:调用draw(1, 2)时,编译器按位置从左往右填实参,无法跳过中间的y去给color赋值。因此「中间空一个、后面再给默认值」在 C++ 里不成立。
1.2 默认值只能指定一次
同一个作用域内,同一个参数不能重复指定默认值:
// 适用于 C++17
void log(const char* msg, int level = 1); // 声明中给出
void log(const char* msg, int level) { // 定义中不再重复
// ...
}如果定义处再写一遍level = 1,编译器会报「重定义默认参数」。反过来,如果函数定义出现在调用之前,也可以只在定义处给默认值,不需要单独的声明。默认值只在声明可见的作用域内生效,因此带默认值的声明应放在所有调用点都能看到的头文件中,不要依赖零散的局部声明。
1.3 默认值在调用点每次求值
默认参数不是「函数定义时算好一个常量存起来」,而是每次调用时重新求值。这意味着默认值可以依赖运行期状态:
// 适用于 C++17
#include <iostream>
#include <string>
int next_id() {
static int id = 0;
return ++id;
}
void write_log(const std::string& message, int id = next_id()) {
std::cout << id << ": " << message << '\n';
}
int main() {
write_log("first"); // 使用 next_id() 的返回值
write_log("second"); // 再次调用 next_id()
}两次调用会得到不同的编号,因为默认实参表达式在每次调用时都会执行。反过来,不要在默认参数里做有副作用且难以预期的操作,也不要在默认参数里使用局部变量——默认实参在声明处解析,那里的局部变量不可见。
1.4 默认值不属于函数类型
函数的类型只由返回类型和参数类型构成,默认值不参与。因此通过函数指针调用时,必须把参数补全:
// 适用于 C++17
void greet(int times = 1);
void (*fp)(int) = &greet;
// fp(); // 错误:函数指针类型里没有默认值
fp(3); // 正确这一点在回调、std::function、以及把函数传给算法时经常被忽略。std::function<void(int)>同样不会记住默认值。
1.5 虚函数与默认参数:静态绑定
默认参数在编译期根据指针或引用的静态类型确定,而函数体在运行期根据动态类型分派。两者组合会产生看起来很怪的结果:
// 适用于 C++17
#include <iostream>
struct Base {
virtual void show(int value = 1) const {
std::cout << "Base " << value << '\n';
}
virtual ~Base() = default;
};
struct Derived : Base {
void show(int value = 2) const override {
std::cout << "Derived " << value << '\n';
}
};
void call(const Base& b) {
b.show(); // 默认值取自 Base:1;函数体是 Derived::show
}
int main() {
Derived d;
call(d); // 按标准语义输出 "Derived 1"
}很多人期望输出Derived 2,实际传给Derived::show的是1。因此不要在虚函数的不同层级给出不同的默认值;更稳妥的做法是只保留一个不带默认参数的虚函数接口,另设一个非虚的便捷函数提供默认值。
二、重载决议:候选、可行、最优
2.1 三个步骤
调用f(a, b)时,编译器大致做三件事:
- 名字查找:在可见的作用域里找到所有名为
f的函数,构成候选集。派生类中的同名函数会隐藏基类的重载,需要using Base::f;才能把基类版本引入候选集。 - 可行性筛选:参数个数匹配(考虑默认参数和省略号),且每个实参都能转换为对应形参类型,该函数才是可行候选。
- 排序选优:对可行候选比较每个实参的隐式转换序列,选出「不差于其他所有候选、且至少有一项更优」的那个;如果不存在唯一最优,就是歧义(ambiguous),编译报错。
2.2 转换序列的等级
排序的核心是隐式转换序列的等级,从好到差大致如下:
| 等级 | 含义 | 例子 |
|---|
| 精确匹配 | 无需转换或仅做资格转换、左值到右值等 | int到int,int到const int& |
| 提升 | 整型提升、浮点提升 | char到int,float到double |
| 转换 | 整型转换、浮点转换、指针转换等 | int到double,int到long |
| 用户定义转换 | 经过构造函数或转换运算符 | 自定义类型到std::string |
| 省略号 | 匹配... | 最低优先级 |
注意char到int是提升,char到double是转换,所以下面调用会选中show(int):
// 适用于 C++17
void show(int);
void show(double);
int main() {
show('a'); // char -> int 是提升,优于 char -> double 的转换
}2.3 返回值不参与重载
仅返回值类型不同的两个函数不是重载,而是重复声明:
// 适用于 C++17
int parse(int);
// double parse(int); // 错误:无法仅靠返回类型区分调用点通常不提供足够信息来区分返回类型,因此语言直接禁止。若确实需要不同结果,应改用不同的函数名、输出参数,或返回一个能表达多种结果的类型。
2.4 引用限定与 const 重载
成员函数可以按const以及引用限定符(&、&&)重载,这在实现「左值返回拷贝、右值返回移动」时很有用:
// 适用于 C++17
#include <string>
#include <utility>
class Text {
public:
const std::string& value() const& { return data_; }
std::string value() && { return std::move(data_); }
private:
std::string data_;
};对左值对象调用value()返回引用,对右值对象调用则把内部字符串移出。注意被移动后的对象处于「有效但未指定」状态,只能销毁或重新赋值。
2.5 nullptr、0 与 NULL
0的类型是int,nullptr的类型是std::nullptr_t。当同时存在f(int)和f(void*)时:
// 适用于 C++17
void pick(int);
void pick(void*);
int main() {
pick(0); // 精确匹配 int,选中 pick(int)
pick(nullptr); // 选中 pick(void*)
// pick(NULL); // NULL 的实现定义,可能选中任一版本,应避免
}NULL在 C++ 中通常被定义为0或0L,具体由实现决定,因此重载场景下应一律使用nullptr(C++11 起)。
三、两者组合时的歧义
缺省参数会让原本「参数个数不同」的函数变得可以接受同样的调用,从而制造歧义:
// 适用于 C++17
void open(int fd);
void open(int fd, int flags = 0);
int main() {
// open(3); // 错误:两个候选都可行,且转换序列相同,产生歧义
open(3, 1); // 明确调用双参数版本
}第一个候选只需要一个实参,第二个候选靠默认参数补齐第二个实参,两者的第一参数都是精确匹配,编译器无法判断谁更优。解决办法是去掉其中一个默认值、改名,或者显式传参。
类似地,默认参数还可能「劫持」原本想调用的重载:
// 适用于 C++17
void print(long value);
void print(int value, int base = 10);
int main() {
print(42); // 第一参数 int 精确匹配,选中双参数版本
}这里print(int, int = 10)的第一参数是精确匹配,优于print(long)所需的整型转换,因此最终调用的是双参数版本。这类代码在阅读时非常容易误判,项目规范里应限制「默认参数 + 重载」的混用。
另外,函数模板与普通函数同时存在时,如果两者在重载决议中同样好,普通函数优先;但如果模板能给出更精确的匹配(例如T&&完美转发),模板也可能胜出。写库代码时应通过static_assert或约束把意图表达清楚,而不是依赖微妙的排序规则。
常见坑点
- ❌ 在参数列表中间给默认值,后面的参数不给。
✅ 默认值必须从右往左连续排列,例如void f(int a, int b = 0, int c = 0);。
- ❌ 在声明和定义里重复写默认值。
✅ 只在一处写(通常是头文件声明),定义处省略默认值。
- ❌ 以为默认参数会存进函数指针或
std::function。
✅ 默认值不属于函数类型,通过指针或std::function调用时必须传全参数。
- ❌ 在基类和派生类的虚函数上写不同的默认值。
✅ 默认值按静态类型确定,函数体按动态类型分派;应避免这种组合,改用非虚便捷函数。
- ❌ 同时提供
f(int)和f(int, int = 0),然后调用f(1)。
✅ 两者转换序列相同会产生歧义;删除其中一个默认值或改用不同函数名。
- ❌ 认为返回值类型不同可以构成重载。
✅ 返回值不参与重载决议,仅返回值不同的声明是重复声明。
- ❌ 用
NULL在f(int)与f(void*)之间做选择。
✅ 使用nullptr(C++11),它的类型明确,能稳定选中指针版本。
- ❌ 在派生类里定义同名函数后,以为基类的其他重载仍然可见。
✅ 派生类的同名函数会隐藏基类所有重载,需要using Base::f;引入。
总结
| 要点 | 缺省参数 | 函数重载 |
|---|
| 本质 | 调用点补齐实参 | 同名多候选,按类型选优 |
| 位置要求 | 只能从右往左连续 | 参数列表可任意不同 |
| 是否属于函数类型 | 否 | 是(重载构成不同的函数类型) |
| 返回值 | 无关 | 不参与重载决议 |
| 与虚函数 | 静态类型决定默认值 | 动态类型决定函数体 |
| 常见问题 | 重复定义、歧义、指针失效 | 歧义、隐藏、意外转换 |
缺省参数适合表达「大多数调用使用同一个合理值」的接口,重载适合表达「同一操作接受不同输入形式」。两者都能减少调用方的样板代码,但混用时必须警惕歧义和虚函数的静态绑定。把默认值集中在声明处、避免在重载族里随意添加默认参数,是保持接口可预测的两条基本规则。