左值右值与移动语义

谁能想到,当const&std::move函数调用函数返回这几样东西碰在一起时,会产生多大的威力?

左值、右值、纯右值、将亡值、左指引用、右值引用、移动语义这些概念你真的懂吗?

函数返回值传递

背景知识

程序员的自我修养 第10章内存 10.2 栈与调用惯例

函数是怎么传递函数值的?

函数返回值的传递:

  • 通常的返回值,4字节由eax 寄存器传递,5~8字节由eaxedx 寄存器组合传递。

  • 当返回值类型尺寸太大,C语言会在函数返回时,使用一个临时的栈上空间作为中转,这样

    使得返回值对象会被拷贝两次。

    • 其中临时的栈上空间由进入函数之前分配好,并将其地址作为隐含参数传递进函数。
    • 最终返回值被拷贝到临时空间后,还是由eax将这个空间的地址作为返回值传递出去,函数返回后,调用者将eax 指向的临时对象再拷贝给需要的地方。造成返回值对象会被拷贝两次

从问题出发

问题1: 函数返回一个对象的过程经历了几次构造析构?

1
2
3
4
5
6
7
8
9
10
11
12
struct Test {
long a, b, c;
};

struct Test Demo1() {
struct Test t = {1, 2, 3};
return t;
}

int main() {
Test res = Demo1();
}

问题2: 函数返回具名对象和函数返回匿名对象有什么不同?

1
2
3
4
5
6
7
8
struct Test Demo1() {
struct Test t = {1, 2, 3};
return t;
}

struct Test Demo2() {
return {1, 2, 3};
}

问题3: 使用引用承接函数返回值有什么不同?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Test {
long a, b, c;
};

struct Test Demo1() {
struct Test t = {1, 2, 3};
return t;
}

int main() {
Test res1 = Demo1();
Test& res2 = Demo1();
const Test& res3 = Demo1();
Test&& res4 = Demo1();
}

问题1 函数返回一个对象的过程经历了几次构造析构?

写代码测试,补全构造析构函数相关打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <utility>

class Result {
public:
Result() { printf("constructor() [%p]\n", this); }
Result(int i): val(i) { printf("constructor(%d) [%p]\n", val, this); }

Result(Result& r): val(r.val) { printf("copy constructor, from [%p] to [%p]\n", &r, this); }
Result(Result&& r): val(r.val) { printf("move constructor, from [%p] to [%p]\n", &r, this); }
Result& operator=(const Result& r) { printf("copy assignment constructor, from [%p] to [%p]\n", &r, this); return *this;}

~Result() { printf("destructor() [%p]\n", this); }
public:
int val;
};

Result process(int i)
{
printf("In process function\n");
Result t(i); //step1
return t; //step2
}

int main()
{
Result s = process(1);//step3

printf("---vals:---\n");
printf("s addr:[%p], val:[%d]\n", &s, s.val);
}

编译运行,关闭返回值优化和编译器优化

1
g++ main.cpp -std=c++11 -fno-elide-constructor -O0  && ./a.out

结果输出

1
2
3
4
5
6
7
8
9
10
In process function
constructor(1) [0x7ff7b218bb20]
move constructor, from [0x7ff7b218bb20] to [0x7ff7b218bb60]
destructor() [0x7ff7b218bb20]
move constructor, from [0x7ff7b218bb60] to [0x7ff7b218bb68]
destructor() [0x7ff7b218bb60]

---vals:---
s addr:[0x7ff7b218bb68], val:[1]
destructor() [0x7ff7b218bb68]

可以看到step1调用了带参数的构造函数,构造了对象0x7ff7b218bb20。然后step2返回时,构造了一个临时对象0x7ff7b218bb60,这个对象(是一个右值)调用了移动构造函数。step3又调用了一次移动构造函数,构造了对象0x7ff7b218bb68。

这个过程发生了两次拷贝:

  • 第一次从t到匿名临时对象
  • 第二次从匿名临时对象到s

总共构造了三个对象:

  • 函数内的局部对象t,step1的0x7ff7b218bb20
  • 用作函数临时返回值的对象,0x7ff7b218bb60
  • 承接返回值的对象s,0x7ff7b218bb68

问题2 函数返回具名对象和函数返回匿名对象有什么不同?

补全代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Result process(int i)
{
printf("In process function\n");
Result t(i); //step1
return t; //step2
}

Result process1(int i)
{
printf("In process1 function\n");
return Result(i); //step4
}

