C++ 完美转发
在理解了左值、右值和移动语义之后,继续学习一下完美转发,这些概念都看过很多次,但是每隔一段时间都会有一定遗忘,现在写下这篇文章记录一下这部分内容,方便下次复习。
完美转发就是:在模板函数里,把参数原来的“左值/右值属性”原封不动地传给另一个函数。
它通常和这三个东西一起出现:
T&&
std::forward<T>(x)
引用折叠
我们一步步来。
1. 先理解左值和右值
1.1 左值 lvalue
简单说:
有名字、能取地址、可以反复使用的对象,通常是左值。
int x = 10;
这里 x 是左值。
int y = x;
x 仍是左值。
1.2 右值 rvalue
简单说:
临时值、没有名字、马上就要销毁的值,通常是右值。
10
x + 1
std::string("hello")
这些都是右值。
例如:
std::string s = std::string("hello");
std::string("hello") 是一个临时对象,是右值。
2. 左值引用和右值引用
2.1 左值引用
int x = 10;
int &ref = x;
int& 只能绑定到左值。
int &a = 10; // 错误,10 是右值
2.2 右值引用
int &&rref = 10;
int&& 可以绑定到右值。
int x = 10;
int &&r = x; // 错误,x 是左值
但是注意一个非常重要的点:
int &&r = 10;
虽然 r 的类型是 int&&,但是:
变量
r本身是左值。
因为 r 有名字,可以取地址,可以反复使用。
int &&r = 10;
// r 是左值
这一点后面特别关键,如果不理解这个知识,对完美转发的理解会比较困难(分不清是左值还是右值)。
3. 为什么需要完美转发?
假设我们有两个重载函数:
#include <iostream>
using namespace std;
void process(int &x) {
cout << "process lvalue\n";
}
void process(int &&x) {
cout << "process rvalue\n";
}
现在直接调用:
int a = 10;
process(a); // lvalue
process(20); // rvalue
输出:
process lvalue
process rvalue
这就是预期的结果,没问题。
现在我们写一个包装函数:
template <typename T>
void wrapper(T x) {
process(x);
}
调用:
int a = 10;
wrapper(a);
wrapper(20);
我们希望它输出:
process lvalue
process rvalue
但实际会输出:
process lvalue
process lvalue
为什么?
因为在 wrapper 里面:
void wrapper(T x) {
process(x);
}
x 是一个有名字的变量。
有名字就是左值。
所以不管你传进来的是左值还是右值,在函数里面 x 都是左值。
4. 完美转发
我们希望:
wrapper(a);
里面调用:
process(左值);
我们也希望:
wrapper(20);
里面调用:
process(右值);
但是普通写法会把所有参数都变成左值。
所以我们需要一种机制:
这就是 完美转发。
4.1 完美转发标准写法
template <typename T>
void wrapper(T &&x) {
process(std::forward<T>(x));
}
重点是两个部分:
T &&x
std::forward<T>(x)
这两个必须配套使用。
完整代码:
#include <iostream>
#include <utility>
using namespace std;
void process(int &x) {
cout << "process lvalue\n";
}
void process(int &&x) {
cout << "process rvalue\n";
}
template <typename T>
void wrapper(T &&x) {
process(std::forward<T>(x));
}
int main() {
int a = 10;
wrapper(a); // process lvalue
wrapper(20); // process rvalue
return 0;
}
输出:
process lvalue
process rvalue
5. T&& 在模板里不是普通右值引用
这是最容易混的点。
当看到:
template <typename T>
void wrapper(T &&x)
可能会以为 x 是右值引用。
但在模板参数推导场景下,T&& 有特殊含义,它叫转发引用 forwarding reference,也常被叫作万能引用 universal reference,它既可以接左值,也可以接右值。
6. T&& 怎么接左值?
看这个调用:
int a = 10;
wrapper(a);
a 是左值,这时模板推导会把 T 推导成 int&。所以 T&& 会变成 int& &&,接下来发生引用折叠:
int& && -> int&
所以最终函数参数类型是:
int &x
因此可以接左值。
7. T&& 怎么接右值?
调用:
wrapper(20);
20 是右值。
这时模板推导:
T = int
所以:
T&& -> int&&
最终函数参数类型是右值引用。
8. 引用折叠规则
这里在刚开始了解的时候感觉很迷惑,但其实不用背一大堆,记住一句话就够:
完整规则:
T& & -> T&
T& && -> T&
T&& & -> T&
T&& && -> T&&
简单记:
左值引用优先
9. std::forward 到底做了什么?
前面说过:
template <typename T>
void wrapper(T &&x) {
process(x);
}
这里的 x 本身是有名字的变量,所以它永远是左值,所以我们需要:
std::forward<T>(x)
它的作用是:
根据
T的推导结果,决定把x转成左值还是右值。
如果传进来的是左值:
int a = 10;
wrapper(a);
此时传入的变量 a 是左值,因此 T = int&,所以 std::forward<T>(x) 结果仍然是左值。
如果传进来的是右值:
wrapper(20);
此时传入的 20 是右值,因此 T = int,所以 std::forward<T>(x) 结果变成右值
std::forward
(x) 是“有条件的 move”: 原来是右值,才转成右值;原来是左值,就保持左值。
10. std::move 和 std::forward 的区别
std::move(x) 会无条件把 x 转成右值,它不管 x 原来是什么。
template <typename T>
void wrapper(T &&x) {
process(std::move(x));
}
这样的代码无论传入的是左值还是右值,都会被 std::move 强行转换成右值,于是可能调用右值版本,甚至把外部对象资源偷走。
std::forward 是有条件地转发,会保留传进来的参数的所有属性。
11. 一个很直观的例子:构造对象
假设有一个类:
#include <iostream>
#include <string>
using namespace std;
class Person {
public:
Person(const string &name) {
cout << "copy string\n";
}
Person(string &&name) {
cout << "move string\n";
}
};
如果直接调用:
string s = "Alice";
Person p1(s); // copy string
Person p2(string("Bob")); // move string
没问题。
现在写一个工厂函数:
template <typename T>
Person createPerson(T &&name) {
return Person(std::forward<T>(name));
}
调用:
string s = "Alice";
auto p1 = createPerson(s); // copy string
auto p2 = createPerson(string("Bob")); // move string
结果也没有问题,这就是完美转发的意义,它让工厂函数不破坏参数原来的属性。
如果工厂函数不使用 std::forward,那么 p1 和 p2 都会调用拷贝构造,右值信息丢失了。
12. 什么时候 T&& 不是万能引用?
// 是万能引用
template <typename T>
void f(T&& x);
// 不是万能引用,就是普通的右值引用
void f(int&& x);
template <typename T>
class Box {
public:
void f(T&& x);
};
这里要小心,如果 T 是类模板参数,不是在函数调用时由 f 推导出来的,那么 T&& 不一定是转发引用。
例如:
Box<int> b;
b.f(10); // T 已经固定为 int,所以 int&&
它不是通过 f 推导出来的。
如果想让成员函数里使用转发引用,要写成:
template <typename T>
class Box {
public:
template <typename U>
void f(U&& x) {
// U&& 才是转发引用
}
};
13. 最后以一个完整的观察代码结束
#include <iostream>
#include <utility>
using namespace std;
void func(int &x) {
cout << "lvalue\n";
}
void func(int &&x) {
cout << "rvalue\n";
}
template <typename T>
void badWrapper(T &&x) {
func(x);
}
template <typename T>
void goodWrapper(T &&x) {
func(std::forward<T>(x));
}
int main() {
int a = 10;
badWrapper(a); // lvalue
badWrapper(20); // lvalue
goodWrapper(a); // lvalue
goodWrapper(20); // rvalue
}