Modern C++ 学习笔记 -- 左值与右值

左值(lvalues)与右值(rvalues)的概念

左值和右值是Modern C++中引入的新概念。简而言之:

  • 左值位于等号左边,我们可以对左值进行取地址操作。
  • 右值位于等号右边,本质上是一个数值,即 literal constant,我们没法对它进行取地址操作。
1
int x = 999; // x 是左值, 999是右值

我们,可以把左值大致想象为一个容器,而容器中存放的就是右值,如果没有这个容器,其中的右值将会失效。对于以下的程序,我们在编译的时候将会得到类似的错误:

1
error: lvalue required as left operand of assignment

1
2
int x;
123 = x;

很显然,等号左边需要的是一个左值,而123作为一个 literal constant 类型,是没有办法充当左值的。同样,我们也没法对一个右值进行取地址的操作:

1
2
int *x;
x = &123; //无法对右值取地址

编译器报错:

1
error: lvalue required as unary '&' operand

左值到右值的隐式转换

左值在很多情况下有可能被转换为右值,比如在C++中的 - 运算符,它将两个右值作为参数,并将计算结果作为右值返回。

1
2
3
int x = 10;
int y = 5;
int z = x - y;

在上面的程序片段中,我们明显看到x, y本身是左值,但是却以右值的身份参与了减法运算。这是如何做到的呢?答案是编译器对左值做了隐式的转换,将x和y转换成为了右值。C++中其他的乘法,除法和加法运算也是同样如此。

如果左值能被转换成右值,那么右值本身能被转换成左值吗?答案是 否定 的.

左值引用与右值引用

C++中引入引用的概念,是为了在程序中方便的通过引用修改原变量的值,并且,在调用方法传参的过程中,传递引用可以避免拷贝。在通常情况下,左值引用只能指向左值,而右值引用只能指向右值。听起来比较废话,但是也有特殊的情况。

左值引用

1
2
3
int x = 10;
int& ref_x = x;
ref_x++;

在上面的示例程序中,我们定义了一个左值x,然后赋值10。随后定义了一个引用,指向x。因此,ref_x成为x的引用,它就叫做左值引用。通过操作ref_x,我们就可以改变x的值。

如果我们把上面的程序简化为:

1
int& ref_x = 10;

在编译的时候,我们会得到类似的错误:

1
cannot bind non-const lvalue reference of type 'int&' to an rvalue of type 'int'

显然,左值引用只能指向一个左值,而不能指向一个右值。不错从错误信息中,我们方法可以得出另外一种写法:

1
const int& ref_x = 10;

根据编译器的规则,我们被允许通过定义一个const类型的左值来指向右值。不过既然这个左值被定义成了const,没有办法修改指向的值。

右值引用

C++中的右值引用以&&表示。通过右值引用,可以修改其指向的右值。

1
2
int&& ref_x = 10; //定义右值引用
ref_x--; //通过右值引用修改其指向的右值

如果我们尝试将右值引用指向一个左值:

1
2
int x = 10;
int&& ref_x = x;

编译器也会抛出类似的错误,告诉我们不能把一个右值引用指向左值。

1
error: cannot bind rvalue reference of type 'int&&' to lvalue of type 'int'

左右值引用的本质

通过一个简单的示例程序,我们就能知道左值引用和有值引用的本质。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void increase(int&& input) {
input++;
}

int main() {
int x = 10;

int& ref_a = &x;
int&& ref_b = std::move(x);

increase(x); // 编译错误,不能传入左值
increase(ref_a); // 编译错误,不能传入 左值引用
increase(ref_b); // 编译错误,右值引用本身是一个左值

increase(std::move(a)); // 编译通过
increase(std::move(ref_a)); // 编译通过
increase(std::move(ref_b)); // 编译通过

increase(7); //编译通过,7是右值

return 0;
}