int main()
{
Result s = process(1); //step3
Result s1 = process1(9); //step5

printf("---vals:---\n");
printf("s addr:[%p], val:[%d]\n", &s, s.val);
printf("s1 addr:[%p], val:[%d]\n", &s1, s1.val);
}

编译运行

1
g++ main.cpp -std=c++11 -fno-elide-constructors -O0 && ./a.out

结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
In process function
constructor(1) [0x7ff7badabb20]
move constructor, from [0x7ff7badabb20] to [0x7ff7badabb60]
destructor() [0x7ff7badabb20]
move constructor, from [0x7ff7badabb60] to [0x7ff7badabb68]
destructor() [0x7ff7badabb60]

In process1 function
constructor(9) [0x7ff7badabb20]
move constructor, from [0x7ff7badabb20] to [0x7ff7badabb48]
destructor() [0x7ff7badabb20]
move constructor, from [0x7ff7badabb48] to [0x7ff7badabb50]
destructor() [0x7ff7badabb48]

---vals:---
s addr:[0x7ff7badabb68], val:[1]
s1 addr:[0x7ff7badabb50], val:[9]
destructor() [0x7ff7badabb50]
destructor() [0x7ff7badabb68]

从输出上来看,两种方式并没有什么不同,都是经历了两次拷贝,为什么会这样呢?

函数process和process1的不同就在于函数内部构造的对象是否有名,process1构造的对象0x7ff7badabb20无名,但这不影响传递返回值时的临时匿名对象0x7ff7badabb48的构造。

返回值优化

我们知道,如果是较小字节的函数返回值,通过寄存器传递。如果是较大字节的返回值,通过在栈空间构建,寄存器返回对象指针,中间发生了两次拷贝。

有什么办法能够减少拷贝呢?有的,直接将临时对象构造在承接返回值的对象上。

编译器很早就发现了这个问题,有了返回值优化,-fno-elide-constructors 这个编译选项用于禁用构造函数省略优。

1
g++ main.cpp -std=c++11 -O0  && ./a.out

结果输出:

1
2
3
4
5
6
7
8
9
In process function
constructor(1) [0x7ff7b144eb68]
In process1 function
constructor(9) [0x7ff7b144eb60]
---vals:---
s addr:[0x7ff7b144eb68], val:[1]
s1 addr:[0x7ff7b144eb60], val:[9]
destructor() [0x7ff7b144eb60]
destructor() [0x7ff7b144eb68]

可以看到整个过程就调用了一次构造函数,无论是pross函数中的具名对象还是process1函数中的匿名对象,它们都被直接构造在承接返回值的对象中了。

这个优化,一个叫做NRVO(Named Return Value Optimization),另一个叫做RVO(Return Value Optimization)。

问题3 使用引用承接函数返回值有什么不同?

补全代码,实际上s2的写法是不对的,报错提示:用非const左值引用绑定马上被析构的匿名临时对象。

1
main.cpp:59:11: error: non-const lvalue reference to type 'Result' cannot bind to a temporary of type 'Result'

注释s2后再运行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Result process(int i)
{
printf("In process function\n");
Result t(i);
return t;
}

int main(){
Result s1 = process(1);
// Result& s2 = process(2);
Result&& s3 = process(3);
const Result& s4 = process(4);

printf("---vals:---\n");
printf("s1 addr:[%p], val:[%d]\n", &s1, s1.val);
// printf("s2 addr:[%p], val:[%d]\n", &s2, s2.val);
printf("s3 addr:[%p], val:[%d]\n", &s3, s3.val);
printf("s4 addr:[%p], val:[%d]\n", &s4, s4.val);
}

编译运行

1
g++ main.cpp -std=c++11 -fno-elide-constructors -O0 && ./a.out

输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
In process function
constructor(1) [0x7ff7bc432b00]
move constructor, from [0x7ff7bc432b00] to [0x7ff7bc432b60]
destructor() [0x7ff7bc432b00]
move constructor, from [0x7ff7bc432b60] to [0x7ff7bc432b68]
destructor() [0x7ff7bc432b60]

In process function
constructor(3) [0x7ff7bc432b00]
move constructor, from [0x7ff7bc432b00] to [0x7ff7bc432b40]
destructor() [0x7ff7bc432b00]

In process function
constructor(4) [0x7ff7bc432b00]
move constructor, from [0x7ff7bc432b00] to [0x7ff7bc432b30]
destructor() [0x7ff7bc432b00]

