C++ 基础知识(3)

右值引用

左值和右值

根据《C++ Primer》的说法,左值和右值可以这样区分:

一个表达式是左值还是右值,取决于我们使用的是它的值还是它在内存中的位置(作为对象的身份)。也就是说一个表达式具体是左值还是右值,要根据实际在语句中的含义来确定。例如

1
2
3
4
5
6
7
int foo(42);
int bar;

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

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

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

又有一个重要的特点,即

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

常量左值引用

常量左值引用,即const的左值引用。

右值只能绑定到右值引用上,左值引用不能绑定右值,例如:

1
2
3
int i = 42;
int &&rr = i; // ERR: i 是左值
int &r = i * 2; // ERR: i * 2 是右值

但是常量左值引用可以绑定右值:

1
2
int i = 42;
const int &r = i * 42; // OK: 可以将 const 引用绑定到右值上

可能一开始会觉得 const 引用可以绑定右值很奇怪,引用并不是对象,他只是为已经存在的对象另取一个名字而已,所以像 int &r = 42 这样的语法是不允许的,因为 42 不是一个对象,只是一个普通字面数值而已,而 const 引用却是一个例外,如果我们将这个过程拆开来看,就可以知道为什么有这个例外了:

1
2
3
4
5
6
// 我们可以先看看常量引用被绑定到另外一种类型上时到底发生了什么
double dval = 3.14;
const int &ri = dval;
// 以上将一个双精度浮点数绑定到整型引用上,所以编译器会对其进行类型转换:
const int tmp = dval; // 先将双精度浮点数变成一个整型常量
const int &ri = tmp; // 让常量引用 ri 绑定到这个临时变量上

这种情况下,ri 会被绑定到一个 临时量 (temporary)对象上,所谓临时量就是当编译器需要一个空间来暂存表达式的求值结果时临时创建的一个未命名的对象。 创建临时量是需要消耗资源的,这也是本文的主题之一:右值引用的由来。

我们再来看看如果 ri 不是常量引用会发生什么:

1
2
3
4
5
double dval = 3.14; 
int &ri = dval;
// 以上将一个双精度浮点数绑定到整型引用上,所以编译器会对其进行类型转换:
const int tmp = dval; // 还是先将双精度浮点数变成一个整型常量
int &ri = tmp; // 再让这个普通引用和 tmp 绑定

可以看到,这样的情况下,我们可以对 ri 进行赋值,因为 ri 并不是常量引用。但有两个问题:

  • tmp 是常量,而我们却用一个不是常量的引用与其绑定,这是不被允许的。
  • 就算我们可以对其进行绑定,而我们绑定的是 tmp,而不是 dval,所以我们对 ri 赋值并不会改变 dval 的值。

于是乎,编译器直接禁止了这种行为。而常引用就不一样了,反正我们也不会改变其值,只是需要知道它的值而已,并且也不违反语法规则,所以将常引用与这种临时量绑定的行为是被允许的。

通过上例我们又可以推出一个规则:引用类型必须与其所引用的对象类型保持一致,不然就会引发临时量的产生,从而引发以上两个问题。同样,常引用除外。

同理我们推出原问题的解:一个字面值如 42,也会产生一个临时量,若不是常引用,同样会引发上文提到的两个问题,所以,我们只可以用常引用绑定右值,而普通引用是不可以的。

左值引用和右值引用

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

左值引用是常见的引用,所以一般在提到「对象的引用」的时候,指得就是左值引用。如果我们将一个对象的内存空间绑定到另一个变量上,那么这个变量就是左值引用。在建立引用的时候,我们是「将内存空间绑定」,因此我们使用的是一个对象在内存中的位置,这是一个左值。因此,我们不能将一个右值绑定到左值引用上。另一方面,由于常量左值引用保证了我们不能通过引用改变对应内存空间的值,因此我们可以将右值绑定在常量引用上。

1
2
3
4
5
// lvalue-reference
int foo(42);
int& bar = foo; // OK: foo 在此是左值,将它的内存空间与 bar 绑定在一起
int& baz = 42; // Err: 42 是右值,不能将它绑定在左值引用上
const int& qux = 42; // OK: 42 是右值,但是编译器可以为它开辟一块内存空间,绑定在 qux 上

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

1
2
3
4
5
6
7
8
// rvalue-reference
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: 后置自减运算符返回的是右值,将它绑定在右值引用上

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

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

敲黑板:这是重点!

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

需要注意的是,右值引用和左值引用本身都是变量,也就是说其本身都是左值,因此不能将一个右值引用绑定到另一个右值引用上,如:

1
2
int &&rr1 = 42;   // OK: 字面量是右值
int &&rr2 = rr1; // ERR: rr1 是变量,虽然它是右值引用,但仍然是左值

右值的作用

标准库 move 函数

虽然右值引用也是变量,是左值,但是可以通过标准库里的 move 函数将左值转变为右值,使得其可以作为右值绑定到右值引用上。如:

1
2
int&& rr1 = 42;                 // 虽然 rr1 是右值引用类型,但其仍然是变量,所以还是左值
int&& rr2 = std::move(rr1); // 使用 std::move() 而不是 move()

需要注意的是,调用 move 函数之后,资源的所有权就会发生转移,原先的右值引用(如rr1)就不能再继续使用了,除非对其重新赋值(即给一份新资源)或者销毁掉。

移动构造函数

有了 move 函数,我们就可以引出移动构造函数和移动赋值运算符了,之前只是提到什么情况下可以使用右值引用,而这里就介绍右值引用该如何应用到实际程序当中了。

和拷贝构造函数不同的是,移动构造函数接受的是右值引用而非左值引用,并且经过移动构造函数,被移动的对象的资源将被”窃取“掉。在完成资源的移动之后,源对象将不在拥有任何资源,其资源所有权已经转交给新创建的对象了。

详见

移动赋值运算符

移动赋值的原理和移动构造是一样的,下面直接给出移动赋值的代码:

1
2
3
4
5
6
7
8
MyString& Mystring::operator=(MyString&& rhs) noexcept {
if (this != &rhs) {
if (string) free(string); // 先将原有资源释放
string = rhs.string;
rhs.string = nullptr;
}
return *this;
}

只需要注意自我赋值的情况即可,并且如果自己本身就持有着资源,记得一定要先释放掉。

转发

转发与 std::forward 的内容详见C++ 引用、移动、转发 (三),作者描述的非常详细,通俗易懂。但第一次接触,还是需要多看多揣摩。

参考链接

struct和class的区别
C++ 引用、移动、转发 (一)
C++ 引用、移动、转发 (二)
C++ 引用、移动、转发 (三)
谈谈c++中的右值引用