从上面的代码示例中,我们可以看出右值引用 ref_b 本身也是一个左值,在调用 increase 的时候,需要通过 std::move 转换为右值,编译器才不会报错。

通过以上的例子,我们可以总结出如下的规律:

  • 左右值引用的引入,都是为了避免拷贝。
  • 左值引用通常指向左值,通过添加 const 关键字约束也能指向右值,不过无法对右值进行修改。
  • 右值引用本质上也是一个左值,右值引用通常情况下指向右值,不过通过 std::move 等形式也可以指向左值。

右值引用与移动语义

在前面的例子中,我们已经涉及到了 std::move 这样的操作。右值引用配合 std::move 通常能实现移动语义,从而实现避免拷贝,提升程序性能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <iostream>
#include <vector>

int main() {
std::vector<std::string> list;

std::string str_a = "Hello";
std::string str_b = "World";

list.push_back(str_a);
list.push_back(std::move(str_b));

std::cout << "str_a: " << str_a << std::endl;
std::cout << "str_b: " << str_b << std::endl;
std::cout << "list[0]: " << list[0] << std::endl;
std::cout << "list[1]: " << list[1] << std::endl;
return 0;
}

如果运行上面的示例程序,我们会得到这样的程序输出:

1
2
3
4
str_a: Hello
str_b:
list[0]: Hello
list[1]: World

很明显,在str_a被添加到vector的时候,并没有涉及到移动语义,所以str_a的值被拷贝到了vector中。而在把str_b添加到vector的过程中,由于用到了std::move,所以str_b的值被移动到了vector中。之后再输出vector的值的时候,可以看到其中已经包含了str_astr_b的值。但是str_b本身的值已经被偷走了。

需要注意的是,std::move本身的名字比较有迷惑性,其实它在这里的工作只是把str_b从左值转换成右值,而不会做实际上移动资源的操作。如果我们把添加str_b的代码替换成:

1
list.push_back(static_cast<std::string&&>(str_b));

会达到一样的效果。而真正的秘密在于 std::vector 提供的两种不同的重载方法:

1
2
void push_back( const T& value );
void push_back( T&& value );

第一个重载方法接受的是左值引用,当传入 str_a 的时候,由于 const 关键字的限制,它的值会被拷贝一份,并放入到vector中,而 str_a 本身的值并不受影响。而第二个重载方法接受的是一个右值引用,push_back方法会把其值放入vector中,并转移 str_b 对字符串值 World 的所有权。这样,当我们再输出它的值的时候,发现已经为空了。

完美转发(std::forward)

完美转发(Perfect Forwarding),转发的意义在于当一个方法把其参数传递给另外一个方法的时候,不光转发参数本身,还包括其属性(左值引用保持左值引用,右值引用保持右值引用)。

std::forward 实际上也是做类型的转换,不同的是 std::move 只把左值转换为右值,std::forward 能转换为左值或右值。

std::forward<T>(arg) 中,如果类型 T 是左值,参数 arg 将被转换为左值,否则 arg 将被转换为右值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <utility>

void target_func(int& arg) { std::cout << "lvalue reference" << std::endl; }

void target_func(int&& arg) { std::cout << "rvalue reference" << std::endl; }

template <typename T> void forward(T&& arg) {
target_func(std::forward<T>(arg));
}

int main() {
forward(5);
int x = 5;
forward(x);
return 0;
}

在以上的示例程序中,forward 使用一个Universal Reference类型接受一个参数,并且通过 std::forward 讲参数转发给 target_func。由于此方法有两个重载,分别接受左值引用和右值引用。在我们分别传入右值 5 和左值 x 的时候,我们发现 forward 这个方法都能准确无误的把参数转发给对应的重载方法。因此,程序的输出分别是:

1
2
rvalue reference
lvalue reference

参考文章

  1. Understanding the meaning of lvalues and rvalues in C++
  2. Cpp Reference
  3. Perfect Forwarding
支持原创技术分享,据说打赏我的人,都找到了女朋友!