---vals:---
s1 addr:[0x7ff7bc432b68], val:[1]
s3 addr:[0x7ff7bc432b40], val:[3]
s4 addr:[0x7ff7bc432b30], val:[4]
destructor() [0x7ff7bc432b30]
destructor() [0x7ff7bc432b40]
destructor() [0x7ff7bc432b68]

(1)诶,怎么回事,怎么用一个右值引用去绑定函数返回值,就只需要一个构造函数?

实际上,s3绑定到了匿名临时对象上,这个匿名临时对象因为被绑定有了名字,所以不再是匿名对象,同时也不再是临时对象,生命周期延长至main函数结束。

(2)为啥常引用和右值引用是一个效果?为什么const修饰的引用可以绑定函数的返回值?

关于这点,趋向于从语言设计层面解答。

虽然引用本质上就是指针的语法糖,但 C++并不满足于此,它为了让「语义」更加接近人类的直觉,它做了这样一件事:让用const修饰的引用可以绑定函数的返回值。

从语义上来说,它不希望我们程序员去区分「寄存器返回值」还是「内存空间返回值」,既然是函数的返回值,你就可以认为它是一个「纯值」就好了。或者换一个说法,如果你要屏蔽寄存器这一层的硬件实现,我们就不应该区分寄存器返回值还是内存返回值,而是假设寄存器足够大,那么函数返回值就一定是个「纯值」。那么这个「纯值」就叫做 rvalue。

这里再回过头来看一下,刚才我们说「函数返回值是 rvalue」这事好像就有一点问题了。从理论上来理解用一个变量或引用来接收一个 rvalue 这种说法是没错的,但其实编译期并不是单纯根据函数返回值这一件事来决定如何处理的,而是要带上上下文(或者说,返回值的长度以及使用返回值的方式)。所以单独讨论f()是什么值类型并没有意义,而是要根据上下文。我们总结如下:

  1. 常量一定是 prvalue(比如说1'a'5.6f)。
  2. 变量、引用(包括常引用)都是 lvalue,哪怕是用于接受函数返回值,它也是 lvalue。(这里一种情况是通过寄存器复制过来的,但复制完它已经成为变量了,所以是 lvalue;另一种是直接把变量地址传到函数中去接受返回值的,这样它本身也是 lvalue)。
  3. 只有当仅使用返回值的一部分(类似于f().a的形式)的这种情况,会使用临时空间(匿名的,会在当前语句结束后析构),这种情况下的临时空间是 xvalue。

回头看问题1,我们可以得出这样一个结论:问题1的代码等同于先声明一个右值引用临时对象_tmp,再用它去构造s对象

1
2
3
4
5
6
7
8
9
10
11
int main()
{
// Result s = process(1);
// 等同于
Result&& _tmp = process(1);
Result s(std::move(_tmp));

printf("---vals:---\n");
printf("_tmp addr:[%p], val:[%d]\n", &_tmp, _tmp.val);
printf("s addr:[%p], val:[%d]\n", &s, s.val);
}

编译运行

1
g++ main.cpp -std=c++11 -fno-elide-constructors -O0 && ./a.out

输出

1
2
3
4
5
6
7
8
9
10
11
In process function
constructor(1) [0x7ff7b833eb20]
move constructor, from [0x7ff7b833eb20] to [0x7ff7b833eb60]
destructor() [0x7ff7b833eb20]
move constructor, from [0x7ff7b833eb60] to [0x7ff7b833eb58]

---vals:---
_tmp addr:[0x7ff7b833eb60], val:[1]
s addr:[0x7ff7b833eb58], val:[1]
destructor() [0x7ff7b833eb58]
destructor() [0x7ff7b833eb60]

对比问题1的输出,可以看到这个_tmp就是匿名的临时变量。

1
2
3
4
5
6
7
8
9
10
In process function
constructor(1) [0x7ff7b218bb20]
move constructor, from [0x7ff7b218bb20] to [0x7ff7b218bb60]
destructor() [0x7ff7b218bb20]
move constructor, from [0x7ff7b218bb60] to [0x7ff7b218bb68]
destructor() [0x7ff7b218bb60]

---vals:---
s addr:[0x7ff7b218bb68], val:[1]
destructor() [0x7ff7b218bb68]

这里可能又有小伙伴会问,为什么要用std::move_tmp的类型不是Result&&是一个右值吗?

准确的来说,右值引用声明的变量是左值,因为它是有地址的。


举一反三

问题4 函数返回对象和对象的引用,有什么不同?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Vector func1() {
Vector a;
return a;
}

