什么是move?理解C++ Value categories,move, move in Rust
原文¶
https://zhuanlan.zhihu.com/p/374392832
C++和Rust里面都有move的概念,掌握move是掌握这两门语言的基石之一,应付经典面试题”浅层拷贝和深层拷贝“,小菜一碟。 很多文章一上来就介绍左值,右值,亡值,纯右值等等,很容易劝退初学者,也本末倒置了。
一句话精髓:move是为了转移对象的所有权,并不是移动对象,跟生活中的移动不一样(日常生活中的移动是把物体从一个地方变动到另外一个地方)。因此,我们可以move的对象是“没人要”的对象。("没人要“见下文讲解)
风筝是类比move最形象的例子!!如下图,一开始我开心的放着风筝,风筝这时候属于我。接着我不玩了,就把风筝送给了你,这就是move,我把风筝move给了你!
可以看到风筝一直在空中,没有被移动,但是风筝的所有权已经变成了你的。有些人会说是你“偷了”我的风筝,因为风筝不是被共享了[1] 。
形象的类比之后,让我们从技术层面真正的理解move。
想要理解C++里面的move,需要明白它是为了解决什么问题而引入。首先让我们来看看跟问题相关的概念——变量,性能与所有权。
变量是什么?¶
为了更好地理解,让我们先讨论讨论变量是什么。
- 变量(variable)是有名字的对象
- 对象是(object)存储某个类型的值的内存区域(memory)
- 值(value)是按照类型进行解释的比特集合
- 类型(type)定义一组可能的值以及一组(作用对象的)操作
这些定义是从<
什么是declaration
从实用的角度,变量可以分为两类:
- 没有指针或引用的变量,可以称为trivial type
- 有指针或引用的变量,可以称handle type (一些书翻译handle为“句柄”,感觉跟“套接字”翻译一样烂)
没有指针或引用的变量,数据可以都放在栈上,或者寄存器放得下就直接放在寄存器里面。
handle type一般有两部分数据:第一部分可以放在栈上,第二部分在堆里。比如vector,见下图,栈上存放着capacity,size和指向数组的指针;堆里存放着vector存储的元素数组。
栈上的数据——就叫做handle,用来控制(handle)堆里面的数据——所以它是堆数据的所有者(owner)。
如果用开头的风筝类比,风筝就是存放在堆里的数据,“你我”就是handle,引用着(牵着)数据(风筝)。注意“你我”是引用/指针,而风筝是被引用的对象。
(注意,第一部分数据也可以放堆里,比如auto v = new std::vector<int>()
vector的第一部分数据就放在了堆里。这里为了简洁和容易理解,统一放在栈上)
move是针对handle type的,因为对于trivial type,move不会带来好处。为什么呢?阅读完本文,你应该可以可以自己回答这个问题了。
为什么Python,JavaScript,Java等等都没有move这个概念呢?因为在这些有GC的语言里面,它们处理handle type的时候,赋值都是"move"——将引用复制给另外一个变量。比如下面JavaScript代码
这里变量b与a引用着同样的对象,它们就是一个引用而已,就像我们的名字。
b = a就是将这个名字给了b变量,这样b变量可以通过名字找到数据。当我们通过b修改数据后,也可以通过a看到修改后的内容,因为它们指代的是同一个东西。
很显然,这时候我们不会使用move这个词,因为b跟a指代同样的对象,是很直观的想法。
而在C++里面,类似的b = a,如下,效果却大不一样。
变量b不指代着变量a指代的对象,而是变量a指代的对象的一个复制品。
b[0] = 55只改变了复制品vector的第一个元素,而没有改变a指代的vector的第一个元素。因为C++默认的是拷贝。
借助下面的图,我们可以更好的理解这其中的区别
引用和被引用的对象
其中,引用a和引用b表示的JavaScript的关系,变量a和变量b是C++里面的关系。我们可以看到引用a和引用b都引用着同一个内容,而变量a和变量b却是两个不同的vector。
为了性能,区分栈和堆是必须的,但不同语言的解决的方法各有不同。(有GC的语言为了更好地管理内存,基本不做区分——不用关心是在栈上还是堆里,因为有GC帮忙管理着内存。这也是我们不会在有GC的语言里面提move这个概念的原因。)
JavaScript只拷贝了栈上面的引用,而C++既拷贝了栈那部分数据也拷贝了堆里面的内容。
很显然如果每次都拷贝堆里面的内容,性能不行。所以在某些场景下,C++也要实现类似JavaScript代码的效果——只拷贝栈上的数据。
比如,当变量a不想拥有vector的时候,它可以把vector直接送给变量b,就像我把风筝送给你一样,从而可以免去了拷贝风筝的花销。性能也就提高了!
C++/Rust有所有权的概念,当我们只拷贝栈上的数据的时候,如果什么都不做,两个变量(如变量a和变量b)就会指代同一个对象,这时候谁拥有这个堆里的数据呢?(注意此刻不讨论智能指针)。
因此,C++11 引入了move的概念,value categories也做了调整,从而规定哪些value可以被move。
只有理解了Value categories,才能更好地理解C++ 的move。下面让我们先看看value categories是什么。
Value Categories¶
C++ 的Value Categories实际上是对表达式(expression)分类(下文为了方便,将值/value与表达式等同)。
一个表达式具有类型和Value Category两个属性。表达式在C++里面可以组成语句,比如语句int a = 3+3;
3+3是表达式。a
既是变量又是表达式。当a被用于计算的时候,a是value。
可以认为Value是某一个时刻的,而variable是一系列时刻组成的——当我们明白value categories是对expression分类的时候,就会理解了——因为讨论expression的时候,讨论的是具体某一个时刻。
C++ 11 引入了右值引用(&&),将value category从以前左值(lvalue)和右值(rvalue)的两类变成lvalue,xvalue,prvalue三类,如下:
其中,
prvalue就是用于计算的或者用于初始化对象的,p代表的是Pure。
比如4,5,6,7这样的数字,或者4+6+7这样的表达式,或者&取到的地址 &a,或者用于构建对象的值{ 1, 2, 3},或者函数的返回值T func()
。
在特定的上下文中,prvalue会实体化产生一个临时变量,比如赋给一个引用,比如取成员变量。这也就可以解释下面的编译错误:
rvalue.cpp:2:10: error: non-const lvalue reference to type 'int' cannot bind to
a temporary of type 'int'
int & r = 3;
为什么int &r = 3会报错呢?而const int &r =3不会报错?
下面是prvalue的实体化发生的具体情况,从cppreference粘贴过来,提供给感兴趣的读者品尝,第一次接触的话推荐跳过。
xvalue 就是临时变量(temporary object)。它是快要被销毁的值,x 代表expiring,所以可以被重新使用。
常见的xvalue有:由prvalue实体化创建的临时变量,函数内的局部变量被return的时候(在它生命的最后时刻);std::move修饰的表达式;返回类型为T&&的函数的调用, T&& f()。
lvalue就是左值,l代表left,指在内存中具有位置的值。
比如所有有名字的变量的expression都是左值。你可以考别人”Book&& v“,这里的v是什么值。答案是因为v是变量,那么它的表达式v是左值,因为你可以获取v的地址。
每一类值对应不同的构造方法:
- prvalue初始化glvalue,是直接构建,不用调用拷贝或移动构造函数;
- xvalue初始化lvalue,会调用移动函数,也就是move;
- lvalue初始化lvalue,会调用拷贝构造函数。
它们三者与move的关系是:
- xvalue可以被直接move;
- prvalue被move的时候,可以理解生成了一个临时变量xvalue,这个临时变量xvalue被move了;
- 如果move lvalue,那么需要使用std::move将lvalue变成xvalue,从而move生成后的xvalue。
因此当别人问我们什么是xvalue,我们可以轻松的告诉他/她:xvalue是可以直接move的expression。切到move,那么就是你的主场了。
这三种值,又可以归为两类:rvalue和glvalue。其中
prvalue和xvalue又叫rvalue,我们常说的右值引用,就是对prvalue和xvlaue的引用,我戏称它们是”没人要的孩子“。因为它们可以直接被move,“好可怜”。
另外要注意的是,右值引用是对rvalue的引用,但是这个引用却是lvalue,所以下面的代码如果要调用T类型的移动函数,要这么写,
根据评论的反馈,有些人不理解这段代码。理解的关键是,右值引用是一个类型,而v是一个变量,所以是左值——一个引用了右值的左值变量。所以我们需要在前面加上std::move。
xvalue和lvalue又叫glvalue,g代表general的意思,也就是更广泛意义上的lvalue,它可以用来获得内存位置。
明白了value categories以及如何move各种值,接下来让我们看看move的真面目~
Move in C++¶
C++ std::move的作用,比如下面的代码,可以理解为告诉编译器,我现在将变量s变成一个xvalue,并将它赋给变量left,请调用left类型的移动函数来操作并管理s指代的资源(”hell world”):
注意,这时候说变量s被move给了left,但是实际上,我们并没有移动s的值,而是实现了一开始Javascript代码的效果,使left指代s原来指代的值(hell world)。
C++与JavaScript的区别是,移动以后(调用移动函数以后),原来变量s指代的值的所有权转移到了left,这个变量s被改变成了”空值“的状态。
不同的类型”空值“状态不一样,所以不建议继续使用s这个变量了。如果你熟知这个”空值“状态是什么,那么你可以按照自己的需要来使用。
另外一篇文章:C++:Rule of five/zero 以及Rust:Rule of two,讲述了C++这样的移动方法带来的两个必须要掌握的移动函数:移动赋值函数和移动构造函数。
所以,C++的move改变了值的所有权,通过的是调用相应的移动函数(具体请看C++:Rule of five/zero 以及Rust:Rule of two),比如b = std::move(a)调用了b类型的移动赋值函数。
既然要转移所有权,那么我们必须要从可以转移所有权的变量转移所有权。
什么变量可以被转移所有权呢?那就是rvalue,也就是prvalue和xvalue,也就是没人要的”孩子“。
接下来让我们看看一些常用的使用姿势,加深理解。
C++ move的常用姿势¶
C++ move是为了转移所有权,下面是一些常见的代码例子。
- 接管资源
2. 转移所有权
auto thread = std::thread([]{});
std::vector<std::thread> lThreadPool;
lThreadPool.push_back(std::move(thread)); //现在thread pool来掌控着thread
3. 避免拷贝
4. 移动函数,负责具体move发生了什么
当你想要转移资源所有权的时候,你会选择move。实际move发生的细节是由移动函数实现的。
下面的代码实现了当book被move的时候,做了特别的事情,推荐运行代码,研究研究输出为什么count从1变成了3。(这个例子是为了说明move的具体实施者是谁,实际的代码要根据需求编写移动函数)
class Book {
int mCount {1};
std::string mName;
public:
std::string& get_name() {
return mName;
}
Book(std::string iName): mName(iName) {}
Book(Book&& iBook) {
swap(this->mName, iBook.mName);
mCount = 3;
}
int get_count() {
return mCount;
}
};
int main() {
Book b("Im");
Book tb = std::move(b);
std::cout<<"old b name is " << b.get_name()<< " count is "<< b.get_count();
std::cout<<"\ntb name is " << tb.get_name()<< " count is "<< tb.get_count();
}
Move By Default in Rust¶
Rust的move跟C++的目的是一样的,Rust跟C++的区别是Rust将原来的变量a设置为不可访问的状态(因为原来的变量a已经没有任何东西可用了)并且赋值默认是move语义 ,从而更容易理解。
在C++大家都说”move根本没有移动,std::move其实cast成右值",但是在Rust里面,move不需要这些转弯!
因为Rust是move by default——默认跟有GC的语言一样,b = a
让变量b指代变量a指代的对象。但是它又借鉴了C++,move以后,变量a与原来的值就没有任何关系了,值的所有权变更到新的主人(变量b)。
所以,可以理解为a被move给了b,既然被move了,a也就不能访问了。是不是很直观?
比如下面的代码Rust会报错" borrow of moved value: s
"
fn take_ownership(new_s: String)
{
}
fn main()
{
let mut s = "ddd".to_owned();
take_ownership(s);
s.push('d'); // 这里会报错
}
因为String s已经被takeownership函数给move了,说明所有权已经被移动到take_ownership函数里面,原来的s不含有任何东西了,所以不能继续对s进行任何操作了。
也正因为Rust是move by default,所以没有Value Categories,也不用像C++那样定义移动构造函数,移动赋值函数,变得更简单和容易理解。对Rust感兴趣的可以阅读Rust那些难理解的点(更新于6月14日)
Move与copy elision¶
C++ 很早就有了copy elision优化,而这优化与move交织在一起,相互影响着,更多梳理请看Copy/move elision: C++ 17 vs C++ 11
Move总结¶
所以编程语言里的move跟我们日常生活的直观理解是有点区别的。我们生活中移动,是把物体从一个地方变动到另外一个地方。而编程语言的move是改变物体(值)的所有权,而物体(值)在内存中没有变动过(存储在堆里那部分数据没有变动,在栈上的数据被拷贝了)。
稍微展开开头的一句话精髓:move是转移对象的所有权,所以我们可以move的是可以转移所有权的对象。
C++,我们可以移动右值,也可以把左值通过std::move变成右值,从而可以实现移动。
Rust,我们可以直接移动变量,但是被移动过的变量就不能再次使用了,因为所有权已经转移,它没权利去操作任何东西了。
明白了move真正的作用是转移所有权,这向我们打开了另一扇大门——通过所有权来管理资源,感兴趣请阅读RAII:如何编写没有内存泄漏的代码 with C++
(Rust关于移动有趣的一个特性,叫Pin,对于刚接触的人来说,比较难理解,写了一篇Pin与刻舟求剑