一文看懂 C++11 的 右值引用、std::move 和 std::forward

右值引用、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 
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 

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 
#include 

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 list{1, 2, 3};
    std::vector 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 
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 test2() {
    auto user = std::make_shared(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 
using namespace std;
void C(int &a) {
    cout << "int& " << a << endl;
}
void C(int &&a) {
    cout << "int&& " << a << endl;
}
template
void B1(A &&a) {
    C(a);
}

template
void B2(A &&a) {
    C(std::forward(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;
}

你可能感兴趣的:(一文看懂 C++11 的 右值引用、std::move 和 std::forward)