万能引用的重载
避免对万能引用进行重载
万能引用可以减少代码运行开销,比如:
1 | |
这段代码主要实现了将外部传入的字符串拷贝至 names
容器中。
因为形参 name
本身是一个左值,因此无论外部传入左值或者右值,在 emplace
加入容器时都会触发拷贝。
为了优化这段代码的性能,我们可以引入万能引用和完美转发:
1 | |
此时,我们传入左值还是会触发拷贝,但是传入右值时,emplace
会采用移动的方法,这样就减少了不必要的开销。
但是,如果我们对以万能引用为参数的函数进行重载,会引发一些问题。比如:
1 | |
我们重载刚才的函数,实现了一个接收 int 整型的函数。
1 | |
传入 int 类型变量时,正确使用了重载函数,但是传入
short 类型变量时,却产生了报错。
原因是万能引用会推导得到 short&
类型,此时编译器决议选择了万能引用版本的函数,而不是将
short 提升至 int 去匹配重载版本。因为
short& 与万能引用模板是精确匹配,而 short
到 int 需要一次整型提升,精确匹配的优先级更高。
如果是使用万能引用的构造函数,此时进行重载也会出现问题:
1 | |
当你尝试对 non-const 的对象进行拷贝时:
1 | |
此时 p 是一个 Person
类型的非常量左值。正常的拷贝构造函数期望
const Person&,需要加一个 const
限定符。而万能引用构造函数可以直接推导出 T 为
Person&,生成一个 Person(Person&)
的签名。由于不需要加
const,万能引用版本成为了更完美的匹配。编译器放弃了拷贝构造函数,转而将
p
作为参数传入了模板构造函数,这通常会导致底层的字符串初始化失败,报出极其难懂的模板编译错误。
在继承体系下,使用万能引用的构造函数也会引发一些问题:
1 | |
对于派生类 SpecialPerson,无论其在初始化
Person 的时候传入 const SpecialPerson&
类型的左值,还是传入 SpecialPerson&&
类型的右值,都会被基类 Person
中的万能引用构造函数拦截。因为万能引用可以精确匹配派生类类型,而匹配基类的拷贝构造函数需要发生派生类到基类的隐式转换,显然前者的优先级高于后者。
重载万能引用的替代方案
前面我们已经展示了万能引用类型函数的”贪婪性”,对万能引用类型的函数进行重载往往不能得到我们想要的结果。
但是,如果我们既想保留万能引用的高性能完美转发,又想支持多种参数类型,该怎么办?
放弃重载
最简单的办法就是放弃重载,可以对不同功能的函数起不同名字。但是这种方法对构造函数无效。
传递 const T&
放弃完美转发,回归 C++98 的经典做法,直接使用
const T&
作为参数类型。但是这种方法放弃了对于右值的完美转发和移动语义优化,产生了额外的开销。
按值传递
按值传递是介于 const T&
和万能引用之间的折中方案:
1 | |
传入左值时发生一次拷贝,传入右值时发生一次移动。虽然性能略逊于万能引用的完美转发,但实现简单、语义清晰,且不会与拷贝/移动构造函数发生重载冲突。
标签分发
如果你确实需要重载,但又不能更改函数名,可以使用标签分发(tag dispatch)。其核心原理是引入一个额外的”标签”参数,将万能引用函数约束在一个内部实现函数中。比如:
1 | |
具体原理是利用 std::is_integral 或
std::iterator_traits
等类型萃取工具,在编译期计算出一个具体的类型(如
std::true_type 或
std::false_type),并实例化出一个空标签对象。最后根据标签对象匹配对应的重载函数。
由于标签类型(如 std::true_type)是空结构体(empty
struct),经过编译器优化后,不会产生额外的内存分配或运行时传递开销。
限制使用万能引用的模板
使用 std::enable_if(即
SFINAE——替换失败不是错误)在编译期强行关闭特定的模板实例化。
1 | |
下面我们来详细分析一下这段代码:
SFINAE
首先,需要理解 SFINAE 的概念。SFINAE 是英文 Substitution Failure Is Not An Error 的首字母缩写,中文翻译为”替换失败不是错误”。简单来说,它的核心规则是:编译器在推导模板参数时,如果尝试将具体的实参类型代入(替换)模板签名中发生了错误,编译器不会直接停止编译并报错,而是默默地将该模板从候选函数列表中剔除,继续寻找其他匹配的重载。
辅助模板参数
1 | |
代码中的 template 存在两个
typename,前者是真实的业务模板参数,用来接收你传入构造函数的实参类型,并用于后续的万能引用和完美转发;
后者是一个匿名且带默认类型的模板参数,typename = ...
等号左边没有写名字,= ... 等号右边的整长串
typename std::enable_if<...>::type
是这个参数的默认值。
对于普通函数,我们可以将 enable_if
放置在函数返回值位置,比如:
1 | |
但是构造函数没有返回类型,为了触发
SFINAE,我们可以利用辅助模板参数的位置来存放
enable_if。
std::enable_if
std::enable_if 是 C++11 引入的核心模板元编程工具,定义在
<type_traits>
头文件中。它的核心作用是在编译期根据给定的布尔条件,强行启用或禁用某个模板函数或模板类。
std::enable_if 的实现如下:
1 | |
std::enable_if 的实现利用了模板的偏特化,当第一个参数为
true 时,编译器会精确匹配到偏特化版本,保存 T
类型到内部 type 成员;当第一个参数为 false
时,编译器会匹配主模板,此时 enable_if 结构体为空,获取
type 成员会失败并触发 SFINAE 机制。
std::is_same
std::is_same 是 C++11 引入在
<type_traits>
头文件中的类型断言/比较工具。它的核心作用是在编译期判断两种类型是否完全相同,并返回一个编译期布尔常值。
需要注意的是,std::is_same
的匹配规则严格,不会进行任何隐式转换,也不会忽略任何修饰。
std::is_same 的实现如下:
1 | |
std::is_same
的实现也是利用了模板的偏特化,当传入的两个参数类型完全相同时,编译器会精确匹配到偏特化版本,此时内部
value 为
true;当两个类型不相同时,编译器会匹配到主模板版本,此时内部
value 为 false。
std::decay
std::decay 是 C++11 引入在
<type_traits>
头文件中的类型转换/萃取工具。它的核心作用是:模拟 C++
在按值传递(pass-by-value)函数参数时所发生的”类型退化(decay)“规则。简单来说,它会把一个带有修饰的复杂类型剥离擦除成一个最干净的基础值类型。
std::decay 的实现如下:
1 | |
std::decay 的逻辑是先剥离引用,然后利用
std::conditional 和多种类型判断,得到退化后的基础类型。
其中 std::conditional 的实现如下:
1 | |
std::conditional
的实现也利用了模板的偏特化,根据第一个布尔类型参数的值匹配不同版本。
这样,当 Person 构造时传入 Person
类或带有修饰的 Person
类变量时,!std::is_same::value 会得到
false,从而通过 std::enable_if 触发 SFINAE
舍弃这个模板,最终匹配到拷贝或移动构造函数。
特别的,在继承体系中,如果使用派生类对象来构造基类
Person,上述代码依然会被模板构造函数抢占,引发编译错误。比如:
1 | |
此时,基类 Person 会调用万能引用版本的构造函数,因为
SpecialPerson 与 Person
类型并不相同。为了处理这种情况,我们需要修改 Person
类:
1 | |
利用 std::is_base_of,当传入 T 为
Person 类或 Person 派生类时会返回
true,取反后触发 SFINAE。
这次的插图来自画师 李斯特
图片地址:https://www.pixiv.net/artworks/135346293
文章内容参考:Effective Modern C++ 42 Specific Ways to Improve Your Use of C++11 and C++14 (Scott Meyers) Item 26、Item 27