欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

C++11中有关右值引用的学习

程序员文章站 2022-03-26 15:55:37
...

1. 左值和右值的区别

左值和右值,最早是从 C 语言继承而来的。在 C 语言,或者继承版本的解释中,

  • 左值是可以位于赋值运算符 = 左侧的表达式(当然,左值也可以位于 = 的右侧),但是
  • 右值是不可以位于赋值运算符 = 左侧的表达式。

对于这个经典的解释,我们有如下示例

int foo(42);
int bar(43);

// foo, bar 都是左值
foo = bar;
bar = foo;
foo = foo * bar;

// foo * bar 是右值
int baz;
baz = foo * bar;  // OK: 右值在赋值运算符右侧
foo * bar = 42;   // Err: 右值在赋值运算符左侧

这个解释很经典,也容易懂。不过在 C++ 里面,左值和右值不能这样定义。根据《C++ Primer》的说法,左值和右值可以这样区分:

一个表达式是左值还是右值,取决于我们使用的是它的值还是它在内存中的位置(作为对象的身份)。

也就是说一个表达式具体是左值还是右值,要根据实际在语句中的含义来确定。例如:

int foo(42);
int bar;

// 将 foo 的值赋给 bar,保存在 bar 对应的内存中
// foo 在这里作为表达式是右值;bar 在这里作为表达式是左值
// 但是 foo 作为对象,既可以充当左值又可以充当右值
bar = foo;

因为 C++ 中的对象本身可以是一个表达式,所以这里有一个重要的原则,即

  • 在大多数情况下,需要右值的地方可以用左值来替代,但
  • 需要左值的地方,一定不能用右值来替代

又有一个重要的特点,即

  • 左值存放在对象中,有持久的状态;而
  • 右值要么是字面常量,要么是在表达式求值过程中创建的临时对象,没有持久的状态。

2. 左值引用和右值引用

在 C++ 中,有两种对对象的引用:左值引用和右值引用

左值引用是常见的引用,所以一般在提到「对象的引用」的时候,指得就是左值引用。如果我们将一个对象的内存空间绑定到另一个变量上,那么这个变量就是左值引用。在建立引用的时候,我们是「将内存空间绑定」,因此我们使用的是一个对象在内存中的位置,这是一个左值。因此:

  • 我们不能将一个右值绑定到左值引用上。
  • 另一方面,由于常量左值引用保证了我们不能通过引用改变对应内存空间的值,因此我们可以将右值绑定在常量引用上。
//左值引用
int foo(42);
int& bar = foo;  // OK: foo 在此是左值,将它的内存空间与 bar 绑定在一起
int& baz = 42;   // Err: 42 是右值,不能将它绑定在左值引用上
const int& qux = 42;  // OK: 42 是右值,但是编译器可以为它开辟一块内存空间,绑定在 qux 上,qux是常量左值引用,能够保证不修改对应内存空间的值

右值引用也是引用,但是它只能且必须绑定在右值上

int foo(42);
int& bar = foo;        // OK: 将 foo 绑定在左值引用上
int&& baz = foo;       // Err: foo 可以是左值,所以不能将它绑定在右值引用上
int&& qux = 42;        // OK: 将右值 42 绑定在右值引用上
int&& quux = foo * 1;  // OK: foo * 1 的结果是一个右值,将它绑定在右值引用上
int& garply = foo++;   // Err: 后置自增运算符返回的是右值,不能将它绑定在左值引用上
int&& waldo = foo--;   // OK: 后置自减运算符返回的是右值,将它绑定在右值引用上

由于右值引用只能绑定在右值上,而右值要么是字面常量,要么是临时对象,所以:

  • 右值引用的对象,是临时的,即将被销毁;并且
  • 右值引用的对象,不会在其它地方使用。

敲黑板:这是重点!

这两个特性意味着:接受和使用右值引用的代码,可以*地接管所引用的对象的资源,而无需担心对其他代码逻辑造成数据破坏。