Vector func2() {
Vector a;
return std::move(a);
}

Vector&& func3() {
Vector a;
return std::move(a);
}

int main() {
Vector test1 = func1();
Vector test2 = func2();
Vector test3 = func3();
}

补全测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Result func1(int i)
{
printf("In func1 function\n");
Result t(i);
return t;
}

Result func2(int i)
{
printf("In func2 function\n");
Result t(i);
return std::move(t);
}

Result&& func3(int i)
{
printf("In func3 function\n");
Result t(i);
return std::move(t);
}

int main()
{
Result s1 = func1(1);
Result s2 = func2(2);
Result s3 = func3(3);

printf("---vals:---\n");
printf("s1 addr:[%p], val:[%d]\n", &s1, s1.val);
printf("s2 addr:[%p], val:[%d]\n", &s2, s2.val);
printf("s3 addr:[%p], val:[%d]\n", &s3, s3.val);
}

编译运行

1
g++ main.cpp -std=c++11 -fno-elide-constructors -O0 && ./a.out

输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
In func1 function
constructor(1) [0x7ff7b7970b10]
move constructor, from [0x7ff7b7970b10] to [0x7ff7b7970b60]
destructor() [0x7ff7b7970b10]
move constructor, from [0x7ff7b7970b60] to [0x7ff7b7970b68]
destructor() [0x7ff7b7970b60]

In func2 function
constructor(2) [0x7ff7b7970b10]
move constructor, from [0x7ff7b7970b10] to [0x7ff7b7970b48]
destructor() [0x7ff7b7970b10]
move constructor, from [0x7ff7b7970b48] to [0x7ff7b7970b50]
destructor() [0x7ff7b7970b48]

In func3 function
constructor(3) [0x7ff7b7970b18]
destructor() [0x7ff7b7970b18]
move constructor, from [0x7ff7b7970b18] to [0x7ff7b7970b40]

---vals:---
s1 addr:[0x7ff7b7970b68], val:[1]
s2 addr:[0x7ff7b7970b50], val:[2]
s3 addr:[0x7ff7b7970b40], val:[-1214837952]
destructor() [0x7ff7b7970b40]
destructor() [0x7ff7b7970b50]
destructor() [0x7ff7b7970b68]

从结果上看,func1和func2毫无差别,这是因为函数内的t都被移动给了匿名临时变量。

但是func3返回的是右值引用,std::move将t变成了右值,从而s3绑定到了函数内部的局部对象t,这是危险的行为。右值引用也是引用

小结

问题1:函数返回对象的细节。函数返回对象时会利用一块空间构造匿名临时对象,再将匿名对象拷贝回外部承接返回值的对象上。

问题2:函数返回值优化。编译器为你默默完成了一切,承接返回值的对象将被直接构造临时对象上。

问题3:右值引用将延长对象声明周期。函数返回对象时构造的匿名对象是右值,当你用右值引用承接函数返回值时,相当于接管了临时对象资源,延长其声明周期。

问题4:右值引用也是引用。

在编译器函数返回值优化技术下,返回一个对象最好的方式就是不要优化,不需要使用std::move,编译器会为你完成一切。

1
2
3
4
5
6
7
struct Test Demo1() {
return Test{1, 2, 3};
}

int main() {
Test res = Demo1();
}

函数参数传递

如下代码会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Result process(int i)
{
printf("In process function\n");
Result t(i);
return t;
}

Result process1(int i)
{
printf("In process1 function\n");
return Result(i);
}

void func1(Result t){
return;
}

void func2(Result& t){
return;
}

void func3(const Result& t){
return;
}

void func4(Result&& t){
return;
}

void func5(const Result&& t){
return;
}

int main(){
Result a(1);
Result&& b = Result(2);

printf("----------\n");
func1(a);
printf("----------\n");
func2(a);
// func2(std::move(a)); // 左值引用无法匹配右值
printf("----------\n");
func3(a);
func3(std::move(a)); // 常引用可以承接右值
printf("----------\n");
// func4(a); // 无法将右值引用绑定到左值
func4(std::move(a));
// func4(b); // b虽然是右值引用类型,本质还是左值,是一个可以取地址的对象
printf("----------\n");
// func5(b); // b左值无法匹配右值类型
func5(std::move(a));
}

测试

1
g++ main.cpp -std=c++11 -fno-elide-constructors -O0 && ./a.out

结果

