小学生学C++编程语法知识(C++左值和右值与右值引用) 左值有名字、可以找到它的东西。右值通常是临时产生、马上就要用完的东西。右值引用专门用来“接住那些马上要用完的东西”。std::move()告诉编译器“这个东西我不要原来的资源了你可以把它搬走”。一、先从“盒子”开始理解假设int a 10;我们可以把它想象成a ↓ ┌─────────┐ │ 10 │ └─────────┘a是一个有名字的盒子。我们可以a 20;也可以cout a;还可以int *p a;因为我们能够找到这个盒子在哪里。这种东西在 C 中叫左值lvalue二、什么是右值看看10或者a 5或者10 20它们通常是计算过程中产生的一个临时结果。比如int x a 5;计算a 5 ↓ 10 5 ↓ 15这个15是计算出来的临时结果。你可以把它想象成┌─────────┐ │ 15 │ └─────────┘ ↓ 马上交给 x它通常不是一个我们长期保存、随时可以找到的“盒子”。这种表达式产生的临时值属于右值rvalue三、先记住一个非常简单的口诀初学的时候可以这样记左值有名字的东西。右值临时产生的东西。例如int a 10;这里a → 左值 10 → 右值再看int b a 5;这里a → 左值 a 5 → 右值四、为什么叫“左值”和“右值”这是一个历史原因。我们经常看到a 10;a在等号左边a 10 ↑ ↑ 左 右所以以前很容易把左值 等号左边右值 等号右边。但是这种理解不够准确例如a b c;右边的b c确实是右值。但a b;右边的b仍然是左值所以“左值” ≠ “出现在等号左边” “右值” ≠ “出现在等号右边”这是初学者最容易踩的坑。五、一个更好的理解左值像“房子”我们可以把左值想象成 一个可以找到的房子例如int a 10;a就像一间有地址的房子a ↓ 10你可以a 20;把房子里的东西换掉。还可以a找到这间房子的地址。而a 5更像 一个临时包裹a 5 ↓ 15 ↓ 用完它主要是为了完成某个计算。所以左值 → 有身份、可以找到 右值 → 通常是临时结果这个理解比单纯记“左边右边”好很多。六、来看几个典型例子例1int a 10;a → 左值 10 → 右值例2int b a;这里b → 左值 a → 左值注意虽然a在等号右边它仍然是左值。例3int c a b;c → 左值 a → 左值 b → 左值 a b → 右值因为a b是计算产生的临时结果。七、函数返回值也很有意思例如int add(int a, int b) { return a b; }调用int x add(3, 5);add(3, 5)产生8这个8是一个临时结果。所以add(3, 5)通常可以作为右值。八、那么问题来了右值为什么需要“引用”我们以前学过int ref a;这叫左值引用它可以理解成给一个已经存在的房子取一个别名。例如int a 10; int ref a;现在a ↓ 10 ↑ refa和ref是同一个变量。所以ref 20;实际上就是a 20;九、可是普通引用不能直接绑定普通右值例如int ref 10;通常是不允许的。因为10是一个临时结果。你不能简单地说“我要给这个临时东西起一个普通左值引用。”所以 C11 引入了右值引用写法int ref 10;注意是一个而是两个十、右值引用到底是什么例如int ref 10;可以简单理解成ref是一个专门用来接住右值的引用。图像化临时值 10 ↓ ┌─────────┐ │ 10 │ └─────────┘ ↑ │ ref所以int ref 10;就是“我允许ref接住这个临时值。”十一、为什么C需要右值引用这就回到我们上一节讲的移动构造函数假设class Box { public: int *data; Box(int n) { data new int[n]; } };现在Box a(1000000);a里面有大量资源a │ └────────→ [100万个数据]如果Box b a;通常意味着我要复制一份。于是a ─────→ [100万个数据] b ─────→ [重新复制的100万个数据]很慢。但是如果Box b 某个马上不用的Box;那么我们完全可以说“既然这个对象马上就不用了干嘛还复制直接把资源搬过来吧”这就是移动语义而 C 需要一个办法区分这个对象以后还要不要于是T表示这是一个左值我还要继续使用。而T表示这是一个右值它通常是临时的可以考虑移动它的资源。十二、这就是移动构造函数为什么写例如class Box { public: int *data; Box(Box other) { data other.data; other.data nullptr; } };看到Box other可以先理解为“我接收一个可以被搬走资源的Box。”然后data other.data;资源搬过来。再other.data nullptr;原来的对象放弃资源。于是移动前 other │ └────→ 移动后 other │ └────→ 空 新对象 │ └────→ 十三、但是有一个特别容易让人困惑的问题看看int a 10; int r a;你可能会想a不是有名字吗它是左值为什么不能绑定到右值引用答案是不能直接这样绑定。因为a是左值。而int需要右值。但是int r std::move(a);可以。这就引出了今天最重要的东西std::move()十四、std::move()到底是什么很多初学者第一次看到std::move(a)会产生一个误解“是不是把a的数据搬走了”其实不是这是一个非常重要的知识点。std::move()本身通常并不真正搬运数据。它做的事情更像“贴一个标签”例如Box a(1000000);原来a ↓ a本身是左值。调用std::move(a)相当于告诉编译器“我允许你把a当成一个可以移动资源的对象。”所以a │ ├── 本身仍然是a │ └── 只是表达式被转换成“右值形式”十五、一个特别重要的口诀请孩子一定记住std::move()不是搬家公司。它更像是“搬家许可证”。它告诉程序“这个对象我后面不再依赖它原来的资源了可以考虑把资源搬走。”真正搬东西的是移动构造函数例如Box b(std::move(a));发生的是std::move(a) ↓ “a可以被移动” ↓ 匹配 Box(Box) ↓ 移动构造函数真正接管资源 ↓ 资源发生转移十六、看看最经典的例子class Box { public: int *data; Box(int n) { data new int[n]; } Box(const Box other) { cout 拷贝构造 endl; // 这里应该复制数据 } Box(Box other) { cout 移动构造 endl; data other.data; other.data nullptr; } };然后Box a(1000000);现在a ↓ 100万个数据如果Box b(a);a是左值。所以更适合Box(const Box)也就是a ↓ 复制 ↓ b如果Box b(std::move(a));那么std::move(a) ↓ 告诉编译器a可以移动 ↓ Box(Box) ↓ 接管资源变成a ↓ 空 b ↓ 100万个数据十七、所以std::move()为什么能让移动构造函数被调用现在我们可以完整回答这个问题了。假设有Box a;直接Box b(a);这里a是左值于是编译器寻找适合左值的构造函数Box(const Box)而Box b(std::move(a));std::move(a)把表达式转换成右值更准确地说是xvalue于是Box(Box)这个移动构造函数就成为合适的匹配。所以a │ │ 左值 ↓ ┌─────────┐ │ 拷贝构造 │ └─────────┘ std::move(a) │ │ 右值形式 ↓ ┌─────────┐ │ 移动构造 │ └─────────┘十八、这里有一个进阶知识右值不只有一种如果以后看到 C 教材说左值、纯右值、将亡值不要害怕。C11之后值类别实际上可以分成表达式 │ ┌────────┴────────┐ │ │ 左值 右值 │ ┌────────┴────────┐ │ │ 纯右值 将亡值 prvalue xvalue初学阶段可以这样理解左值有身份、可以找到的对象。例如int a; a纯右值比较典型的临时值10 a b a * b将亡值它是一个已经存在但资源可以被“搬走”的对象。最典型的例子std::move(a)所以严格来说std::move(a)并不是把a变成一个“纯右值”而是把它转换成一个将亡值xvalue。对于小学生第一遍学习时记住std::move(a)让a变成“可以被移动”的右值形式。就足够了。十九、一个特别重要的坑std::move()之后还能不能使用原对象例如Box a(1000000); Box b(std::move(a));移动之后a → 资源已经被b接管 b → 拥有原来的资源那么cout a.data;能不能写这里要非常小心。移动后的对象仍然是一个有效对象但它的具体内容通常处于“有效但未指定”的状态。所以不要假设a还保持原来的数据。对于自己设计的移动构造函数我们通常会让它变成a.data nullptr;因此移动之后可以销毁它也可以重新给它赋值但不要依赖它还保留原来的资源或值。这也是为什么std::move(a);应该表达一个明确意图“a接下来不再需要原来的资源了。”二十、左值引用和右值引用放在一起比较现在把它们放一起int a 10; int r1 a; int r2 std::move(a);可以这样理解a │ ├── r1左值引用 │ └── r2右值引用它们最大的区别左值引用T右值引用T主要绑定左值右值典型用途给已有对象取别名移动语义是否可以修改通常可以通常可以典型例子int raint rstd::move(a)二十一、为什么普通的int也可以移动你可能会问int a 10; int b std::move(a);移动有什么意义其实对于int这种很小的数据移动和复制几乎没有区别。因为int ↓ 4字节左右复制一下非常快。移动语义真正厉害的地方是大数组 vector string 动态内存 文件句柄 大型资源这些资源。例如vectorint a(10000000); vectorint b std::move(a);这里移动就非常有价值。因为不需要重新复制1000万个元素。二十二、vector就是最好的现实例子例如vectorint a(1000000);大概可以想象a │ └────────→ [100万个int]然后vectorint b std::move(a);更像移动前 a ─────→ [100万个int] 移动后 a ─────→ 空/有效但未指定状态 b ─────→ [原来的100万个int]而不是a ─────→ [100万个int] b ─────→ [重新复制100万个int]所以移动语义的核心价值就是避免不必要的昂贵复制。二十三、把整个知识串起来现在我们终于可以把左值 → 右值 →→std::move()→ 移动构造全部串起来。C对象 │ ┌───────┴───────┐ │ │ 左值 右值 │ │ “我还要用” “临时/可以搬” │ │ T T │ │ │ 移动构造函数 │ │ │ ↓ │ 接管资源 │ │ └───────────────┘而std::move(a)就是在中间搭了一座桥a │ │ 左值 │ ▼ std::move(a) │ │ 转换成可移动的右值形式 │ ▼ 移动构造函数 │ ▼ 资源转移二十四、最后用一个“搬家故事”彻底记住假设小明有一辆玩具车小明 │ └── 小明还要继续玩就是一个左值因为它是小明正在使用、可以找到的东西。有一天小明决定“这辆车我不要了送给小红。”于是std::move(car)相当于小明宣布“这辆车可以搬走了”然后移动构造函数Car(Car other)相当于搬家公司来了把车直接搬给小红。最后小明 │ └── 空 小红 │ └── 注意std::move()不是搬家公司。它只是“允许搬家”的通知。真正执行资源转移的是移动构造函数。⭐ 最终记忆口诀我建议小朋友把下面这几句话背下来① 什么是左值有身份、能找到、通常可以长期使用的对象。例如int a 10; a② 什么是右值通常是临时产生的值或者可以把资源交出去的对象。例如10 a b③T是什么左值引用给一个已有对象起别名。int r a;④T是什么右值引用专门接住右值最重要的用途之一就是实现移动语义。int r 10;⑤std::move()是什么它不是搬东西而是告诉编译器“这个对象的资源可以被搬走。”std::move(a)⑥ 移动构造函数是什么接管资源而不是重新复制资源。Box(Box other) { data other.data; other.data nullptr; } 一张图总结a │ │ 左值 │ │ std::move(a) ↓ 可移动的右值 │ ↓ T │ ↓ 移动构造函数 │ ↓ 接管资源 │ ┌────────┴────────┐ ↓ ↓ 新对象 a 拿到资源 放弃资源真正理解这张图就已经把C11 移动语义最核心的逻辑串起来了左值 “我还要用它”右值 “它可以被拿走/通常是临时结果” “我可以接收可移动对象”std::move() “我允许你把它当成可移动对象”移动构造 “那我就把资源直接接过来”。