右值引用、std::move 和 std::forward 是 C++11 中的最重大语言新特性之一。就算我们不主动去使用右值引用,它也在影响着我们的编码,这是因为STL的 string、vector 等类都经过了右值引用的改造。由于现在的 IDE 有很多优化提示,我们也会在不经意间使用了右值引用,智能指针 unique_ptr 的实现也和右值引用有关。std::unique_ptr 是通过删除拷贝构造函数的方式禁止拷贝,提供移动构造函数来转移智能指针内实际指针的所有权,以此实现了资源独占。
“左值” 和 “右值” 的区别
要理解右值引用,首先要去理解左值和右值的区别,区分非常简单,就是可以通过 & 符号获取引用地址就是左值,否则就是右值。
int handle1(int &a) {
return a;
}
int &handle2(int &a) {
return a;
}
int main(int argc, char **argv) {
int a = 10;
int *p1 = &a; // 正确,a 是左值
int *p2 = &(10); // 错误,10 是右值
int *p3 = &handle1(a); // 错误,handle1() 的返回值是右值
int *p4 = &handle2(a); // 正确,handle2() 的返回值是左值
return 0;
}
“左值引用” 和 “右值引用” 的区别
上面例子知道了左值和右值的区别,那左值引用和右值引用就很好理解了。& 表示左值引用,&& 表示右值引用,左值引用只能用来关联左值,而右值引用只允许用来关联右值。
int main(int argc, char **argv) {
int a = 10;
int &p1 = a; // 正确,p1 是左值引用
int &&p2 = 10; //正确,p2 是右值引用
int &p3 = 10; // 错误,右值不允许赋值给左值引用
int &&p4 = a; //错误,左值不允许赋值给右值引用
return 0;
}
示例 2
#include <iostream>
void handle(int &&i) {
std::cout << "右值引用" << std::endl;
}
void handle(int &i) {
std::cout << "左值引用" << std::endl;
}
int main(int argc, char **argv) {
int i1;
int &i2 = i1;
int &&i3 = 1;
int i4 = i3;
handle(i1); // 左值引用
handle(i2); // 左值引用
handle(i3); // 左值引用
handle(i4); // 左值引用
handle(0); // 右值引用
return 0;
}
有人就会觉得疑问,为何 i3 和 i4 也属于左值引用?实际上被声明出来的左右值引用都是左值,因为被声明出来的左右值引用已经分配了地址。
“右值引用” 有什么用?
下面通过简单的例子,并结合 std::move 说明右值引用的作用。
class User {
public:
int id;
User(int id) {
this->id = id;
}
~User() {
std::cout << "析构函数" << std::endl;
}
};
User GetUser() {
User user(1);
return user;
}
int main(int argc, char **argv) {
User user = GetUser();
return 0;
}
上面会输出几次“析构函数”?如果是不加以任意的操作,运行结果只会输出1次,实际上是因为编译帮我们做了优化,所以我们需要先关闭编译器优化:
# CMakeLists.txt
cmake_minimum_required(VERSION 3.17)
project(c11-sample)
add_compile_options(-fno-elide-constructors) #关闭编译器优化
add_executable(${PROJECT_NAME} main.cpp)
运行结果:返回给函数发生了一次拷贝,赋值给 user 的时候也发生了一次拷贝,这样频繁的拷贝是影响性能,应该尽量避免。
析构函数
析构函数
析构函数
引入右值引用
User GetUser() {
User user(1);
return user;
}
int main(int argc, char **argv) {
User&& user = GetUser();
return 0;
}
运行结果:虽然返回值的时候发生了拷贝,但是由于使用了右值引用,赋值给右值引用 &&user 是不会发生拷贝。
析构函数
析构函数
右值引用和 std::move 的应用场景
结合 std::move 实现对象的 深度拷贝
和 剪切(转移)
,这里一个比较复杂的示例,假设 HttpResponse 是一个网络请求的响应类,需要根据不同的应用场景使用深度拷贝或剪切,这里的深度拷贝是复制出一个数据一样的对象,而剪切就是把数据转移到另外一个新的对象中,而原来的对象就不能再使用。
#include <iostream>
class HttpResponse {
public:
int id;
char *data;
int length;
int tag;
HttpResponse(int id, char *data, int length) : id(id), data(data), length(length) {}
HttpResponse(const HttpResponse &user) {
std::cout << "深度拷贝 " << user.id << std::endl;
this->id = user.id;
this->tag = user.tag;
this->length = user.length;
this->data = new char[user.length];
memcpy(this->data, user.data, user.length);
}
HttpResponse(HttpResponse &&user) {
std::cout << "剪切(转移) " << user.id << std::endl;
this->id = user.id;
this->tag = user.tag;
this->length = user.length;
this->data = user.data;
user.id = 0;
user.data = nullptr;
user.length = 0;
user.tag = 0;
}
~HttpResponse() {
if (data != nullptr) {
std::cout << "回收内存 data " << id << std::endl;
delete[]data;
}
}
};
void setResponse(HttpResponse response) {
response.tag = 2;
}
int main(int argc, char **argv) {
HttpResponse res1(1, new char[1024], 1024);
HttpResponse res2(2, new char[1024], 1024);
setResponse(std::move(res1));
setResponse(res2);
// 假设调用 setResponse 后,在当前栈不再需要。
std::cout << res1.id << std::endl; // 错误,res1 已经转移,不能再调用
std::cout << res2.id << std::endl; // 正确,由于 res2 是通过深度拷贝的方式实现。
return 0;
}
运行结果:
剪切(转移) 1
回收内存 data 1
深度拷贝 2
回收内存 data 2
0
2
回收内存 data 2
std::move 到底做了什么?
理解上面的示例,我们要先理解 std::move 到底做了什么,std::move 并不是万能的,并不能实现对象的转移,函数的本身是不能提高性能的,对象的转移实际上是由对象构造函数自己去实现的,std::move 的作用仅仅只是把变量 左值引用(&) 强转成 右值引用(&&),让构造函数可以区分拷贝(const HttpResponse &user)和 move(HttpResponse &&user),执行不同的构造函数。
std::move 的代码结构如下,而且有 IDE 提示加成 Clang-Tidy: 'res1' used after it was moved
,提示你不要再使用这个对象了,实际上这个对象并没有被回收。
T&& move(T& a){
return (T&&)a;
}
哪些类支持 std::move ?
通过上面的例子我们可以知道,一个类是否支持 std::move 是和这个类的构造函数有关,必须由类的开发者编写代码支持,所以并不是所有的类都是支持 std::move,不过STL 内置的类很多都有经过了右值引用的改造构造函数,是可以支持 std::move,比较常用的有std::string、std::vector等。由于 STL 很多类都实现了 std::move,合理使用 std::move 是可以提高性能。
#include <iostream>
#include <vector>
class User {};
int main() {
std::string str = "Hello World";
std::string str2 = std::move(str); // 正确
User user;
User user2 = std::move(user); // 使用不正确,User 不存在右值引用的构造函数,将会使用拷贝构造器
std::vector<int> list{1, 2, 3};
std::vector<int> list2(std::move(list));
// 错误,内部数据已经不存在 std::cout << str << std::endl;
// 错误,内部数据已经不存在 std::cout << list[0] << list[1] << list[2] << std::endl;
std::cout << str2 << std::endl; // Hello World
std::cout << list2[0] << list2[1] << list2[2] << std::endl; // 123
return 0;
}
是否可以通过 std::move 返回局部变量?
不允许!当局部变量离开栈时候就已经被回收,如果是基本类型或者 string 可以直接返回。返回局部变量又不希望多次拷贝可以通过智能指针实现,可以参考 C++ 智能指针。
#include <iostream>
class User {
public:
int id;
User(int id) : id(id) {}
~User() { std::cout << "~User " << id << std::endl; }
};
User &&test1() {
User user(1);
return std::move(user);
}
std::shared_ptr<User> test2() {
auto user = std::make_shared<User>(2);
return user;
}
User test3() {
User user(3);
return user;
}
int main(int argc, char **argv) {
User &&user1 = test1();
auto user2 = test2();
User user3 = test3();
// 错误,离开栈时候,user1已经被回收,会导致野指针
std::cout << user1.id << std::endl;
// 正确,通过智能指针实现
std::cout << user2->id << std::endl;
// 正确,直接返回,但是会发生多次拷贝
std::cout << user3.id << std::endl;
}
~User 1
~User 3
-459020032
2
3
~User 3
~User 2
std::forward 完美转发
如果我们在调用一个 B 函数传入参数 a,而 B 函数需要调用 C 函数并传入 a 参数,我们希望调用 C 函数时候,a 可以保留原来的左右值特征。由于实际上被声明出来的左右值引用都是左值,经过转发后被变成左值。所以我们需要通过 std::forward 来保存參数的左值或右值特性,实现完美转发。std::make_unique 和 std::make_shared 的内部就使用到了 std::forward。我们在实际的开发中使用完美转发的场景很少,通常是需要结合模板参数情况下才使用,所以我们不必纠结于怎么让它发挥作用,理解就好。
#include <iostream>
using namespace std;
void C(int &a) {
cout << "int& " << a << endl;
}
void C(int &&a) {
cout << "int&& " << a << endl;
}
template<class A>
void B1(A &&a) {
C(a);
}
template<class A>
void B2(A &&a) {
C(std::forward<A>(a));
}
int main(int argc, char *argv[]) {
int a = 1;
B1(a); // int& 1
B1(2); // int& 1
B2(a); // int& 1
B2(2); // int&& 2
return 0;
}