1
2
3
4
5
6
7
8
9
10
11
constructor(1) [0x7ff7b2611ba8]
constructor(2) [0x7ff7b2611b98]
----------
copy constructor, from [0x7ff7b2611ba8] to [0x7ff7b2611b88]
destructor() [0x7ff7b2611b88]
----------
----------
----------
----------
destructor() [0x7ff7b2611b98]
destructor() [0x7ff7b2611ba8]

从中可以知道:

  • 左值引用不能匹配右值,但是常引用可以

  • 右值引用匹配的是右值,如果要匹配一个左值,需要使用std::move将其转化为右值

  • 右值引用类型的变量,它是一个左值,有地址

  • 右值引用仍然是引用,不会产生拷贝

右值引用用于匹配右值,而并非表示一个右值。因此,尽量不要声明右值引用类型的变量,而只在函数形参使用它以匹配右值。

左值右值的广泛定义

C++在C++98时便遵循C模型,引入了左值、右值的概念。

  • 左值(lvalue) :表达式结束后依然存在的持久对象。
  • 右值(rvalue) :表达式结束后就不再存在的临时对象。

常见右值:字面量(字符字面量除外)、临时的表达式值、临时的函数返还值。

更直观的理解是:有变量名的对象都是左值,没有变量名的都是右值。

值得注意的是,字符字面量是唯一不可算入右值的字面量,因为它实际存储在静态内存区,是持久存在的。

左值、纯右值、将亡值

1
2
3
4
5
6
7
8
9
10
       expression

lvalue rvalue
│ │
┌─────┴─────┐ └─────┐
│ │ │
lvalue xvalue prvalue
│ │
└─────┬─────┘
glvalue

C++11使用下面两种独立的性质来区别类别:

  1. 拥有身份:指代某个非临时对象,可被标识,有地址。
  2. 可被移动:可被右值引用类型匹配。

每个C++表达式只属于三种基本值类别中的一种:左值 (lvalue)、纯右值 (prvalue)、将亡值 (xvalue)

  • 拥有身份且不可被移动的表达式被称作 左值 (lvalue) 表达式,指持久存在的对象或类型为左值引用类型的返还值。

  • 拥有身份且可被移动的表达式被称作 将亡值 (xvalue) 表达式,一般是指类型为右值引用类型的返还值。

  • 不拥有身份且可被移动的表达式被称作 纯右值 (prvalue) 表达式,也就是指纯粹的临时值(即使指代的对象是持久存在的)。

  • 不拥有身份且不可被移动的表达式无法使用。

为什么要将右值分出来纯右值和将亡值呢?因为将亡值,比如我们函数中的临时返回值,确确实实是有地址的。纯右值,比如汇编语言中的立即数,它是存在寄存器中的,没有地址。

这里简单区别下:

  • 纯右值没有内存实体,比如寄存器中的数据
  • 将亡值比如函数返回值的匿名空间

将亡值和左值区别在于将亡值生命周期更短,一般活不过一行语句。

右值引用与移动语义

AI生成

右值引用是移动语义的语言机制

语法基础:T&& 声明右值引用类型

绑定规则:只能绑定到右值(prvalue、xvalue)

生命周期延长:右值引用可以延长临时对象的生命周期

移动语义是右值引用的应用目的

资源转移:高效转移资源所有权

避免拷贝:对于管理资源的对象特别重要

性能优化:减少动态内存分配和复制

它们的关系如同锁和钥匙

右值引用是钥匙(访问移动权限的凭证)

移动操作是锁(实际转移资源的机制)

std::move() 是制造钥匙的工具(将左值转为右值)

实际应用中的关系链

1
2
3
4
5
6
7
8
9
10
11
左值对象

std::move() // 产生xvalue

右值引用绑定 // T&& 接受xvalue

移动构造函数/赋值被调用 // Resource(Resource&&)

资源高效转移 // 真正的"移动"

源对象置空 // 进入有效但空的状态

这种机制使得C++能够在不牺牲安全性的前提下,达到与C语言手动管理内存相近的性能。

总结

从左值右值的关系,我们可以先入为主的出发,将右值分为纯右值与将亡值。

然后再讲述函数返回值与将亡值的关系,再提一嘴函数返回值优化。

接着,从右值引用承接函数返回值出发,说出右值引用可以延长对象声明周期。

但是,最好不要声明右值引用的类型,只利用它来匹配右值。

最后讲述右值引用与std::move的关系。

reference

作者

Desirer

发布于

2026-01-03

更新于

2026-01-25

许可协议