一文看懂 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 <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;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容