从汇编的角度看CPP函数传参和返回值--prvalue和xvalue和移动语义std::move的故乡
ref
- 腾讯的这篇知乎回答从汇编角度讲解函数返回值,直击右值引用本质,本文只是此篇回答部分内容的总结 https://www.zhihu.com/question/428340896/answer/2913419725
- 腾讯的这篇知乎文章从高级语言的语音层面讲述左值 右值和move,https://zhuanlan.zhihu.com/p/335994370
前言
- 没有汇编基础的同学先看此篇总结再看本文 https://www.cnblogs.com/yiwanfengweng/articles/18616685
- 本文的例子全部基于https://godbolt.org/将CPP代码编译为汇编代码来分析,编译时使用x86-64 GCC 14.2版本,这个GCC版本的默认C++标准是C++17:
- 本文无特殊说明,使用默认的编译选项,即使用GCC14.2 C++17标准将CPP代码编译为汇编
- 偶尔会使用编译选项,例如-fno-elide-constructors -std=c++14和-fno-elide-constructors,目的是禁止编译器的返回值优化RVO,C++14自身没有RVO但是编译器自作主张给它RVO了,C++17标准里纳入了RVO。
Value Category
自C++11引入的将亡值(xvalue, expiring, ex开头的字母的缩写通常是x)概念,每个人第一眼看到下图都会懵圈,表达式(Expression)分为泛左值(glvaue, general)和右值(rvalue)两大类,泛左值分为左值(lvaue, left)和将亡值,右值分为将亡值和纯右值(prvalue, pure)。
这些分类有一个统称的名字--Value Category,是C++在值(Value)/表达式上,继值类型(Value Type)之后新增的一个属性。
最让初学者头痛的问题来了,为什么xvalue即是glvalue又是rvalue?
这些令人困惑的Value Category要从函数返回值说起。

