[C++] 移动语义和右值引用

移动语义和右值引用

特性说明

C++11中最为重要的特性就是移动语义和右值引用。这两者带来的革命性变化,使得其成为大家选择C++11的理由,以及提升代码效率的必备之法。

左值和右值

C++中所有的表达式和值,要么是左值,要么是右值。通俗的来说,左值指可以使用&取得其地址的“非临时对象”,而右值则是指不可用&取得其地址的“临时对象”。

int a = 0;

在上面这个例子中,a可以使用&a获得其地址,a是一个左值。而0不可以使用&00是一个右值。经过运算得到的临时对象也是一个右值,例如:

std::string s { "Hello, pangda" };
auto ns = s + "!\n";

在这里, s + "!\n"就是一个std::string类型的临时对象,于是它也是一个右值。

在之前版本的C++中,早已有了“左值引用”的概念,左值引用只可以使用“可取地址的对象”来赋值,即用左值赋值。

而在C++11中,我们引入了新的“右值引用”的概念。类比于左值引用,右值引用也仅可以使用右值赋值。我们用T &&来表示T类型的右值引用,例如:

int &&rval1 = 1;    // 正确,1是右值,可以赋值给右值引用

int a = 1;
int &&rval2 = a;    // 错误,a是左值,不可以赋值给右值引用

当然,同一类型的右值引用和左值引用是完全不同的两种类型。所以,下面的重载形式是合法的:

void f(int &a) {
  std::cout << "Left Version" << std::endl;
}

void f(int &&a) {
  f(a);       // 这里a是一个左值,使用左值版本
  std::cout << "Right Version" << std::endl;
}

int main(int argc, char *argv[]) {
  int a = 0;
  f(a);       // 左值版本
  f(a + 1);   // 注意:临时对象也是右值,所以这里是右值版本
}

正如上面注释所说的,在右值引用版本的f函数中,调用f(a)将采用左值版本的f函数。这很容易就能想明白,这是由于在这里a已经成为一个int &&类型的左值,我们可以使用&a获取它的地址。

移动语义

移动语义依赖于右值引用。移动语义可以理解为“放弃持有权而转移给其他对象”。对于一个对象来说,它持有的各种资源(堆上资源、系统对象等等),可以通过移动语义,赋予另一个对象。

移动语义是相对于拷贝语义的。在引入移动语义之前,只有拷贝语义,于是在这个例子当中:

std::string a { "hello, world" };
std::string b = a;

字符串ab的内容均为hello, world,并且在赋值之后,ab都是有效且相互独立的。在这里,operator =就是“拷贝语义”,它完全复制了字符串a中的各个部分。

通常情况下,拷贝语义已经足够了。不过,我们举一个稍显极端的例子:若之前的a字符串的长度足够长(比如:10^10),而a字符串在此之后不再使用,那么将a字符串复制一份就将是一个极大而无意义的消耗。

当然,上面的例子中可以使用swap来减小代价,像这样:

std::string a { "hello, world" };
std::string b;
b.swap(a);

但,若是函数传参的情况:

void f(std::string v);

对于函数f,将没有任何手段防止对a的复制。虽然我们可以考虑将参数改为常量引用const std::string &,但这可能会限制函数f的实现。

这时,新引入的移动语义显得极有意义,正如上面我们讨论到的,一般而言,右值均是“临时对象”,临时对象在完成其使命之后就会立即被析构,既然被析构,那么给他分配的资源也将无意义。那么为何不把给他分配的资源直接“转移”给更恒久的对象呢?

比如,既然我们可以肯定字符串a传参给函数f之后就不再使用,那么为何不直接将字符串a中所有成员直接赋值给函数的参数v呢?这样我们就不必再次分配空间,也不必再次拷贝这些内容。

当然,像之前例子的移动语义版本:

std::string a { "hello, world" };
std::string b = std::move(a);

不同于拷贝语义,此时a中的内容将不再有效,我们只能肯定b中一定为Hello, world

实现拷贝语义,我们已经很熟悉了,需要定义拷贝构造函数和拷贝赋值运算符。那么,为了实现移动语义,我们也要实现移动构造函数和移动赋值运算符。若未实现移动构造函数和移动赋值运算符而使用移动语义,那么C++调用拷贝构造函数和拷贝赋值运算符——也就是会使用拷贝语义。像这样:

class ClassName {
  // 移动构造函数的原型
  ClassName(ClassName&& str);

  // 移动赋值运算符的原型
  ClassName& operator=(ClassName&& str);
};

当然,不只是类,普通的函数和运算符也可以利用右值引用运算符实现移动语义。

std::move

概念

使用移动语义之前,我们有必要了解std::move这个标准库函数。这是一个模板函数,作用是将参数强制转换为右值。这样配合移动构造函数和移动赋值运算符,我们就可以实现移动语义。如之前的例子:

std::string a { "hello, world" };
std::string b = std::move(a);

在这里,我们将a转换为了右值,并通过移动赋值运算符进行了移动语义的赋值操作。

没有真的移动

与函数名字不同的是,std::move函数并不真的“移动”对象。例如:

std::string a { "Move Or Not" };
std::move(a);

执行std::move并没有发生任何移动,上面的代码段执行完毕之后,a中的内容不会有任何变化。std::move的功能仅仅是强制类型转换的缩写形式,也就是说,如果我将之前的例子改写为这样:

std::string a { "hello, world" };
std::string b = static_cast<std::string &&>(a);

除了形式更加繁琐之外,仍然正确地执行了移动语义的赋值操作。也就是说,真正的“移动”是通过移动赋值运算符和移动构造函数进行的,与std::move并没有关系,他只是在“显式地声明放弃控制权”。

使用移动语义提升性能

标准库部分

若你使用了C++11以及更后的版本,整个标准库已经完全地升级以支持移动语义,若你想要转交标准库容器的控制权,可以直接使用std::move。比如:

std::vector<int> deal_something(std::vector<int> numbers) {
  // do something...
  return std::move(numbers);
}

int main(int argc, char *argv[]) {
  std::vector<int> v;
  auto ret = deal_something(std::move(numbers));
}

使用这个代码,将减少vector的拷贝。

自实现部分

正如我们之前所说的,移动语义并不是凭空出现的,若你不手动地声明移动构造函数和移动赋值操作符,那么C++将会使用拷贝语义的版本。所以,若你自己实现的类也想使用移动语义,那么你需要手动实现这两个函数。

什么时候用?

到这里我们也发现了,移动语义本身并不直接具有提升性能的作用。在这之中仍然有临时对象,移动语义并不负责将对象从一个地方移动到另一个地方,而只是以更小的代价创建一个新的对象。

因此,若不能提升性能,满屏幕的std::move反而成为一种心智负担,错误的使用甚至会创造出更多的临时对象。因此,我们应当在正确的情况下使用。

我们可以分析出,首先,POD对象不适合使用移动。因为无论如何,POD对象中的成员仍需要被复制;其次,资源管理对象应当配置移动语义。一般而言这些类都会被声明为“不可复制”的,配置移动语义可以使得操作这个类更加方便;第三,需要大量的额外资源的对象可以配置移动语义,这样将提供减少不必要赋值的机会。

上面的情况其实也可以总结为,“移动”当且仅当比“拷贝”更迅速时才应当使用,若两者时间相差不大甚至“拷贝”更迅速时,采用“拷贝”是更好的选择。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值