前言
「默认参数」和「函数模板」是两个独立但经常一起出现的语言特性。默认参数让调用方省略尾部实参,函数模板让同一段逻辑适配不同类型。把两者放在一起时,边界规则会变得微妙:默认的函数实参不参与模板实参推导,默认的模板实参则可以补齐无法推导的类型;默认实参不属于函数类型,通过函数指针调用时不会生效;虚函数的默认实参按静态类型选取,而不是按动态类型。
常见的误解包括:写template<class T> void f(T x = 0);之后调用f(),以为T会从0推导出来——实际上推导失败,因为默认函数实参不用于推导;以为给虚函数在派生类里换一个默认值就能多态生效——实际上调用点看的是基类声明的默认值;以为默认实参在编译期被「贴」到调用点后就和声明无关——实际上名字在声明点查找,值在调用点求值。
本文按「默认函数实参的规则 → 与重载/函数类型/虚函数的交互 → 默认模板实参 → 两者组合的工程写法」展开,代码以 C++17 为基准,涉及 C++11 才有的规则会明确标注。
一、默认函数实参的基本规则
默认实参必须出现在参数列表的尾部:一旦某个参数有默认值,它后面所有没有参数包的位置都必须也有默认值。void f(int a = 1, int b);不合法。参数包(variadic template)后面不能再跟普通参数,因此默认实参与参数包的组合要格外小心。
默认实参在每次调用时求值,而不是在声明时求值一次:
// 适用于 C++17
int next_id();
void emit(int id = next_id()); // 每次调用 emit() 都会调用 next_id()如果默认表达式有副作用(分配资源、修改全局状态、抛异常),这些副作用会在调用点发生。默认实参甚至可以是throw表达式,用来强制某个重载必须显式传参,这是合法但少见的技巧。
默认实参中的名字在声明点做名字查找,而不是调用点。也就是说,声明之前必须能看到所用的函数、变量或类型;声明之后新加的名字不会被默认实参看到。但值的读取发生在调用点:如果默认实参引用一个全局变量,调用时读到的是当时的变量值,而不是声明时的快照。
还有一条容易和模板规则混淆的限制:默认函数实参不能使用本函数的形参。即使前面的形参在名字查找中可见,标准也明确禁止这种写法;而默认模板实参可以引用它前面的模板参数(如U = T)。两者不要混为一谈。
同一个作用域内,默认实参不能重复指定。可以先声明不带默认值,再在后续声明中补上:
// 适用于 C++17
void connect(int timeout);
void connect(int timeout = 1000); // 合法:首次给出默认值
// void connect(int timeout = 2000); // 错误:重复指定这条规则对头文件很重要:默认值只应出现在一处(通常是头文件里的声明),源文件里的定义不要再写一遍,否则会报重定义默认实参。
默认实参不是函数类型的一部分。void f(int, int = 0)的类型是void(int, int),不是void(int)。这意味着函数指针、std::function、虚函数表都不携带默认值信息。
二、与重载、函数指针、虚函数的交互
重载决议先选出函数,再填入默认实参。默认实参不改变候选函数的签名,因此不能靠「不同的默认值」制造两个重载——它们会被视为同一个函数的重复声明。反过来,默认实参可以让一个函数在调用形式上「看起来」支持更少的参数,从而参与重载竞争,但这只是调用形式,不改变签名。
函数指针调用时不能使用默认实参:
// 适用于 C++17
void send(int code, int retry = 3);
void (*p)(int, int) = &send; // 正确:完整签名是两个参数
// void (*q)(int) = &send; // 错误:函数类型不匹配
// p(1); // 错误:通过指针调用必须给足实参
p(1, 3); // 正确如果确实需要「少一个参数」的调用形式,应写一个转发函数或 lambda,而不是依赖指针保留默认值。
虚函数与默认实参是最容易出错的地方。默认实参是静态绑定的:调用表达式按静态类型选择默认值,函数体按动态类型分派。派生类可以声明不同的默认值,但通过基类指针调用时用的仍是基类的默认值。
// 适用于 C++17
#include <iostream>
struct Base {
virtual ~Base() = default;
virtual void log(int level = 1) const {
std::cout << "Base level " << level << '\n';
}
};
struct Derived : Base {
void log(int level = 2) const override {
std::cout << "Derived level " << level << '\n';
}
};
void call(const Base& b) {
b.log(); // 默认值取自 Base 的声明,即 1;函数体是 Derived 的
}call里b.log()实际执行Derived::log,但level是Base声明的默认值 1。这种「函数体是派生类、参数是基类默认值」的组合几乎总是 bug。工程规范通常是:虚函数不要用默认实参;需要默认行为时,写一个非虚的公开接口带默认参数,内部转调一个不同名的纯虚函数。
// 适用于 C++17
struct Handler {
virtual ~Handler() = default;
void log() const { log_impl(1); } // 非虚接口,默认值只此一处
private:
virtual void log_impl(int level) const = 0;
};这种「非虚接口(NVI)」写法把默认值的决策固定在基类,派生类只实现带完整参数的核心逻辑,避免了静态/动态不一致。
三、默认模板实参
函数模板的默认模板实参与默认函数实参是两回事。默认模板实参自 C++11 起才允许用于函数模板(类模板从 C++98 就有)。它用于补齐无法从函数实参推导的类型参数:
// 适用于 C++17
#include <vector>
template <typename T, typename U = T>
U accumulate_basic(const std::vector<T>& values, U init = U{}) {
U acc = init;
for (const auto& v : values) {
acc += static_cast<U>(v);
}
return acc;
}
void demo() {
std::vector<int> v{1, 2, 3};
int a = accumulate_basic(v); // T=int, U 默认 int
double b = accumulate_basic(v, 0.0); // T=int, U 由第二实参推导为 double
(void)a;
(void)b;
}规则是:模板参数列表里,一旦某个参数有默认值,它后面的参数都必须有默认值,参数包除外。默认模板实参可以引用它前面已声明的模板参数(如U = T),不能引用后面的。
默认模板实参在后续声明中可以补充,但不能重复指定;显式特化里不能再写默认实参,标准禁止在显式特化中指定默认实参。如果确实需要为特化提供不同行为,应通过重载或if constexpr分支处理。
一个高频用法是用默认模板参数做 SFINAE 约束:
// 适用于 C++17
#include <type_traits>
template <typename T, typename = std::enable_if_t<std::is_integral_v<T>>>
T twice(T value) {
return static_cast<T>(value + value);
}
// twice(1.5); // 替换失败,该模板从候选集中移除这里第二个模板参数没有名字,只用来承载约束。要注意:如果写两个仅默认实参不同的同名模板,它们会被视为同一模板的重复声明,而不是两个重载;需要两个互斥重载时,应让模板参数列表真正不同(例如用std::enable_if_t<条件, int> = 0这样的非类型参数),或者直接用 C++17 的if constexpr在函数体内分支。C++20 起可以用 concepts 写得更直观,但需要 C++20 及以上,且各编译器支持程度不同。
四、组合使用时的完整示例
下面把「默认模板实参 + 默认函数实参 + 重载」放在一个可运行的最小场景里:一个把容器元素转成字符串并以分隔符连接的函数。默认模板参数决定返回字符串类型,默认函数参数决定分隔符。
// 适用于 C++17
#include <sstream>
#include <string>
#include <type_traits>
#include <vector>
template <typename T,
typename CharT = char,
typename Traits = std::char_traits<CharT>>
std::basic_string<CharT, Traits>
join(const std::vector<T>& values,
const std::basic_string<CharT, Traits>& sep =
std::basic_string<CharT, Traits>(1, static_cast<CharT>(','))) {
std::basic_ostringstream<CharT, Traits> oss;
bool first = true;
for (const auto& v : values) {
if (!first) {
oss << sep;
}
oss << v;
first = false;
}
return oss.str();
}
void use() {
std::vector<int> nums{1, 2, 3};
std::string s1 = join(nums); // 默认分隔符 ','
std::string s2 = join(nums, std::string(" | "));
(void)s1;
(void)s2;
}这里CharT与Traits都有默认值,T从values推导;第二个函数参数有默认值,因此join(nums)合法。注意默认表达式std::basic_string<CharT, Traits>(1, static_cast<CharT>(','))依赖CharT,它是在模板实例化时按具体类型生成的,这正是默认函数实参与模板参数可以协作的地方。
再看一个常见需求:让调用方可以省略「比较器」,但比较器类型又要能推导。直接给函数参数默认值行不通,因为默认值不能用于推导Compare的类型;正确做法是给模板参数默认值,并让函数参数默认构造:
// 适用于 C++17
#include <algorithm>
#include <functional>
#include <vector>
template <typename T, typename Compare = std::greater<T>>
void sort_desc(std::vector<T>& v, Compare comp = Compare{}) {
std::sort(v.begin(), v.end(), comp);
}Compare有默认模板实参,comp有默认函数实参,二者配合才能让sort_desc(v)与sort_desc(v, my_cmp)同时成立。如果只写Compare comp = std::less<T>{}而不给模板参数默认值,sort_desc(v)仍然无法推导Compare,会编译失败。
常见坑点
- ❌ 写
template<class T> void f(T x = 0);后调用f(),以为能从默认实参推导T。
✅ 默认函数实参不参与模板实参推导;调用f<int>()或显式给实参。
- ❌ 在虚函数上使用默认实参,派生类换一个默认值。
✅ 默认值静态绑定;用非虚接口包一层,默认值只写在基类。
- ❌ 通过函数指针调用时省略尾部实参。
✅ 默认实参不属于函数类型;写转发函数或补全实参。
- ❌ 在头文件和源文件的定义里各写一次默认值。
✅ 默认实参只能指定一次;通常只写在头文件声明处。
- ❌ 写两个仅默认模板实参不同的同名模板,期望形成重载。
✅ 默认实参不参与签名等价判断;用不同的模板参数列表或if constexpr。
- ❌ 让默认实参引用声明点之后才定义的函数或变量。
✅ 默认实参的名字在声明点查找,声明前必须可见。
- ❌ 认为默认实参只求值一次,把有副作用的表达式放进去。
✅ 每次调用都会求值;有副作用时改用显式重载。
- ❌ 在显式特化里再写默认实参。
✅ 标准禁止在显式特化中指定默认实参;把默认值留在主模板声明。
总结
| 主题 | 规则 |
|---|
| 位置 | 默认函数实参必须在参数列表尾部 |
| 求值 | 每次调用求值;名字在声明点查找 |
| 重复 | 同一作用域只能指定一次,可后补 |
| 函数类型 | 默认实参不属于函数类型,指针调用不生效 |
| 虚函数 | 默认值按静态类型选取,易与动态分派错配 |
| 模板推导 | 默认函数实参不参与模板实参推导 |
| 默认模板实参 | 函数模板自 C++11 起支持;可引用前面的参数 |
| SFINAE | 常用无名默认模板参数承载enable_if约束 |
默认参数是「调用形式」的语法糖,函数模板是「类型」的泛化;两者交界处的核心只有一句话:默认函数实参不参与推导,默认模板实参才可以补齐类型。记住这一点,再避开虚函数的静态默认值,绝大多数误用都能避免。