C/C++的局部变量和入参(此小节和左右值无关,但原理相通,可不看)
C++函数的局部变量按照函数中声明定义的先后顺序,依次从高地址到低地址遵循某一内存对齐规则存放在栈帧中。(C++的内存对齐规则待新开文章探究...)
void demo(int in1, char in2) {
int a = 0;
long b = 1;
short c = 2;
}
demo(int, char):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-36], edi //通过edi传递第一个参数 int in1
//并存放到[rbp-36]位置的栈上
mov eax, esi
mov BYTE PTR [rbp-40], al //通过esi传递第二个参数 char in2
//并存放到[rbp-40]位置的栈上
mov DWORD PTR [rbp-4], 0 //局部变量 int a,可见rbp-8 到 rbp-4的这4 Byte空间因为内存对齐是空的
mov QWORD PTR [rbp-16], 1 //局部变量 long b
mov WORD PTR [rbp-18], 2 //局部变量 short c
nop
pop rbp
ret
C++函数的6个及以内参数在RDI RSI RDX RCX R8 R9寄存器能装下的情况下,参数从左到右依次放到RDI RSI RDX RCX R8 R9寄存器中传递给下一个调用栈,
超出的参数按照倒序压入栈中,使用栈空间向下一个栈传参。
void demo(int a, int b, int c, int d, int e, int f, int g, int h) {
int sum = a + b + c + d + e + f + g + h;
}
void demo_caller() {
demo(1,2,3,4,5,6,7, 8);
}
demo(int, int, int, int, int, int, int, int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-20], edi//第1个参数
mov DWORD PTR [rbp-24], esi
mov DWORD PTR [rbp-28], edx
mov DWORD PTR [rbp-32], ecx
mov DWORD PTR [rbp-36], r8d
mov DWORD PTR [rbp-40], r9d//第6个参数
mov edx, DWORD PTR [rbp-20]
mov eax, DWORD PTR [rbp-24]
add edx, eax
mov eax, DWORD PTR [rbp-28]
add edx, eax
mov eax, DWORD PTR [rbp-32]
add edx, eax
mov eax, DWORD PTR [rbp-36]
add edx, eax
mov eax, DWORD PTR [rbp-40]
add edx, eax
mov eax, DWORD PTR [rbp+16]//rbp向上8Byte是上个栈的rbp地址,rbp+8向上8Byte是上个栈的rip地址
//rbp+16向上4Byte是上个栈传递给当前栈的第7个参数
add edx, eax
mov eax, DWORD PTR [rbp+24]//rbp+24向上4Byte是上个栈传递给当前栈的第8个参数
add eax, edx
mov DWORD PTR [rbp-4], eax
nop
pop rbp
ret
demo_caller():
push rbp
mov rbp, rsp
push 8 //栈上传递的参数是靠后的参数先入栈,这样靠前的参数可以先出栈
push 7
mov r9d, 6
mov r8d, 5
mov ecx, 4
mov edx, 3
mov esi, 2
mov edi, 1
call demo(int, int, int, int, int, int, int, int)
add rsp, 16
nop
leave
ret
在参数较为复杂,无法被寄存器传递时,主调函数会在本栈中构造出需要传递给被调函数的参数,通过rdi把参数的内存地址传递给被调函数,被调函数调用结束之后主调函数会立刻销毁参数的空间。
struct Test {
long a;
long b;
long c;
};
long demo(struct Test t) {
long sum = t.a + t.b + t.c;
return sum;
}
void demo_caller() {
struct Test t{1,2,3};
int x = demo(t);
}
demo(Test):
push rbp
mov rbp, rsp
mov rdx, QWORD PTR [rbp+16]//被调函数的实参实际上在主调函数的栈上
mov rax, QWORD PTR [rbp+24]
add rdx, rax
mov rax, QWORD PTR [rbp+32]
add rax, rdx
mov QWORD PTR [rbp-8], rax
mov rax, QWORD PTR [rbp-8]//把计算结果通过rax返回给主调函数
pop rbp
ret
demo_caller():
push rbp
mov rbp, rsp
sub rsp, 32//rbp-4是int x的起始地址,rbp-32是局部变量t的起始地址
mov QWORD PTR [rbp-32], 1//给t赋值
mov QWORD PTR [rbp-24], 2//给t赋值
mov QWORD PTR [rbp-16], 3//给t赋值
sub rsp, 24//在本栈构造传递给被调函数的参数
mov rcx, rsp//rcx存放着参数的起始地址
mov rax, QWORD PTR [rbp-32]
mov rdx, QWORD PTR [rbp-24]
mov QWORD PTR [rcx], rax//给参数赋值
mov QWORD PTR [rcx+8], rdx//给参数赋值
mov rax, QWORD PTR [rbp-16]
mov QWORD PTR [rcx+16], rax//给参数赋值
call demo(Test)
add rsp, 24//立即销毁传递给被调函数的参数的空间
mov DWORD PTR [rbp-4], eax//rbp-4实际上就是int x
nop
leave
ret
C/C++的返回值的实现方式
- 通过寄存器eax返回
- 稍大的值通过两个寄存器rax 和rdx返回
- 复杂的返回值的返回方式有点类似于传递复杂的参数(即被调函数的出参),主调函数的栈会开辟出用于存放返回值的空间,并通过rdi把空间的地址传递给被调函数,被调函数直接把返回值写入该空间,然后把该空间的地址通过rax返回给主调函数, 和传参不同点在于返回值在被调函数返回之后不会立刻销毁,因为返回值本质上是主调函数的具名局部变量。
- 只需要更大的值的返回值的一部分,行为类似上条,但是整个返回值变成了一个匿名变量(这个匿名空间没有被销毁,因为它没有析构函数)。
int demo() {
return 5;
}
struct Test {
long a, b;
};
struct Test demo2() {
struct Test t{1,2};
return t;
}
struct BigTest {
long a, b, c;
};
struct BigTest demo3() {
struct BigTest t{1,2,3};
return t;
}
void demo3_caller() {
struct BigTest t = demo3();
}
void demo3_caller2() {
long r = demo3().a;
r++;
}
demo():
push rbp
mov rbp, rsp
mov eax, 5//通过eax返回
pop rbp
ret
demo2():
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-16], 1
mov QWORD PTR [rbp-8], 2
mov rax, QWORD PTR [rbp-16]//通过rax和rdx返回结构体
mov rdx, QWORD PTR [rbp-8]
pop rbp
ret
demo3():
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi //主调函数的栈上给返回值开辟了空间,并通过rdi传递返回值的地址给rdi
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], 1//直接把返回值写入到主调函数的栈上
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+8], 2//直接把返回值写入到主调函数的栈上
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], 3//直接把返回值写入到主调函数的栈上
nop
mov rax, QWORD PTR [rbp-8]//把返回值的地址通过rax返回给主调函数
pop rbp
ret
demo3_caller():
push rbp
mov rbp, rsp
sub rsp, 32//开辟被调函数返回值的空间
lea rax, [rbp-32]
mov rdi, rax//通过rdi传递返回值的地址给rdi
call demo3()
nop
leave
ret
demo3_caller2():
push rbp
mov rbp, rsp
sub rsp, 32//开辟被调函数返回值的空间,
lea rax, [rbp-32]
mov rdi, rax
call demo3()//通过rdi传递返回值的地址给rdi
mov rax, QWORD PTR [rbp-32]
mov QWORD PTR [rbp-8], rax
add QWORD PTR [rbp-8], 1
nop
leave
ret
有构造和析构函数之后的的返回值
复杂参数和复杂返回值在不考虑类的构造和析构时完全可以按照上面两小节的汇编实现方式,但是如果有了析构和构造函数,作为局部变量的对象的构造和析构是发生在当前函数的作用域里的,上面两小节的方式里,当前函数作用域
的局部变量的内存地址存在于上一个函数栈中,复杂参数的传参还好(毕竟被调函数结束时,位于主调函数的参数会被立马回收销毁),但是复杂返回值即是主调函数的局部变量也是被调函数的局部变量,被调函数开始时这个返回值会被构造函数访问(或修改),结束时这个返回值是会被析构函数访问(或修改),使得这个返回值失去了意义。
为了保证对象行为周期的闭环,C++会多一次拷贝构造来避免上述返回值问题。
这里需要提一下平凡类型(Trivial),C++面对平凡类型还是采用函数出参的方式返回,面对非平凡类型则会多一次拷贝构造。平凡类型是一种创建、复制、移动和销毁可以通过简单的内存操作来实现的类型,默认构造函数、复制构造函数、移动构造函数、复制赋值运算符、移动赋值运算符和析构函数都不是用户定义的,而是编译器提供的(隐式的)。
编译器就是通过是否是平凡类型来决定是否按照C的方式来编译代码。
以下是非平凡类型的返回值实现
非平凡类型和平凡类型的返回值是类似的,但是加上了一个拷贝构造函数,被调函数里的返回值通过拷贝构造函数拷贝到主调函数的栈上。
如果主调函数只需要非平凡类型的某一部分的值返回,那么主调函数上的返回值会是一个匿名临时变量,在被调函数返回后会立刻销毁这个匿名临时变量,这就是将亡值!
//编译器选项是-fno-elide-constructors,还是使用的默认C++17标准,这个编译器选项是关闭编译器的返回值优化(Return Value Optimize, RVO),看到C++11和C++14语言的标准行为,C++17在标准上也纳入了这个RVO。
struct Test {
Test() {}
~Test() {}
long a, b, c;
};
struct Test demo() {
struct Test t;
return t;
}
void demo_caller() {
struct Test x = demo();
}
void demo_caller2() {
long r = demo().a;
}
Test::Test() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::~Test() [base object destructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::Test(Test const&) [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi //this 指针
mov QWORD PTR [rbp-16], rsi//参数的指针
mov rcx, QWORD PTR [rbp-8]
mov rsi, QWORD PTR [rbp-16]
mov rax, QWORD PTR [rsi]
mov rdx, QWORD PTR [rsi+8]
mov QWORD PTR [rcx], rax//赋值
mov QWORD PTR [rcx+8], rdx//赋值
mov rax, QWORD PTR [rsi+16]
mov QWORD PTR [rcx+16], rax//赋值
nop
pop rbp
ret
demo():
push rbp
mov rbp, rsp
sub rsp, 48
mov QWORD PTR [rbp-40], rdi//在主调函数栈上的返回值地址,实际上就是局部变量x
lea rax, [rbp-32]//被调函数的局部变量t
mov rdi, rax//t的地址传参给构造函数
call Test::Test() [complete object constructor]
lea rdx, [rbp-32]
mov rax, QWORD PTR [rbp-40]
mov rsi, rdx//被调函数的局部变量t的地址通过rsi传参给拷贝构造函数,即this指针
mov rdi, rax//主调函数的局部变量x的地址通过rdi传参给拷贝构造函数
call Test::Test(Test const&) [complete object constructor]
lea rax, [rbp-32]
mov rdi, rax//被调函数的局部变量t的地址通过rdi传参给析构函数
call Test::~Test() [complete object destructor]
nop
mov rax, QWORD PTR [rbp-40]//通过rax返回返回值的地址
leave
ret
demo_caller():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]//取主调函数的局部变量x的地址通过rdi传递给被调函数
mov rdi, rax
call demo()
lea rax, [rbp-32]
mov rdi, rax//取主调函数的局部变量x的地址给析构函数
call Test::~Test() [complete object destructor]
leave
ret
demo_caller2():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]//取主调函数的匿名局部变量的地址通过rdi传递给被调函数
mov rdi, rax
call demo3()
mov rax, QWORD PTR [rbp-32]
mov QWORD PTR [rbp-8], rax//将匿名局部变量的部分值给局部变量r
lea rax, [rbp-32]
mov rdi, rax//取主调函数的匿名局部变量的地址给析构函数
call Test::~Test() [complete object destructor]
nop
leave
ret
demo_caller2():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]//取主调函数的匿名局部变量的地址通过rdi传递给被调函数
mov rdi, rax
call demo()
mov rax, QWORD PTR [rbp-32]
mov QWORD PTR [rbp-8], rax//将匿名局部变量的部分值给局部变量r
lea rax, [rbp-32]
mov rdi, rax//取主调函数的匿名局部变量的地址通过rdi传递给析构函数
call Test::~Test() [complete object destructor] //匿名局部变量在被调函数调用结束之后就会被立刻析构!!!这是就是将亡值!!!梦开始的地方!!!
add QWORD PTR [rbp-8], 1 //给局部变量r加1
nop
leave
ret
左值(Left-hand-side Value)、纯右值(Pure Right-hand-side Value)、将亡值(Expiring Value)
通过上节内容可以归纳出以下关键信息:
- 返回值通过寄存机传递给上一个栈;
- 返回值会被当做出参,直接写入上一个栈的空间里,如果是非平凡值,写入过程里有拷贝构造函数的参与;
- 只需要返回值的一部分,返回值也会被当做出参,但是在上一个栈里是个匿名临时变量,如果是非平凡值,这个匿名临时变量会在被调函数返回后立刻销毁;
本质上对应了函数返回数据的3个方式:
- 存放在寄存器
- 直接操作于主调函数接受返回值的的变量
- 如果主调函数的变量是个非平凡类型,会被立刻析构销毁
C++根据以上方式来定义了prvalue、lvalue和xvalue:
- 第一种返回值是prvalue,prvalue是用寄存器保存的值,也叫字面量(常量)
- 无论是寄存器的值,还是一个常量,C/C++都是没办发去更改的(高级语言无法直接修改寄存器),所以prvalue是一个没有内存实体承载的值;
- 第二种返回值是lvalue,因为返回值本质上是主调函数的普通局部变量,它有实际的内存地址,而且被调函数返回后不会被立刻析构,只会在主调函数作用域结束时才析构;
- 第三种返回值是xvalue,被调函数返回后主调函数上的匿名临时局部变量就会被立刻析构销毁,它的确是有实际的内存地址的,所以被称为将亡值;
rvalue无法被取址
prvalue没有内存实体所以无法被取地址,xvalue有内存实体为什么也无法被取地址?
在C里面无法通过一个demo()来确定返回值是写到局部变量还是匿名局部变量里,需要完整上下文,即struct Test x = demo()或者long r = demo().a来确定。所以如果只给编译器一个demo(),1)编译器是不知道返回值的确切实现方式的;2)此外函数的返回值也有可能是一个pvalue,pvalue没有实体内存,不能被取地址,所以综合上述两个因素C/C++不支持&demo()的写法。
C++的(左值)引用的本质
C++引入了引用的概念,语义上是让x的引用成为x的别名,但是实际实现上却无法真的给变量起别名,所以使用指针语法糖实现引用
int a = 5;
int &r = a;
语义上,a是一个变量,r是该变量别名,对r的任何行为等价于对a的任何行为。实际上是定义了一个新的变量pr,初始化位a的内存地址,对p的任何行为等价于对*pr的任何行为,本质上就取地址和解指针的语法糖。
既然本质是指针,那么指针的解类型是可以手动定义的,所以引用的类型也是可以手动定义的(所以语义上的别名不是真的别名,不然怎么可以手动定义类型?)
int a = 5;
char &r = reinterpret_cast<char &>(a); //手动定义引用的类型不会报错
//上面这行代码等的本质是以下这行代码
char *pr = reinterpret_cast<char *>(&a);
指针的解类型可以强转,自然可以隐式转换,可变指针可以隐式转换为不可变(const)指针,所以可变引用也可以隐式转换为不可变(const)引用。
int a = 5;
const int &r = a;
//等价于
const int &r = const_cast<const int &>(a);
//等价于
const int *pr = &a;
//等价于
const int *pr = const_cast<const int *>(&a);
上节讲到rvalue不能取地址,所以我们不可以用一个普通引用来接受函数返回值:
Test &r = demo(); //报错!因为它等价于
Test *pr = &demo(); //报错,所以上面也不可以
常引用与右值(Right-hand-side Value)
引用的本质是指针语法糖,但是C++为了让语义贴近人类认知直觉,规定了const修饰的引用可以绑定函数的返回值。
从语义上来讲,C++不希望用户去区分返回值是寄存器里的还是内存空间里的,C++希望通过const 引用让用户认为函数返回值是一个纯值,假设寄存器无限大,函数返回值就是一个纯值,这个纯值被统称为右值rvalue。
换句话说,语言设计层面,函数返回值是一个rvalue;编译器实现层面,根据返回值的大小决定了是寄存器返回还是放上一个栈的内存里返回,寄存器返回是pvalue,内存里返回是xvalue。
常量引用也是引用,是引用就一定有取地址和解指针操作,绑定函数返回值的时候,假设返回值是寄存器返回,没有内存地址,常量引用应该怎么取地址呢?
- 如果函数通过寄存器返回,那么就会把寄存器的值复制到一片内存空间里,常量应用取的就是这片内存的地址;
- 如果函数通过出参方式返回,那么常量应用取的就是出参的内存地址;
所以使用常量引用取接受函数返回值,本质上和使用普通变量接受函数返回值是一样的!以下代码的汇编指令是一样的
Test demo1() {
Test t;
return t;
}
const Test &t1 = demo1();
//上下两行代码在返回值的实现上汇编指令是一样的,不同在于&t1 除了返回值本身的空间,还有个8字节空间用于存储返回值的地址
const Test t2 = demo1();
int demo2() {
return 1;
}
const int &x1 = demo2();
//上下两行代码在返回值的实现上汇编指令是一样的,不同在于&x1 除了返回值本身的空间,还有个8字节空间用于存储返回值的地址
const int x2 = demo2();
上面的示例中,无论是t1还是t2,无论是x1还是x2,本质上都是普通的局部变量,拥有内存实体,声明周期跟随栈空间,所以都是左值lvalue,即引用本身是lvalue。函数的返回值可能是xvalue或pvalue,但是如果用引用接受返回值,返回值就会变成lvalue。
关于value category的总结
上节说到函数返回值是pvalue或xvalue(两者统称rvalue),无法通过&func()取返回值地址, 又讲到如果用局部变量或者常引用接收返回值的话返回值就变成lvalue;我们单单讨论func()的返回值是什么类型的value没有意义,要结合对返回值的使用方式(即上下文)才能作出判断。
以下是对于值的类型的判断总结:
- 常量是prvalue(类似1, 'a', 5.6f)
- 变量、引用(包括常引用)都是lvalue,即使用于接受函数返回值,它也是lvalue
- 只有使用返回值的一部分的时候(类似func().a),会使用临时匿名变量,函数返回后立刻析构,此时临时匿名变量是xvalue
引入xvalue来找补
C++设计时本想把value分成两类:变量和值。变量有内存实体,可以在赋值语句的左边,称为左值;值没有内存实体,只能出现在赋值语句的右边,称为右值。
但是实现时发现,受到硬件的的约束(寄存器不能无限大),导致不能把所有的右值按照统一的方式来处理,所以强行划分出prvalue和xvalue,划分的目的是给类的引入做找补,保证对象系统的自闭环。
C++还希望从语义上更加友好,引入了引用概念,但是实现时却是通过取地址和解指针实现的,所以引用本质上就是一个语法糖,引用本身也是一个lvalue。因为函数返回值(pvalue)不能被取地址,还规定了常引用可以接受函数返回值,常引用本质上是改变了函数返回的硬件实现(用调用函数的栈的匿名变量空间接收返回值,常引用本身存放这个匿名变量空间的内存地址)。
通过引入pvalue和xvalue,从此C++形成了逻辑上的语义闭环。然而C++又引入了新的概念,移动语义,从而需要继续对理论找补。
C++11和C++17里使用常引用接收返回值
可以看到C++14(或者C++11)在关闭RVO的时候,因为返回值是非平凡类型,
使用局部变量接收返回值会有以下步骤发生:
- 被调函数会调用默认构造函数创建出返回值,这是一个在被调函数栈上的局部变量t
- 被调函数调用拷贝构造函数把当前栈上的局部变量t拷贝到主调函数的栈上的临时匿名变量空间
- 被调函数返回前析构局部变量t
- 主调函数调用拷贝构造函数把临时匿名变量空间(将亡值)拷贝到本地局部变量x里
- 主调函数立刻析构临时匿名变量(立刻发生在被调函数返回后)
使用常引用接受返回值,会有以下步骤发生:
- 被调函数会调用默认构造函数创建出返回值,这是一个在被调函数栈上的局部变量t
- 被调函数调用拷贝构造函数把当前栈上的局部变量t拷贝到主调函数的栈上的由常引用tmp指向的匿名变量空间
- 被调函数返回前析构局部变量t
- 主调函数调用拷贝构造函数把tmp指向的匿名变量空间(这个匿名变量空间不是xvalue,而是lvalue)拷贝到本地局部变量x里
- 主调函数不会立刻析构临时匿名变量,因为tmp常引用让它成为一个有名字的lvalue
对于如下代码,我们分别使用-fno-elide-constructors -std=c++14和默认编译选项(-std=c++17)来看看汇编实现的差别
C++11/14 使用普通变量/常引用接受函数返回值
编译器选项是-fno-elide-constructors -std=c++14
struct Test {
long a, b, c;
Test() {}
~Test() {}
};
struct Test demo() {
struct Test t;
return t;
}
void demo_caller() {
Test x = demo();//使用局部变量接收返回值
x.a += 1;
}
void demo_caller2() {
const Test &tmp = demo(); //常引用接受返回值(临时空间)
Test y = tmp;
y.a += 1;
}
Test::Test() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::~Test() [base object destructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::Test(Test const&) [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov rcx, QWORD PTR [rbp-8]
mov rsi, QWORD PTR [rbp-16]
mov rax, QWORD PTR [rsi]
mov rdx, QWORD PTR [rsi+8]
mov QWORD PTR [rcx], rax
mov QWORD PTR [rcx+8], rdx
mov rax, QWORD PTR [rsi+16]
mov QWORD PTR [rcx+16], rax
nop
pop rbp
ret
demo():
push rbp
mov rbp, rsp
sub rsp, 48
mov QWORD PTR [rbp-40], rdi//主调函数返回值的地址空间
lea rax, [rbp-32]//局部变量Test t的地址
mov rdi, rax
call Test::Test() [complete object constructor]//构造出Test t;
lea rdx, [rbp-32]////局部变量Test t的地址
mov rax, QWORD PTR [rbp-40]//主调函数返回值的地址空间
mov rsi, rdx
mov rdi, rax
call Test::Test(Test const&) [complete object constructor]//把Test t拷贝构造到主调函数返回值的地址空间
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
nop
mov rax, QWORD PTR [rbp-40]
leave
ret
demo_caller():
push rbp
mov rbp, rsp
sub rsp, 64
lea rax, [rbp-32]//主调函数返回值的地址空间,这时是个临时匿名变量
mov rdi, rax
call demo()
lea rdx, [rbp-32]//主调函数返回值的地址空间,这时是个临时匿名变量
lea rax, [rbp-64]//主调函数局部变量x的地址空间
mov rsi, rdx
mov rdi, rax
call Test::Test(Test const&) [complete object constructor]//使用拷贝构造函数,把临时匿名变量拷贝到x
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]//立刻析构临时匿名变量,这发生在x.a += 1之前
mov rax, QWORD PTR [rbp-64]
add rax, 1
mov QWORD PTR [rbp-64], rax//这是x.a += 1
lea rax, [rbp-64]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
demo_caller2():
push rbp
mov rbp, rsp
sub rsp, 64
lea rax, [rbp-32]//主调函数返回值的地址空间,常引用指向这块内存空间
mov rdi, rax
call demo()
lea rax, [rbp-32]
mov QWORD PTR [rbp-8], rax
mov rdx, QWORD PTR [rbp-8]
lea rax, [rbp-64]
mov rsi, rdx
mov rdi, rax
call Test::Test(Test const&) [complete object constructor]
mov rax, QWORD PTR [rbp-64]
add rax, 1
mov QWORD PTR [rbp-64], rax//这是y.a += 1
lea rax, [rbp-64]
mov rdi, rax
call Test::~Test() [complete object destructor]//因为返回值被常引用标记了,生命周期和栈同步,
//不再被立刻析构,而是栈退出前析构
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
C++17 使用普通变量/常引用接受函数返回值,C++17引入了复制省略(Copy Elision)
而C++17里,被调函数直接使用默认构造函数在主调函数的栈上构造了返回值,省去了不必要的拷贝构造。
C++17使用局部变量接收返回值会有以下步骤发生:
- 被调函数会调用默认构造函数创建出返回值,这是一个在主调函数栈上的局部变量x
- 被调函数不再像C++14一样需要使用拷贝构造函数把局部变量拷贝到一个主调函数的临时匿名变量上
- 主调函数可以直接使用这个局部变量x
- 被调函数不再像C++14一样需要使用拷贝构造函数把主调函数的临时匿名变量拷贝到主调函数的局部变量x上
- 综上,C++17的复制省略机制省去了两次拷贝构造
编译器选项是-std=c++17
struct Test {
long a, b, c;
Test() {}
~Test() {}
};
struct Test demo() {
struct Test t;
return t;
}
void demo_caller() {
Test x = demo();//使用局部变量接收返回值
x.a += 1;
}
void demo_caller2() {
const Test &tmp = demo(); //常引用接受返回值(临时空间)
Test y = tmp;
y.a += 1;
}
Test::Test() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::~Test() [base object destructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
demo():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call Test::Test() [complete object constructor]//直接在主调函数的局部变量上构造返回值,省去一次拷贝
mov rax, QWORD PTR [rbp-8]
leave
ret
demo_caller():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]
mov rdi, rax
call demo()
mov rax, QWORD PTR [rbp-32]//因为返回值就是局部变量,再省去一次拷贝
add rax, 1
mov QWORD PTR [rbp-32], rax
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
demo_caller2():
push rbp
mov rbp, rsp
sub rsp, 64
lea rax, [rbp-32]
mov rdi, rax
call demo()
lea rax, [rbp-32] // rbp-32就是tmp常引用指向的返回值
mov QWORD PTR [rbp-8], rax// rbp-8就是tmp常引用存放指针的地方
mov rcx, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rcx]
mov rdx, QWORD PTR [rcx+8]
mov QWORD PTR [rbp-64], rax//把返回值拷贝到局部变量y里
mov QWORD PTR [rbp-56], rdx//把返回值拷贝到局部变量y里
mov rax, QWORD PTR [rcx+16]
mov QWORD PTR [rbp-48], rax//把返回值拷贝到局部变量y里
mov rax, QWORD PTR [rbp-64]
add rax, 1
mov QWORD PTR [rbp-64], rax
lea rax, [rbp-64]
mov rdi, rax
call Test::~Test() [complete object destructor]
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
C++11和C++17使用常引用接受函数返回值的总结
使用常引用接受返回值,会有以下步骤发生:
- 被调函数会调用默认构造函数创建出返回值,这是一个在主调函数栈上的由tmp常引用指向的匿名局部变量
- 被调函数不再像C++14一样需要使用拷贝构造函数把局部变量拷贝到一个主调函数的临时匿名变量上
- 主调函数调用把tmp指向的匿名变量空间(这个匿名变量空间不是xvalue,而是lvalue)拷贝到本地局部变量y里
- 可见这里还是发生了一次拷贝
- *C++17的复制省略机制相比C++14减少了一次拷贝构造
- 主调函数不会立刻析构临时匿名变量,因为tmp常引用让它成为一个有名字的lvalue
右值引用(rvalue=reference)
C++11开始引入了右值引用的概念,右值引用起初完全是用于解决函数返回值问题的。
- 右值引用接受接受函数返回值时,可以直接给临时对象xvalue标记上名字,xvalue从此变成lvalue;
- 右值引用相比局部变量接受返回值,少了一次拷贝构造;
- 右值引用相比常引用接受返回值,不会被const约束;
从上一节的C++11/14示例里我们看到:
- 使用普通变量接受返回值,会导致出现一个xvalue(存放返回值)和普通变量本身;
- 使用常引用接受返回值会,出现一个xvalue(存放返回值)和一个8字节的空间(即常引用)指向的xvalue的地址(因为xvalue被常引用指向了,所以准确来说来说它变成了lvalue)
可见C++14里,如果使用普通变量接收返回值会出现一个xvalue;使用常引用接受返回值虽然不再有xvalue,但是这个常引用是const的,无法修改,如果要修改的话,还是得再去拷贝构造一个新的变量。是否可以有一个语法可以表达:
使用局部变量接受返回值,并且不出现额外的临时对象xvalue?有,这就是右值引用。
再说一遍,右值引用起初完全是用于解决函数返回值问题的。
- 语义上,函数返回值都是rvalue(prvalue或者xvalue),因此用于接受rvalue的引用就叫做右值引用(rvalue-reference);。
- 行为上,右值引用接受返回值和常引用接受函数返回值完全一样,不同点在于右值引用不需要const修饰,所以是可以更改的,相比于直接用变量接收返回值,少了一次拷贝构造和析构。
- 右值引用一旦指向了函数返回值,整个过程就不会有右值出现了,而是用左值保存返回值,因此右值引用本身是左值。
下面是一个C++14的示例,-fno-elide-constructors 是让其不要有RVO,可以看到使用了右值引用的C++14和可以直接用局部变量接受函数返回值的C++17行为完全一样(C++17的这种避免xvalue的能力叫做复制省略Copy Elision)
编译选项是-fno-elide-constructors -std=c++14
struct Test {
long a, b, c;
Test() {}
~Test() {}
};
struct Test demo() {
struct Test t;
return t;
}
void demo_caller() {
Test &&rf = demo(); //右值引用接受返回值
rf.a += 1;
}
Test::Test() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::~Test() [base object destructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
Test::Test(Test const&) [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov rcx, QWORD PTR [rbp-8]
mov rsi, QWORD PTR [rbp-16]
mov rax, QWORD PTR [rsi]
mov rdx, QWORD PTR [rsi+8]
mov QWORD PTR [rcx], rax
mov QWORD PTR [rcx+8], rdx
mov rax, QWORD PTR [rsi+16]
mov QWORD PTR [rcx+16], rax
nop
pop rbp
ret
demo():
push rbp
mov rbp, rsp
sub rsp, 48
mov QWORD PTR [rbp-40], rdi
lea rax, [rbp-32]
mov rdi, rax
call Test::Test() [complete object constructor]
lea rdx, [rbp-32]
mov rax, QWORD PTR [rbp-40]
mov rsi, rdx
mov rdi, rax
call Test::Test(Test const&) [complete object constructor]
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
nop
mov rax, QWORD PTR [rbp-40]
leave
ret
demo_caller():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]
mov rdi, rax
call demo()
lea rax, [rbp-32]
mov QWORD PTR [rbp-8], rax//右值引用指向了这块xvalue内存,xvalue变成了lvaue
mov rax, QWORD PTR [rbp-8] //把rf的指向的内存地址放到rax
mov rax, QWORD PTR [rax]//获得rf.a的值,放到rax
lea rdx, [rax+1]//rax的值 + 1 放到rdx上,这行汇编比较难懂。。。lea竟然能这么玩
mov rax, QWORD PTR [rbp-8]//获得rf.a的值,放到rax
mov QWORD PTR [rax], rdx//把rf.a + 1的结果写到rf.a上
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
移动语义
上节末尾示例可知,C++17的复制省略机制可以让局部变量接受返回值达到和右值引用一样的避免xvalue的效果,那么右值引用是不是在有Copy Elision的C++17版本里或者有着编译器RVO的C++14里失去了意义?不会,右值引用还有另一个重要用途,移动语义。
移动语义起初为了解决资源复用问题,请看以下C++14里的代码,-fno-elide-constructors是为了避免RVO,从而举出资源的例子。
可见MyString str = demo(); 这行代码触发了两次拷贝构造,MyString每次拷贝构造都要申请1024Byte的空间。
编译选项是-fno-elide-constructors -std=c++14
#include <cstring>
class MyString {
public:
MyString();
~MyString() ;
MyString(const MyString &);
void set(int x);
private:
char *buf_;
};
//由于算法本身不是本例程的重点,因此忽略掉一切扩容和优化问题,简单书写
MyString::MyString(): buf_(new char [1024]) {}
MyString::~MyString() {
if (buf_ != nullptr) {
delete [] buf_;
}
}
void MyString::set(int x) {
buf_[0] = x;
}
MyString:: MyString(const MyString &str) : buf_(new char [1024]) {
std::memcpy (buf_, str.buf_, 1024);
}
MyString demo() {
MyString str1;
str1.set(9);// 这里对str1做了一些操作,比如说添加了一些数据之类的
return str1;
}
void demo_caller() {
MyString str = demo(); //触发一次拷贝构造
}
MyString::MyString() [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edi, 1024
call operator new[](unsigned long)
mov rdx, rax
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
leave
ret
MyString::~MyString() [base object destructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
test rax, rax
je .L4
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
test rax, rax
je .L4
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdi, rax
call operator delete[](void*)
.L4:
nop
leave
ret
MyString::set(int):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov edx, DWORD PTR [rbp-12]
mov BYTE PTR [rax], dl
nop
pop rbp
ret
MyString::MyString(MyString const&) [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov edi, 1024
call operator new[](unsigned long)
mov rdx, rax
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-16]
mov rcx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov edx, 1024
mov rsi, rcx
mov rdi, rax
call memcpy
nop
leave
ret
demo():
push rbp
mov rbp, rsp
push rbx
sub rsp, 40
mov QWORD PTR [rbp-40], rdi
lea rax, [rbp-24]
mov rdi, rax
call MyString::MyString() [complete object constructor]
lea rax, [rbp-24]
mov esi, 9
mov rdi, rax
call MyString::set(int)
lea rdx, [rbp-24]
mov rax, QWORD PTR [rbp-40]
mov rsi, rdx
mov rdi, rax
call MyString::MyString(MyString const&) [complete object constructor] //触发了拷贝构造
nop
lea rax, [rbp-24]
mov rdi, rax
call MyString::~MyString() [complete object destructor]
jmp .L11
mov rbx, rax
lea rax, [rbp-24]
mov rdi, rax
call MyString::~MyString() [complete object destructor]
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L11:
mov rax, QWORD PTR [rbp-40]
mov rbx, QWORD PTR [rbp-8]
leave
ret
demo_caller():
push rbp
mov rbp, rsp
push rbx
sub rsp, 24
lea rax, [rbp-24]
mov rdi, rax
call demo()
lea rdx, [rbp-24]
lea rax, [rbp-32]
mov rsi, rdx
mov rdi, rax
call MyString::MyString(MyString const&) [complete object constructor] //触发了拷贝构造
lea rax, [rbp-24]
mov rdi, rax
call MyString::~MyString() [complete object destructor]
lea rax, [rbp-32]
mov rdi, rax
call MyString::~MyString() [complete object destructor]
jmp .L15
mov rbx, rax
lea rax, [rbp-24]
mov rdi, rax
call MyString::~MyString() [complete object destructor]
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L15:
mov rbx, QWORD PTR [rbp-8]
leave
ret
在类似的对象拷贝中,是否可以让新的对象直接复用原有对象在堆上开辟的空间呢?这样可以大量节省资源,于是C++引入了移动构造函数和移动赋值函数,当使用一个将亡的对象来构造新对象时,就调用移动构造函数进行浅复制。C++从语义上做了一件事:函数返回值作为实参的话,如果匹配到多个重载函数,优先匹配使用右值引用作为函数参数的重载函数。尽可能让将亡的对象可以被新对象复用堆上资源。
用以下示例说明该有限匹配右值引用参数的函数语义
编译选项是-fno-elide-constructors -std=c++14
struct Test {};
test demo() {
Test t;
return t;
}
void f (const Test&) {
std::cout << 1 << std::endl;
}
void f (Test&&) {
std::cout << 2 << std::endl;
}
void func () {
Test t;
f(t); //输出1
f(demo()); //输出2
借着“优先匹配使用右值引用作为函数参数的重载函数”这个语义,MyString可以做出如下改进
#include <cstring>
class MyString {
public:
MyString();
~MyString() ;
MyString(const MyString &);
MyString(MyString &&);
void set(int x);
private:
char *buf_;
};
//由于算法本身不是本例程的重点,因此忽略掉一切扩容和优化问题,简单书写
MyString::MyString(): buf_(new char [1024]) {}
MyString::~MyString() {
if (buf_ != nullptr) {
delete [] buf_;
}
}
void MyString::set(int x) {
buf_[0] = x;
}
MyString:: MyString(const MyString &str) : buf_(new char [1024]) {
std::memcpy (buf_, str.buf_, 1024);
}
MyString:: MyString(MyString &&str) {
buf_ = str.buf_;
str.buf_ = NULL;
}
MyString demo() {
MyString str1;
str1.set(9);// 这里对str1做了一些操作,比如说添加了一些数据之类的
return str1;
}
void demo_caller() {
MyString str = demo(); //触发的是MyString(MyString &&),复用了堆上内存
str.set(10); //在这行代码之前,demo()的返回值作为xvalue被析构了
MyString &&str2 = demo();
str2.set(20);
}
MyString::MyString() [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov edi, 1024
call operator new[](unsigned long)
mov rdx, rax
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
leave
ret
MyString::~MyString() [base object destructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
test rax, rax
je .L4
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
test rax, rax
je .L4
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov rdi, rax
call operator delete[](void*)
.L4:
nop
leave
ret
MyString::set(int):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov DWORD PTR [rbp-12], esi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov edx, DWORD PTR [rbp-12]
mov BYTE PTR [rax], dl
nop
pop rbp
ret
MyString::MyString(MyString const&) [base object constructor]:
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov edi, 1024
call operator new[](unsigned long)
mov rdx, rax
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-16]
mov rcx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
mov edx, 1024
mov rsi, rcx
mov rdi, rax
call memcpy
nop
leave
ret
MyString::MyString(MyString&&) [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov QWORD PTR [rbp-16], rsi
mov rax, QWORD PTR [rbp-16]
mov rdx, QWORD PTR [rax]
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
mov rax, QWORD PTR [rbp-16]
mov QWORD PTR [rax], 0
nop
pop rbp
ret
demo():
push rbp
mov rbp, rsp
sub rsp, 32
mov QWORD PTR [rbp-24], rdi//这是demo_caller函数上存放demo返回值的空间
lea rax, [rbp-8]//这是demo函数里的str1的地址空间
mov rdi, rax
call MyString::MyString() [complete object constructor]//构造str1
lea rax, [rbp-8]
mov esi, 9
mov rdi, rax
call MyString::set(int)//改变str1
lea rdx, [rbp-8]
mov rax, QWORD PTR [rbp-24]//这是demo_caller函数上存放demo返回值的空间
mov rsi, rdx
mov rdi, rax
call MyString::MyString(MyString&&) [complete object constructor]//使用了移动构造函数进行拷贝,节省了一次堆上资源的消耗, 把demo的str1的资源给了demo_caller上的临时匿名变量。
lea rax, [rbp-8]
mov rdi, rax
call MyString::~MyString() [complete object destructor]//销毁str1
nop
mov rax, QWORD PTR [rbp-24]//返回demo_caller上的临时匿名变量的地址
leave
ret
demo_caller():
push rbp
mov rbp, rsp
push rbx
sub rsp, 40
lea rax, [rbp-32] //用于存放demo返回值的临时变量空间
mov rdi, rax
call demo()
lea rdx, [rbp-32]
lea rax, [rbp-40]//这是demo_caller上的str的地址空间
mov rsi, rdx
mov rdi, rax
call MyString::MyString(MyString&&) [complete object constructor]//使用移动构造函数复制局部变量
//把demo返回值的临时临时变量移动拷贝给了str局部变量
// 再一次节省了堆上空间的消耗
lea rax, [rbp-32]
mov rdi, rax
call MyString::~MyString() [complete object destructor] //调用完移动构造函数之后,作为xvalue的临时变量被立刻析构
lea rax, [rbp-40]
mov esi, 10
mov rdi, rax
call MyString::set(int)//修改str局部变量
lea rax, [rbp-48]//这是另一个临时变量空间的地址,rbp-48
mov rdi, rax
call demo()
lea rax, [rbp-48]
mov QWORD PTR [rbp-24], rax//使用右值引用指向这个这个临时变量空间
//右值引用的地址是rbp-24
//从此临时变量从xvalue变成了lvalue
mov rax, QWORD PTR [rbp-24]
mov esi, 20
mov rdi, rax
call MyString::set(int)//修改右值引用
lea rax, [rbp-48]//销毁右值引用指向的变量
mov rdi, rax
call MyString::~MyString() [complete object destructor]
lea rax, [rbp-40]
mov rdi, rax
call MyString::~MyString() [complete object destructor]
jmp .L13
mov rbx, rax
lea rax, [rbp-40]//销毁局部变量str
mov rdi, rax
call MyString::~MyString() [complete object destructor]
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L13:
mov rbx, QWORD PTR [rbp-8]
leave
ret
上述例子可以知道,右值引用的最大价值不是优化返回值,而是标记实参是否是直接的函数返回。这样可以把本来是rvalue的函数返回值的堆上资源做一次浅复制进行复用,这种通过浅复制实现的复用被称作移动语义。
以上有两个重点信息
- 使用局部变量接受demo()的返回值,
- demo()使用MyString(MyString &&)把返回值写到demo_caller的临时变量空间,节省一次堆上内存消耗
- demo_caller使用MyString(MyString &&)把demo_caller的临时变量空间写到局部变量str上,节省了第二次的堆上内存消耗
- demo_caller的临时变量空间被立刻销毁
- 使用右值引用接受demo()的返回值,
- demo()使用MyString(MyString &&)把返回值写到demo_caller的临时变量空间,节省一次堆上内存消耗
- demo_caller使用右值引用直接指向这个临时变量空间,将其从xvalue变成了lvalue
可见右值引用最大的作用是减少了堆上的资源,实现了移动语义。
移动语义是一个软约束
上节我们知道实现移动语义有两大条件:
- C++增加了“优先匹配使用右值引用作为函数参数的重载函数”这个语义机制
- 再外加由用户自行定义的移动构造函数、移动赋值函数
第一个条件是C++强行给定的,但是第二个条件是用户实现的,但是如果用户不正确定义移动构造函数、移动赋值函数,就无法实现移动语义,因此移动语义是一个软约束。
目前为止,我们看到移动语义的使用范围仅仅限于处理函数返回值(rvalue)实现这一小小的地方,有没有可能把使用范围扩大到lvalue呢?这就是下一节需要做的事情。
std::move
上节提到C++通过规定“优先匹配使用右值引用作为函数参数的重载函数”实现了针对作为右值的函数返回值的移动语义。那我们是否可以对lvalue做同样的浅复制实现的移动语义呢?可以,使用std::move。
以上一节的实现了移动构造函数的MyString为例
void func() {
MyString str1;
MyString str2 = str1; //这里会使用拷贝构造函数,因为str1不是右值,而是左值
//注意这里的等号是构造函数的语法糖
// 等同于MyString str2(str1)
}
如果可以把str1强行定义为右值,是否就可以当做右值处理,毕竟移动语义本身是个软约束,不会check入参是否是真的右值。
void func() {
MyString str1;
MyString str2 =static_cast<MyString &&>(str1); // 强行转成右值引用,去触发移动构造函数
// 注意这里的等号是构造函数的语法糖
// 等同于MyString str2(static_cast<MyString &&>(str1))
}
由于static_cast<Type &&>()这个把左值强转为右值引用的操作过于魔幻,STL提供了一个工具函数来代替它,那就是std::move
template <typename T>
constexpr std::remove_reference_t<T> &&move(T &&ref) noexept {
return static_cast<std::remove_reference_t<T> &&>(ref);
}
所以刚刚的代码可以写成
void func() {
MyString str1;
MyString str2 =std::move(str1); // 强行转成右值引用,去触发移动构造函数, 实现移动语义
}
从语义上看左值右值
被声明出来的左、右值引用都是左值,因为被声明出的左右值引用是有地址的。
// 形参是个右值引用
void change(int&& right_value) {
right_value = 8;//实参变成了左值
}
int main() {
int a = 5; // a是个左值
int &ref_a_left = a; // ref_a_left是个左值引用
int &&ref_a_right = std::move(a); // ref_a_right是个右值引用
change(a); // 编译不过,a是左值,change参数要求右值
change(ref_a_left); // 编译不过,左值引用ref_a_left本身也是个左值
change(ref_a_right); // 编译不过,右值引用ref_a_right本身也是个左值
change(std::move(a)); // 编译通过
change(std::move(ref_a_right)); // 编译通过
change(std::move(ref_a_left)); // 编译通过
change(5); // 当然可以直接接右值,编译通过
cout << &a << ' ';
cout << &ref_a_left << ' ';
cout << &ref_a_right;
// 打印这三个左值的地址,都是一样的
}
看完后你可能有个问题,std::move会返回一个右值引用int &&,它是左值还是右值呢?
从表达式int &&ref = std::move(a)来看,右值引用ref指向的必须是右值,所以move返回的int &&是个右值。
所以右值引用既可能是左值,又可能是右值吗?
确实如此:右值引用既可以是左值也可以是右值,如果有名称则为左值,否则是右值。
或者说:作为函数返回值的 && 是右值,直接声明出来的 && 局部变量和函数参数是左值。
其实引用和普通变量是一样的,int &&ref = std::move(a)和 int a = 5没有什么区别,等号左边就是左值,右边就是右值。
结论:
- 从性能上讲,左右值引用没有区别,传参使用左右值引用都可以避免拷贝。
- 右值引用可以直接指向右值,也可以通过std::move指向左值;而左值引用只能指向左值(const左值引用也能指向右值)。
- 作为函数形参时,右值引用更灵活。虽然const左值引用也可以做到左右值都接受,但它无法修改,有一定局限性。
#include <utility>
void f(const int& n) {
n += 1; // 编译失败,const左值引用不能修改指向变量
}
void f2(int && n) {
n += 1; // ok
}
int main() {
f(5);
f2(5);
int x = 5;
f2(x); //编译失败,只接受右值引用参数
f2(std::move(x));//编译成功
}
从语义上看std::move
事实上std::move移动不了什么,唯一的功能是把左值强制转化为右值,让右值引用可以指向左值。
std::move等同于一个类型转换,单纯的std::move(xxx)不会有性能提升。
在STL的很多容器中,都实现了以右值引用为参数的移动构造函数和移动赋值重载函数,或者其他函数,最常见的如std::vector的push_back和emplace_back。参数为左值引用意味着拷贝,为右值引用意味着移动。
除非设计不允许移动,STL类大都支持移动语义函数,即可移动的。
可移动对象在<需要拷贝且被拷贝者之后不再被需要>的场景,建议使用std::move触发移动语义,提升性能。
有些STL类是move-only的,比如unique_ptr,这种类只有移动构造函数,因此只能移动(转移内部对象所有权,或者叫浅拷贝),不能拷贝(深拷贝)
std::unique_ptr<A> ptr_a = std::make_unique<A>();
std::unique_ptr<A> ptr_b = std::move(ptr_a); // unique_ptr只有‘移动赋值重载函数‘,参数是&& ,只能接右值,因此必须用std::move转换类型
std::unique_ptr<A> ptr_b = ptr_a; // 编译不通过
int main() {
std::string str1 = "aacasxs";
std::vector<std::string> vec;
vec.push_back(str1); // 传统方法,copy
vec.push_back(std::move(str1)); // 调用移动语义的push_back方法,避免拷贝,str1会失去原有值,变成空字符串
vec.emplace_back(std::move(str1)); // emplace_back效果相同,str1会失去原有值
vec.emplace_back("axcsddcas"); // 当然可以直接接右值
}
// std::vector方法定义
void push_back (const value_type& val);
void push_back (value_type&& val);
void emplace_back (Args&&... args);
回头看函数传参
三种参数传递方式,普通参数、左值引用参数、右值引用参数
- 普通参数传递,主调函数把局部变量当做参数传递给被调函数时,会在主调函数拷贝局部函数到当前栈上当做实参,被调函数通过访问上一个栈来访问实参,
- 被调函数返回后,对应的在主调函数上的被调函数实参立刻被销毁
- 对于可以被寄存器传递的平凡类型,无论需要传递的参数是局部变量还是常量,都是通过寄存器传递的
- 左值引用参数传递,主调函数不再需要拷贝局部变量,主调函数直接把局部变量(局部变量本身就是实参)的地址通过rdi传递给被调函数,被调函数通过访问上一个栈来访问实参
- 右值引用参数传递,和左值引用一样的操作
- 不同在于,会把传参的地址转成右值引用
//使用默认的编译选项(GCC14.2 C++17)
#include <utility>
struct Test {
Test() : a(0), b(1), c(2) {}
~Test() {}
long a,b,c;
};
void f1(Test t) {
t.a += 1;
}
void c1() {
Test t;
f1(t);
t.a = 1;
}
void f2(Test &t) {
t.a += 2;
}
void c2() {
Test t;
f2(t);
t.a = 1;
}
void f3(Test &&t) {
t.a += 3;
}
void c3() {
Test t;
f3(std::move(t));
t.a = 1;
}
Test::Test() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], 0
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+8], 1
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], 2
nop
pop rbp
ret
Test::~Test() [base object destructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
f1(Test):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi//rdi存放的是实参的地址,实参在上一个函数栈上
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
lea rdx, [rax+1]
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
pop rbp
ret
c1():
push rbp
mov rbp, rsp
sub rsp, 64
lea rax, [rbp-64]//取局部变量内存地址
mov rdi, rax
call Test::Test() [complete object constructor]//构造局部变量
mov rax, QWORD PTR [rbp-64]
mov rdx, QWORD PTR [rbp-56]
mov QWORD PTR [rbp-32], rax//拷贝局部变量到实参
mov QWORD PTR [rbp-24], rdx//拷贝局部变量到实参
mov rax, QWORD PTR [rbp-48]
mov QWORD PTR [rbp-16], rax//拷贝局部变量到实参
lea rax, [rbp-32]
mov rdi, rax//把实参地址传递给被调函数
call f1(Test)
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]//被调函数返回后立刻销毁实参
mov QWORD PTR [rbp-64], 1//给局部变量t.a = 1
lea rax, [rbp-64]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
f2(Test&):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
lea rdx, [rax+2]
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
pop rbp
ret
c2():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]//局部变量t的地址
mov rdi, rax
call Test::Test() [complete object constructor]//构造局部变量t
lea rax, [rbp-32]
mov rdi, rax
call f2(Test&)//把局部变量t的地址传递给被调函数
mov QWORD PTR [rbp-32], 1//给局部变量t.a = 1
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
f3(Test&&):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rax, QWORD PTR [rax]
lea rdx, [rax+3]
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], rdx
nop
pop rbp
ret
c3():
push rbp
mov rbp, rsp
sub rsp, 32
lea rax, [rbp-32]//局部变量t的地址
mov rdi, rax
call Test::Test() [complete object constructor]//构造局部变量t
lea rax, [rbp-32]
mov rdi, rax
call std::remove_reference<Test&>::type&& std::move<Test&>(Test&)在传递把局部变量t的地址之前,先把它转成右值引用
mov rdi, rax
call f3(Test&&)//把局部变量t的地址传递给被调函数
mov QWORD PTR [rbp-32], 1//给局部变量t.a = 1
lea rax, [rbp-32]
mov rdi, rax
call Test::~Test() [complete object destructor]
leave
ret
void f_int(int x) {
int u = x + 1;
}
void f_int_caller() {
f_int(0);
int y = 1;
f_int(y);
}
void f_int_lref(int &x) {
int u = x + 1;
}
void f_int_lref_caller() {
//f_int_lref(1);
int x = 1;
f_int_lref(x);
}
void f_int_rref(int &&x) {
int u = x + 1;
}
void f_int_rref_caller() {
f_int_rref(1);
}
f_int(int):
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-20], edi//直接通过寄存传递实参到被调函数上的局部变量x,x就是实参
mov eax, DWORD PTR [rbp-20]
add eax, 1
mov DWORD PTR [rbp-4], eax
nop
pop rbp
ret
f_int_caller():
push rbp
mov rbp, rsp
sub rsp, 16
mov edi, 0//常量是通过寄存器传递给被调函数的
call f_int(int)
mov DWORD PTR [rbp-4], 1
mov eax, DWORD PTR [rbp-4]//局部变量也是通过寄存器传递给被调函数的
mov edi, eax
call f_int(int)
nop
leave
ret
f_int_lref(int&):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi//实参在主调函数上,这里rdi只是传递实参地址
mov rax, QWORD PTR [rbp-24]
mov eax, DWORD PTR [rax]
add eax, 1
mov DWORD PTR [rbp-4], eax
nop
pop rbp
ret
f_int_lref_caller():
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 1
lea rax, [rbp-4]
mov rdi, rax
call f_int_lref(int&)
nop
leave
ret
f_int_rref(int&&):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-24], rdi//实参在主调函数上,这里rdi只是传递实参地址
mov rax, QWORD PTR [rbp-24]
mov eax, DWORD PTR [rax]
add eax, 1
mov DWORD PTR [rbp-4], eax
nop
pop rbp
ret
f_int_rref_caller():
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 1
lea rax, [rbp-4]
mov rdi, rax
call f_int_rref(int&&)
nop
leave
ret
回头看返回值
main函数里调用Test y = f2(f1()); 非常值得探究:
- f1返回一个构造好的Test对象
- f1的返回本质上是把结果写到主调函数的临时局部变量空间中
- 这里的临时局部变量空间不在f2上,而是在main函数上
- f2接受一个Test的右值引用参数,并且把这个参数的c置为8,再把这个参数作为返回值返回
- 这里的右值引用参数的实参本质上是左值,就是f1返回的Test对象,就是main函数上的临时局部变量
- 这里的返回值机制和f1完全一样,把返回值写到主调函数的空间上,只是这时候的空间是main的局部变量y
- 综上
- f1返回的值和f2的参数,都是main函数上的临时匿名局部变量
- f2 返回的值是main函数上的局部变量y
假设给下面的main函数再加一行代码Test &&z = f2(f1());,又该怎么分析呢?
其实这行代码在main上的效果,main栈上新增了有两个Test匿名局部变量和8字节的右值引用,一个匿名局部变量是临时的,另一个匿名局部变量是被一个右值引用指着的,f1和f2的行为不会有任何改变
#include <iostream>
struct Test {
Test() : a(0), b(1), c(2) {}
~Test() {}
long a,b,c;
};
Test f1() {
Test t;
return t;
}
Test f2(Test &&x) {//作为形参是右值引用(这只是说明它可以接受函数返回,或者std::move之后的值),但是x也是左值
x.a = 8;
return x;
}
int main() {
Test y = f2(f1());
std::cout << y.a << std::endl;//输出 8
return 0;
}
Test::Test() [base object constructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax], 0
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+8], 1
mov rax, QWORD PTR [rbp-8]
mov QWORD PTR [rax+16], 2
nop
pop rbp
ret
Test::~Test() [base object destructor]:
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi
nop
pop rbp
ret
f1():
push rbp
mov rbp, rsp
sub rsp, 16
mov QWORD PTR [rbp-8], rdi
mov rax, QWORD PTR [rbp-8]
mov rdi, rax
call Test::Test() [complete object constructor]
mov rax, QWORD PTR [rbp-8]
leave
ret
f2(Test&&):
push rbp
mov rbp, rsp
mov QWORD PTR [rbp-8], rdi//是main函数上局部变量y的地址
mov QWORD PTR [rbp-16], rsi//是f1函数的返回值地址,这个地址是main函数上的临时匿名局部变量的地址
mov rax, QWORD PTR [rbp-16]
mov QWORD PTR [rax], 8 //给临时匿名局部变量的a赋值为8
mov rcx, QWORD PTR [rbp-8]//往后就是把main的临时匿名局部变量 赋值给 main的局部变量y,本质上是f2函数的返回返回值的方式。
mov rsi, QWORD PTR [rbp-16]
mov rax, QWORD PTR [rsi]
mov rdx, QWORD PTR [rsi+8]
mov QWORD PTR [rcx], rax
mov QWORD PTR [rcx+8], rdx
mov rax, QWORD PTR [rsi+16]
mov QWORD PTR [rcx+16], rax
mov rax, QWORD PTR [rbp-8]
pop rbp
ret
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 72
lea rax, [rbp-48]//这是f1的返回值的地址,返回值跨了两个函数调用关系,放在了main函数的栈上
mov rdi, rax
call f1()//f1调用Test构造函数给构造出来了
lea rax, [rbp-80]
lea rdx, [rbp-48]
mov rsi, rdx//把f1的返回值地址传参给f2
mov rdi, rax//把f1的局部变量y的地址传参给f2
call f2(Test&&)
lea rax, [rbp-48]//f1调用结束之后,返回值作为右值被立刻销毁
mov rdi, rax
call Test::~Test() [complete object destructor]
mov QWORD PTR [rbp-64], 9//rbp-64是main上的y局部变量的c
mov rax, QWORD PTR [rbp-80]//rbp-80是main上的y局部变量,这行之后的是std::cout代码,不用看
mov rsi, rax
mov edi, OFFSET FLAT:std::cout
call std::basic_ostream<char, std::char_traits<char> >::operator<<(long)
mov esi, OFFSET FLAT:std::basic_ostream<char, std::char_traits<char> >& std::endl<char, std::char_traits<char> >(std::basic_ostream<char, std::char_traits<char> >&)
mov rdi, rax
call std::basic_ostream<char, std::char_traits<char> >::operator<<(std::basic_ostream<char, std::char_traits<char> >& (*)(std::basic_ostream<char, std::char_traits<char> >&))
mov ebx, 0
lea rax, [rbp-80]//销毁局部变量y
mov rdi, rax
call Test::~Test() [complete object destructor]
mov eax, ebx
jmp .L11
mov rbx, rax
lea rax, [rbp-80]
mov rdi, rax
call Test::~Test() [complete object destructor]
mov rax, rbx
mov rdi, rax
call _Unwind_Resume
.L11:
mov rbx, QWORD PTR [rbp-8]
leave
ret
概念串联汇总
关于 右值引用/重载决议/std::move/移动语义 我给出另一个简约的整体理解视角:
-
右值引用
- 右值引用原本只用于接收函数返回值:
- 函数返回值是一个将亡值。
- 使用普通变量接收函数返回值会出现一次拷贝构造;
- 使用常引用接收函数返回值虽然可以避免拷贝构造(常引用给了将亡值名字,让它不再是将亡值),但是存在const限制且常引用有额外8字节开销;
- 使用右值引用来接收函数返回值,可以避免拷贝构造+const限制+引用8字节开销,右值引用接收函数返回值的本质是最低代价消灭将亡值(cc Copy Elision)
- 右值引用另一个大作用是配合重载决议实现移动语义
- 右值引用原本只用于接收函数返回值:
-
重载决议
- CPP语义强制规定,函数返回值作为实参的话匹配到多个重载函数时,优先匹配使用右值引用作为函数参数的重载函数,以达到复用将亡值的堆上资源目的。
- 普通变量作为实参传入函数有没有办法做到同样的目的?
-
有办法!std::move强制把普通变量(左值)转成右值引用,以达到被「使用右值引用作为函数参数的函数」接收的目的。
-
std::move是一个语法糖,把一个左值引用强行cast成右值引用
MyClass obj1; MyClass obj2 =static_cast<MyClass &&>(obj1); // static_cast把obj1强行变成右值引用
-
-
移动语义
- 移动语义的目的是复用堆上内存资源,避免内存分配。
一句话总结:CPP 使用 std::move把左值变成右值引用,然后配合重载决议规则实现了移动语义。
最后的总结
先提一下广义左值(Generalized Left-side-hand Value,glvalue),这是一个很少被提到的概念,lvalue和xvalue统称glvalue,原因是他们都有内存实体,但是glvalue很少被使用,xvalue在语法上被定义为不可取地址的值,所以更多地把xvalue当做rvalue。
本文的绝大部分内容借用原博客https://www.zhihu.com/question/428340896/answer/2913419725 答主的内容,在汇编示例上有一些改动,加了自己的理解。
C++引入了如此多概念,既要兼容C又要提供高级语义封装屏蔽硬件细节,确实很烦,我们学习的时候要深入,搞清楚底层,使用的时候要提高视野,按照语义来使用。
浙公网安备 33010602011771号