最近《Effective Modern C++ 》看到了第五章,感觉这章挺有趣的,所以单独拿出来总结一下,主要是想对通用引用和右值引用相关的东西总结补充一下,感兴趣的不妨看看。
区分通用引用和右值引用
“T&&
”有两种不同的意思。第一种,当然是右值引用。“T&&
”的另一种意思是,它既可以是右值引用,也可以是左值引用,被称为通用引用(universal references)。
比如下面的例子中
void f(Widget&& param); //右值引用
Widget&& var1 = Widget(); //右值引用
auto&& var2 = var1; //通用引用,auto&& 是通用引用,它的类型根据 var1 的类型来推导,var1 是一个右值引用,var2 也是一个右值引用,绑定到 var1。
template<typename T>
void f(std::vector<T>&& param); //右值引用
template<typename T>
void f(T&& param); //通用引用
上面的例子中:
auto&& var2 = var1;
这是一个 通用引用(完美转发引用)。
auto&&
是 通用引用,它的类型根据var1
的类型来推导。- 由于
var1
是一个右值引用,auto&&
会推导为Widget&&
。因此,var2
也是一个右值引用,绑定到var1
。
template<typename T> void f(T&& param);
这是一个 通用引用(完美转发引用)。
T&& param
是一个 通用引用,它的类型根据传递给模板函数的实际类型来推导。- 如果传入的是右值,
param
会被推导为右值引用 (T&&
)。 - 如果传入的是左值,
param
会被推导为左值引用 (T&
),这是通过引用折叠规则完成的。
对一个通用引用而言,类型推导是必要的,它必须恰好为“T&&
”。下面几种情况都可能使一个引用失去成为通用引用的资格。
template<typename T> void f(std::vector<T>&& param);
这是一个 右值引用。
std::vector<T>&& param
是一个 右值引用,它接受一个std::vector<T>
类型的右值。- 函数
f
只能接受右值类型的参数。
除此之外,即使一个简单的const
修饰符的出现,也足以使一个引用失去成为通用引用的资格:
template <typename T>
void f(const T&& param); //param是一个右值引用
对右值引用使用std::move
,对通用引用使用std::forward
std::move
是一个类型转换工具,它将左值转换为右值引用,允许通过移动语义来避免不必要的对象复制。比如有时候我们希望将其资源转移给另一个对象时,你应该使用 std::move
。这样可以避免复制操作,提高效率:
std::vector<int> createVector() {
std::vector<int> v = {1, 2, 3};
return std::move(v); // 使用 std::move 防止复制,转移资源
}
std::forward
它通常用于完美转发(perfect forwarding)的场景中,保留了传入参数的值类别(即,是否是左值或右值)。这使得它特别适用于在函数模板中转发参数,而不改变它们的值类别。
完美转发:指的是保持参数的左值或右值性质,以便在转发时能够选择正确的构造(复制或移动)。比如下面:
template <typename T>
void wrapper(T&& arg) {
// 使用 std::forward 保留 T&& 参数的左值或右值性质
someFunction(std::forward<T>(arg));
}
在这个例子中,std::forward<T>(arg)
将确保传入的参数 arg
被正确地转发到 someFunction
,而不引入不必要的复制或移动。
所以 move 和 forward 使用场景是不一样的,如果在通用引用上使用std::move
,这可能会意外改变左值:
class Widget {
public:
template<typename T>
void setName(T&& newName) //通用引用可以编译,
{ name = std::move(newName); } //但是代码太太太差了!
…
private:
std::string name;
std::shared_ptr<SomeDataStructure> p;
};
std::string getWidgetName(); //工厂函数
Widget w;
auto n = getWidgetName(); //n是局部变量
w.setName(n); //把n移动进w!
… //现在n的值未知
上面的例子,局部变量n
被传递给w.setName
,n
的值被移动进w.name
,调用setName
返回时n
最终变为未定义的值。
所以对于 move 和 forward 我们尽量不要用错,但是也不要不用,否则可能会有性能上的损失,比如下面:
Matrix //按值返回
operator+(Matrix&& lhs, const Matrix& rhs)
{
lhs += rhs;
return std::move(lhs); //移动lhs到返回值中
}
如果在最后 return的时候直接返回 lhs,那么其实是拷贝lhs到返回值中,拷贝比移动效率低一些。
forward
情况也类似,如下:
template<typename T>
Fraction //按值返回
reduceAndCopy(T&& frac) //通用引用的形参
{
frac.reduce();
return std::forward<T>(frac); //移动右值,或拷贝左值到返回值中
}
如果std::forward
被忽略,frac
就被无条件复制到reduceAndCopy
的返回值内存空间。
最后提一下,由于 RVO 的存在,我们也不要以为使用 move 操作就可以提升性能,RVO 主要解决的是在函数返回一个局部对象时,编译器可能会为该对象创建一个临时副本,然后将其复制到返回值中,从而引入了不必要的复制开销。
所以当我们想要“优化”代码,把“拷贝”变为移动:
Widget makeWidget() //makeWidget的移动版本
{
Widget w;
…
return std::move(w); //移动w到返回值中(不要这样做!)
}
通过返回 std::move(w)
,你实际上告诉编译器“我想要移动这个对象”,从而避免了 RVO 的优化,可能会导致不必要的资源转移。
所以你看,在c++中,为了做性能优化其实是一件挺复杂的事情。
避免在通用引用上重载
为什么要避免使用
通用引用可以绑定到几乎所有类型的实参,包括左值和右值。这种特性使得编译器在选择重载时可能会优先选择通用引用版本,即使这并不是开发者所期望的行为。并且由于通用引用的匹配规则,编译器在解析重载时可能会产生意外的结果。举个例子,假设有以下两个函数:
template<typename T>
void foo(T&& t) { /* 通用引用 */ }
void foo(int& t) { /* 左值引用 */ }
当调用foo(5)
时,编译器会选择第一个函数,因为5
是一个右值,而对于左值int a; foo(a);
则会选择第二个函数。
但是假如将short a; foo(a);
则会选择第一个函数。有两个重载的foo
。使用通用引用的那个推导出T
的类型是short
,因此可以精确匹配。对于int
类型参数的重载也可以在short
类型提升后匹配成功。根据正常的重载解决规则,精确匹配优先于类型提升的匹配,所以被调用的是通用引用的重载。
如果想要使用重载该怎么办
最简单的方式当然是放弃了,比如 go 里面就没有重载,比如我们重载了logAndAdd函数,那么可以换成logAndAddName和logAndAddNameIdx来避免重载。
第二种方法就是将通用引用改写成 const T&
,因为通用引用带来的复杂推导、重载歧义等问题反而会增加维护成本。此时,直接使用对 const
的左值引用即可满足需求。
改写之后 const T&
可以绑定到左值和右值,并且所有实参都被看作不可修改的“左值”,所以无需区分值类别。
比如原始版本(使用通用引用):
#include <utility> // std::forward
template<typename T>
void process(T&& param) {
// 这里可能会做完美转发
someFunction(std::forward<T>(param));
}
改用 const T&
后:
template<typename T>
void process(const T& param) {
// 现在 param 总是 const 引用,不再做完美转发
// 如果只是读 param 或传入其他函数当 const 引用使用就可以
someFunction(param); // 直接传参即可
}
这种转变缺点是效率不高,因为放弃了移动语义,但是为了避免通用引用重载歧义、易读性优先,所以放弃一些效率来确保行为正确简单可能也是一种不错的折中。
再来就是使用tag dispatch,比如下面我们有两个重载函数,但是由于通用引用的存在,会发生引用重载歧义,所以我们想要正确的让函数实现最优匹配,可以使用std::is_integral
是一个类型特征(type trait),用来在编译期判定某个类型是否是整型。
template<typename T>
void logAndAdd(T&& name)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
void logAndAdd(int idx) //新的重载
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(nameFromIdx(idx));
}
比如我们可以改写成这样:
template<typename T>
void logAndAdd(T&& name)
{
logAndAddImpl(
std::forward<T>(name),
std::is_integral<typename std::remove_reference<T>::type>()
);
}
template<typename T> //非整型实参:添加到全局数据结构中
void logAndAddImpl(T&& name, std::false_type)
{
auto now = std::chrono::system_clock::now();
log(now, "logAndAdd");
names.emplace(std::forward<T>(name));
}
std::string nameFromIdx(int idx); //与条款26一样,整型实参:查找名字并用它调用logAndAdd
void logAndAddImpl(int idx, std::true_type) //译者注:高亮std::true_type
{
logAndAdd(nameFromIdx(idx));
}
logAndAdd
传递一个布尔值给logAndAddImpl
表明是否传入了一个整型类型,通过 std::true_type
和std::false_type
来判断应该调用哪个函数。上面的例子中还用到了std::remove_reference
,它的作用是去掉类型中的引用修饰符得到正确的类型:若 T
为 U&
或 U&&
,则 std::remove_reference<T>::type
得到 U
;如果 T
本身不是引用类型,则结果还是 T
本身。
在这个设计中,类型std::true_type
和std::false_type
是“标签”(tag),在logAndAdd
内部将重载实现函数的调用“分发”(dispatch)给正确的重载。
再来就是基于 std::enable_if
的重载,如下实现了两个版本的重载函数:
// ----------------------
// (A) 针对整型参数的重载
// ----------------------
template<
typename T,
// 使用 std::enable_if 使该重载仅在 T 是整型时有效
typename = std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& idx)
{
cout << "for int" << endl;
}
// ------------------------
// (B) 针对非整型参数的重载
// ------------------------
template<
typename T,
// 使用 std::enable_if 使该重载仅在 T 不是整型时有效
typename = std::enable_if_t<!std::is_integral_v<std::remove_reference_t<T>>>
>
void logAndAdd(T&& name)
{
cout << "for universal reference" << endl;
}
int main()
{
logAndAdd("Alice"); // 非整型,调用重载 (B)
std::string bob = "Bob";
logAndAdd(bob); // 非整型,调用重载 (B)
logAndAdd(42); // 整型,调用重载 (A)
return 0;
}
通过 SFINAE 条件:
std::enable_if_t<std::is_integral_v<std::remove_reference_t<T>>>
如果形参是整型类型(如 int
, long
,无论左值还是右值),匹配这个重载。否则就调用另外一个。
关于引用折叠reference collapsing
在C++中引用的引用是非法的,比如下面的写法,编译器会报错:
int x;
…
auto& & rx = x; //error: 'rx' declared as a reference to a reference
但是我们上面用了很多这样的例子:
template<typename T>
void func(T&& param); //同之前一样
Widget w; //一个变量(左值)
func(w); //用左值调用func;T被推导为Widget&
它并没有因为被推导成了 Widget& && param
编译器报错。因为编译器会通过引用折叠把T
推导的类型带入模板变成Widget& param
,也就是说这时候 func 里面传入的是一个左值引用。
存在引用折叠是为了适配“完美转发”这种灵活的泛型编程需求。在模板中使用如 T&&
(或者 auto&&
)这类通用引用的时候,我们把左值传给 T&&
时,需要推导出的类型为 T&
(左值引用);把一个右值传给 T&&
时,需要推导出的类型为 T&&
(右值引用)。
由于这个推导会自动在类型上再套一层引用,所以不可避免会产生 T& &
、T&& &
、T& &&
、T&& &&
这类“引用的引用”。若没有引用折叠规则,这些“引用的引用”将无法在语言中被直接表示。
C++ 标准中定义的引用折叠规则可总结为以下四条(这里的 &
代表左值引用,&&
代表右值引用):
T& &
折叠为T&
T& &&
折叠为T&
T&& &
折叠为T&
T&& &&
折叠为T&&
其中可以看出,只要有一个左值引用(&
)参与,就会最终折叠成左值引用;只有当纯右值引用(T&& &&
)相叠时,才会保留为右值引用。
可以用简单的测试代码来查看引用折叠的结果:
#include <type_traits>
#include <iostream>
#include <string>
template <typename T>
void testReference(T&& x) {
using XType = decltype(x); // 注意这里的 x 是函数形参
// 现在 XType 才是真正的形参类型,比如 int&、int&&
std::cout << "XType is "
<< (std::is_reference<XType>::value ? "reference " : "non-reference ")
<< (std::is_lvalue_reference<XType>::value ? "&" : "")
<< (std::is_rvalue_reference<XType>::value ? "&&" : "")
<< std::endl;
}
int main() {
int i = 0;
const int ci = 0;
// 1) 左值 int
testReference(i); // T 推断为 int&,故 T&& 折叠为 int&
// 2) 右值 int
testReference(10); // T 推断为 int, 故 T&& 折叠为 int&&
// 3) 左值 const int
testReference(ci); // T 推断为 const int&, T&& -> const int&
// 4) 右值 const int
testReference(std::move(ci)); // T 推断为 const int, T&& -> const int&&
return 0;
}
完美转发失效的情况
花括号
比如我们有 fwd 的函数,利用fwd
模板,接受任何类型的实参,并转发得到的任何东西。
template<typename... Ts>
void fwd(Ts&&... params) //接受任何实参
{
f(std::forward<Ts>(params)...); //转发给f
}
假定f
这样声明:
void f(const std::vector<int>& v);
在这个例子中,用花括号初始化调用f
通过编译,
f({ 1, 2, 3 }); //可以,“{1, 2, 3}”隐式转换为std::vector<int>
但是传递相同的列表初始化给fwd不能编译
fwd({ 1, 2, 3 }); //错误!不能编译
因为对f
的直接调用(例如f({ 1, 2, 3 })
),编译器看看调用地传入的实参,看看f
声明的形参类型。它们把调用地的实参和声明的实参进行比较,看看是否匹配,并且必要时执行隐式转换操作使得调用成功。但是 fwd 是个模版,所以不能这样调用。
0
或者NULL
作为空指针
传递0
或者NULL
作为空指针给模板时,类型推导会出错,会把传来的实参推导为一个整型类型(典型情况为int
)而不是指针类型。结果就是不管是0
还是NULL
都不能作为空指针被完美转发。解决方法非常简单,传一个nullptr
而不是0
或者NULL
。
仅有声明的整型static const
数据成员
当一个编译时常量被用作 纯值,如传递给函数、用于模板参数等,编译器可能不会为其分配实际的存储空间,只在需要时进行内联优化。这意味着这些常量没有独立的地址可供引用。
然而,当你尝试取这些常量的地址时,编译器需要一个实际的存储位置。如果该常量没有被定义在某个存储位置(例如,静态成员变量在类外未定义),链接器将找不到该符号,从而产生链接错误。
比如在类中定义整型static const
数据成员:
class Widget {
public:
static const std::size_t MinVals = 28; //MinVal的声明
…
};
想象下f
(fwd
要转发实参给它的那个函数)这样声明:
void f(std::size_t val);
我们尝试通过fwd
调用f
会报错:
fwd(Widget::MinVals); //ld: symbol(s) not found for architecture arm64
c++: error: linker command failed with exit code 1
要能够安全地取编译时常量的地址,需要确保这些常量有实际的存储空间。一种方式是提供类外定义:
#include <cstddef>
class Widget {
public:
static const std::size_t MinVals = 28; // 类内声明和初始化
};
// 类外定义
const std::size_t Widget::MinVals;
void f(std::size_t val) {}
template<typename T>
void fwd(T&& param) {
f(std::forward<T>(param));
}
int main() {
fwd(Widget::MinVals);
const std::size_t* ptr = &Widget::MinVals; // 现在可以安全取地址
}
还有就是从 C++11 开始,使用 constexpr
可以在一定条件下避免需要类外定义,因为 constexpr
变量默认具有内联性质,编译器会为其分配存储空间:
#include <cstddef>
class Widget {
public:
static constexpr std::size_t MinVals = 28; // 使用 constexpr
};
再来就是从 C++17 开始,可以使用 inline
关键字:
class Widget {
public:
inline static const std::size_t MinVals = 28; // 使用 inline
};
重载函数
如果我们试图使用函数模板转发一个有重载的函数,也是会报错的,译器不知道哪个函数应被传递。如下f被定义为可以传递函数指针:
void f(int (*pf)(int));
现在假设我们有了一个重载函数,processVal
:
int processVal(int value);
int processVal(int value, int priority);
报错:
fwd(processVal); //错误!那个processVal?
但是我们可以给函数重命名来解决这个问题:
using ProcessFuncType =
int (*)(int);
ProcessFuncType processValPtr = processVal; //指定所需的processVal签名
fwd(processValPtr); //可以
fwd(static_cast<ProcessFuncType>(workOnVal)); //也可以
Reference
《Effective Modern C++ 》