3. 引用的值类型和引用叠加

3.1 值类型

我们思考一个问题:右值引用本身是左值还是右值?或者可以先思考一下它的对偶问题:左值引用本身是左值还是右值?
先看下面的代码:

//左值引用的值类型
int foo(32);
int& bar = foo;   // bar 是对foo 的左值引用
int& baz = bar;   // baz 是对bar 的左值引用,因此bar 是左值
int qux = ++foo;  \
// 前置自增返回的是左值引用,这里赋给qux,此时左值引用作为右值

观察上面代码,不难发现,左值引用本身既可以是左值,又可以是右值。它具体是左值还是右值,依然取决于它作为表达式时候的作用。更仔细地观察可以发现,如果左值引用作为一个变量被保存下来了,那么它就可以是左值(当然也可以起到右值的作用);而如果左值引用是一个临时变量(例如函数的返回值),那么它就是右值(如int qux = ++foo;

同理可以用在右值引用上:

class Type;
void foo(Type&& bar) {
    // 将右值引用作为 Type 的构造函数的参数
    // 此时匹配 Type::Type(const Type& orig), 即拷贝构造函数
    // bar 是左值
    Type baz(bar);
}
Type&& qux(); 	// 这里是函数
quux = qux();   // qux 的返回值是 Type 类型的右值引用,此时右值引用是右值

和左值引用一样,右值引用本身也既可以作为左值也可以作为右值。并且,同样的是:如果右值引用作为变量被保存下来了,那么应该把它当做是一个左值看待;否则应当作为右值看待。

因此,不论是左值引用还是右值引用,都有

  • 当引用作为变量被保存下来,那么它是左值;否则
  • 它是右值。

3.2 引用叠加

(to do…)

4. 右值引用怎么用

说了这么多右值引用的概念,应该说点实际的用途了,这样右值引用这件事情看起来才会显得自然。

4.1 move语义

假设有如下代码:

#include <iostream>
#include <string>

class Container {
 private:
  typedef std::string Resource;

 public:
  // 默认构造函数
  Container() {
    resource_ = new Resource;
    std::cout << "default constructor." << std::endl;
  }
  // 拷贝构造函数,不允许隐式转换
  explicit Container(const Resource& resource) {
    resource_ = new Resource(resource);
    std::cout << "explicit constructor." << std::endl;
  }
  ~Container() {
    delete resource_;
    std::cout << "destructor" << std::endl;
  }
  Container(const Container& rhs) {
    resource_ = new Resource(*(rhs.resource_));
    std::cout << "copy constructor." << std::endl;
  }
  Container& operator=(const Container& rhs) {
    delete resource_;
    resource_ = new Resource(*(rhs.resource_));
    std::cout << "copy assignment." << std::endl;
    return *this;
  }

 private:
  Resource* resource_ = nullptr;
};

Container foo() {
  Container ret("tag");    //调用默认构造函数
  return ret;   //返回调用拷贝构造
}

int main() {
  Container bar;  //调用默认构造
  bar = foo();
  return 0;
}

运行的结果是:

default constructor.
explicit constructor.
copy assignment.
destructor
destructor

在执行 bar = foo() 的时候,会进行这样的操作:

  • 从函数返回值中得到临时对象 rhs;
  • 销毁 bar 中的资源(delete resource_;);
  • 将 rhs 中的资源拷贝一份,赋值给 bar 中的资源(resource_ = new Resource(*(rhs.resource_)););
  • 销毁 rhs 这一临时对象。

仔细想想你会发现,销毁 bar 中的资源,再从临时对象中复制相应的资源,这件事情完全没有必要。我们最好能直接抛弃 bar 中的资源而后直接接管 foo 返回的临时对象。这就是 move 语义。

这样一来,就意味着我们需要重载 Container 类的赋值操作符,它应该有这样的函数声明:
Container& Container::operator=(<mystery type> rhs)
为了与拷贝版本的赋值运算符区分,我们希望,当 Container::operator= 的右操作数是右值引用时,调用这个版本的赋值运算符,那么毫无疑问, 应该是 Container&&。于是我们定义它(称为移动赋值运算符,以及同时定义移动构造函数):

#include <iostream>
#include <string>

class Container {
 private:
  typedef std::string Resource;

 public:
  Container() {
    resource_ = new Resource;
    std::cout << "default constructor." << std::endl;
  }
  explicit Container(const Resource& resource) {
    resource_ = new Resource(resource);
    std::cout << "explicit constructor." << std::endl;
  }
  ~Container() {
    delete resource_;
    std::cout << "destructor" << std::endl;
  }
  Container(const Container& rhs) {
    resource_ = new Resource(*(rhs.resource_));
    std::cout << "copy constructor." << std::endl;
  }
  Container& operator=(const Container& rhs) {
    delete resource_;
    resource_ = new Resource(*(rhs.resource_));
    std::cout << "copy assignment." << std::endl;
    return *this;
  }
  Container(Container&& rhs) : resource_(rhs.resource_) {
    rhs.resource_ = nullptr;
    std::cout << "move constructor." << std::endl;
  }
  Container& operator=(Container&& rhs) {
    Resource* tmp = resource_;
    resource_ = rhs.resource_;
    rhs.resource_ = tmp;
    std::cout << "move assignment." << std::endl;
    return *this;
  }

 private:
  Resource* resource_ = nullptr;
};

Container foo() {
  Container ret("tag");
  return ret;
}

int main() {
  Container bar;  // default constructor.
  bar = foo();
  return 0;
}

// $ ./a.out
// default constructor.
// explicit constructor.
// move assignment.
// destructor
// destructor

这里程序的运行逻辑是这样的:

  • 首先 程序第一行Container bar会调用默认构造函数
  • 然后程序第二行调用foo()函数
    • 函数里面第一行调用explicit constructor
  • 回到主程序bar = foo()这里,调用移动赋值函数
  • 然后析构函数删除foo的临时变量
  • 最后deletebar

由此可见,这相当于我们将临时对象 rhs 中的资源「移动」到了 foo 当中,避免了销毁资源再拷贝赋值的开销。

5. 完美转发(to do)

6. std::move

标准库还定义了 std::move 函数,它的作用就是将传入的参数以右值引用的方式返回。

template<class T>
typename std::remove_reference<T>::type&&
std::move(T&& a) noexcept
{
  typedef typename std::remove_reference<T>::type&& RvalRef;
  return static_cast<RvalRef>(a);
}

首先,出现了两次 std::remove_reference<T>::type&&,它确保不论 T 传入的是什么,都将返回一个真实类型的右值引用。static_cast<RvalRef>(a) 则将 a 强制转换成右值引用并返回。有了 std::move,我们就可以调用 std::unique_ptr 的移动赋值运算符了(当然,单独这样调用可能没有什么意义):

std::unique_ptr<Type> new_ptr = std::move(old_ptr);
// old_ptr 应当立即被销毁,或者赋予别的值
// 不应对 old_ptr 当前的状态做任何假设

在这里,因为使用了 std::move 窃取了 old_ptr 中的资源,然后将他们移动到了 new_ptr 中去。这就隐含了一层意思:接下来我们不会在用 old_ptr 做任何事情,除非我们显式地对 old_ptr 赋予新值。事实上,我们不应对 old_ptr 当前的状态做任何假设,它就和已定义但未初始化的状态一样。因为,old_ptr 当前的状态,完全取决于 std::unique_ptr<Type>::operator=(unique_ptr<Type>&&) 的行为

7. 额外的例子

这里参考知乎的一个回答:

如何评价 C++11 的右值引用(Rvalue reference)特性? - Tinro的回答 - 知乎
https://www.zhihu.com/question/22111546/answer/30801982

8. 参考

  1. 谈谈 C++ 中的右值引用
相关标签: C++