首页 / 资讯中心 / 文章详情

深入解析C++中的函数模板和函数的默认参数

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