跳转至

什么是move?理解C++ Value categories,move, move in Rust

原文

https://zhuanlan.zhihu.com/p/374392832

C++和Rust里面都有move的概念,掌握move是掌握这两门语言的基石之一,应付经典面试题”浅层拷贝和深层拷贝“,小菜一碟。 很多文章一上来就介绍左值,右值,亡值,纯右值等等,很容易劝退初学者,也本末倒置了。

一句话精髓:move是为了转移对象的所有权,并不是移动对象,跟生活中的移动不一样(日常生活中的移动是把物体从一个地方变动到另外一个地方)。因此,我们可以move的对象是“没人要”的对象。("没人要“见下文讲解)

风筝是类比move最形象的例子!!如下图,一开始我开心的放着风筝,风筝这时候属于我。接着我不玩了,就把风筝送给了你,这就是move,我把风筝move给了你!

可以看到风筝一直在空中,没有被移动,但是风筝的所有权已经变成了你的。有些人会说是你“偷了”我的风筝,因为风筝不是被共享了[1]

28928-ixavdgiupei.png

形象的类比之后,让我们从技术层面真正的理解move。

想要理解C++里面的move,需要明白它是为了解决什么问题而引入。首先让我们来看看跟问题相关的概念——变量,性能与所有权。

变量是什么?

为了更好地理解,让我们先讨论讨论变量是什么。

  • 变量(variable)是有名字的对象
  • 对象是(object)存储某个类型的值的内存区域(memory)
  • (value)是按照类型进行解释的比特集合
  • 类型(type)定义一组可能的值以及一组(作用对象的)操作

这些定义是从<> by Bjarne Stroustrup 翻译而来

16308-dzijcfp6v8t.png

什么是declaration

从实用的角度,变量可以分为两类:

  1. 没有指针或引用的变量,可以称为trivial type
  2. 有指针或引用的变量,可以称handle type (一些书翻译handle为“句柄”,感觉跟“套接字”翻译一样烂)

没有指针或引用的变量,数据可以都放在栈上,或者寄存器放得下就直接放在寄存器里面。

handle type一般有两部分数据:第一部分可以放在栈上,第二部分在堆里。比如vector,见下图,栈上存放着capacity,size和指向数组的指针;堆里存放着vector存储的元素数组。

93297-sv2lvhtkosp.png

栈上的数据——就叫做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代码

let a = { name: "good"};
let b = a;
b.name = "Bad";
// a,b的名字都变成了bad.

这里变量b与a引用着同样的对象,它们就是一个引用而已,就像我们的名字。

b = a就是将这个名字给了b变量,这样b变量可以通过名字找到数据。当我们通过b修改数据后,也可以通过a看到修改后的内容,因为它们指代的是同一个东西。

很显然,这时候我们不会使用move这个词,因为b跟a指代同样的对象,是很直观的想法。

而在C++里面,类似的b = a,如下,效果却大不一样。

auto a = std::vector<int>(3,2);
auto b = a;
b[0] = 55;
// a[0] == 2但是b[0] == 55。

变量b不指代着变量a指代的对象,而是变量a指代的对象的一个复制品。

b[0] = 55只改变了复制品vector的第一个元素,而没有改变a指代的vector的第一个元素。因为C++默认的是拷贝。

借助下面的图,我们可以更好的理解这其中的区别

40086-kydxn6x56ao.png

引用和被引用的对象

其中,引用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三类,如下:

81453-yoptm8sqxk.png

其中,

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粘贴过来,提供给感兴趣的读者品尝,第一次接触的话推荐跳过。

59221-wcbz56s3mam.png

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类型的移动函数,要这么写,

void f(T&& v) {
  T v = std::move(v); //要调用移动函数,但是v是lvaue,所以我们要使用std::move
} 

根据评论的反馈,有些人不理解这段代码。理解的关键是,右值引用是一个类型,而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”):

std::string s =" hell world";
auto left = std::move(s);
//s被move了,对于string来说被设为了空string。

注意,这时候说变量s被move给了left,但是实际上,我们并没有移动s的值,而是实现了一开始Javascript代码的效果,使left指代s原来指代的值(hell world)。

C++与JavaScript的区别是,移动以后(调用移动函数以后),原来变量s指代的值的所有权转移到了left,这个变量s被改变成了”空值“的状态。

不同的类型”空值“状态不一样,所以不建议继续使用s这个变量了。如果你熟知这个”空值“状态是什么,那么你可以按照自己的需要来使用。

10748-kl657x3c1q.png

另外一篇文章: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是为了转移所有权,下面是一些常见的代码例子。

  1. 接管资源
void My::take(Book && iBook) 
{
  mBook = std::move(iBook); //将没人要的iBook,拿过来据为己有
}

2. 转移所有权

auto thread = std::thread([]{});
std::vector<std::thread> lThreadPool;
lThreadPool.push_back(std::move(thread)); //现在thread pool来掌控着thread

3. 避免拷贝

void f() {
  std::vector v = ...;
  take(std::move(v)); // 直接move进了函数g里面,不用拷贝
}

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与刻舟求剑