上一篇文章《C++变量生命周期》中提到了C++中变量的生命周期与作用域是有差别的,其中提到了 左值 和 右值 这两个概念,引申出来了 左值引用 和 右值引用 这两个东西,上次因为篇幅问题,只是简单说了一下左值和右值的一些区别,生命周期和简单用法,没有详细展开。这次讨论一下右值引用的一些常见问题和使用。
move
在C++11中,加入了 std::move
函数,实现了移动语义,有了 move函数后,可以实现高效的资源转移,不必像传统的复制那样 ‘笨重’。
move例子
先来一个简单的例子来看一下 move
函数的魅力
1#include <iostream>
2#include <utility>
3#include <vector>
4
5class MyClass {
6public:
7 MyClass(int value) : value_(value) {
8 std::cout << "Constructing MyClass with value " << value_ << std::endl;
9 }
10
11 MyClass(const MyClass& other) : value_(other.value_) {
12 std::cout << "Copy constructing MyClass with value " << value_ << std::endl;
13 }
14
15 MyClass(MyClass&& other) noexcept : value_(other.value_) {
16 other.value_ = 0;
17 std::cout << "Move constructing MyClass with value " << value_ << std::endl;
18 }
19
20 MyClass& operator=(const MyClass& other) {
21 if (this != &other) {
22 value_ = other.value_;
23 std::cout << "Copy assigning MyClass with value " << value_ << std::endl;
24 }
25 return *this;
26 }
27
28 MyClass& operator=(MyClass&& other) noexcept {
29 if (this != &other) {
30 value_ = other.value_;
31 other.value_ = 0;
32 std::cout << "Move assigning MyClass with value " << value_ << std::endl;
33 }
34 return *this;
35 }
36
37 int getValue() const {
38 return value_;
39 }
40
41private:
42 int value_;
43};
44
45int main() {
46 MyClass a(10);
47 MyClass b = std::move(a);
48 MyClass c = b;
49
50 std::cout<<"----------------------------"<<std::endl;
51
52 std::cout << "Value of a after move: " << a.getValue() << std::endl;
53 std::cout << "Value of b after move: " << b.getValue() << std::endl;
54
55 std::cout<<"----------------------------"<<std::endl;
56 std::vector<MyClass> vec;
57 vec.push_back(MyClass(20));
58 std::cout<<"----------------------------"<<std::endl;
59 vec.push_back(std::move(b));
60
61 return 0;
62}
这段代码执行的结果
1Constructing MyClass with value 10
2Move constructing MyClass with value 10
3Copy constructing MyClass with value 10
4----------------------------
5Value of a after move: 0
6Value of b after move: 10
7----------------------------
8Constructing MyClass with value 20
9Move constructing MyClass with value 20
10----------------------------
11Move constructing MyClass with value 10
12Move constructing MyClass with value 20
通过代码执行的结果可以看到,
在代码 MyClass a(10);
中,初始化变量 a
调用了构造函数。
在代码 MyClass b = std::move(a);
中,初始化变量 b
调用了移动构造函数。
在代码 MyClass c = b;
中,初始化变量 c
调用了拷贝构造函数。
在代码 vec.push_back(MyClass(20));
中,对应的两行输出
1Constructing MyClass with value 20
2Move constructing MyClass with value 20
MyClass(20)
构造一个临时对象- 调用移动构造函数,这个临时对象传入到
vec
中
在代码 vec.push_back(std::move(b));
中,对应的一行输出
1Move constructing MyClass with value 10
因为是使用的 std::move
函数,所以直接通过移动构造函数把值放到 vec
中
最后一行 Move constructing MyClass with value 20
输出是因为在 vec.push_back(std::move(b));
时,vec
发生了扩容,MyClass(20)
这个对象发生了一次移动。
看到这里,大概能对 move
函数有大概的了解了。可能也会产生一个疑问,为什么要使用move
函数呢?
首先我们要明确一个点,在C++中,变量的赋值默认都是拷贝行为。所谓的引用传递参数,不过是编译器实现的一种指针,本质是对内存地址的一次拷贝。
拷贝会带来什么问题呢?其中一个问题就是,在传递大的对象时,需要把对象的成员变量都复制一次。上面例子中的 MyClass
中只有一个 int value_
类型的成员变量开销比较小,如果成员变量很多,而且类型复杂,那每次传递变量都复制一次,这个开销就不能忽视了。
move分析
回到 move
函数,移动语义允许将资源的 所有权 从一个对象转移到另一个对象,而无需深拷贝。
看到这里,是否好奇 move
函数为什么有如此魔力,是怎样实现的呢
下面就是 move
函数的常见实现方式
1template<typename T>
2typename std::remove_reference<T>::type&& move(T&& t) {
3 return static_cast<typename std::remove_reference<T>::type&&>(t);
4}
看着挺复杂的,可以将这段代码简化一下,简化后大概就是这个样子
1template <typename T>
2T &&move(T &t) {
3 return static_cast<T &&>(t);
4}
这样看下来,move
函数其实就做了一件事 接受一个通用引用(T&&),然后通过 static_cast 将其转换为右值引用
move
的本意是提前让一个对象“将亡”,然后把控制权“移交”给右值引用,所以才叫move
,也就是“移动语义”。但是,C++并不能真正让一个对象提前“亡”,所以这里的“移动”仅仅是“语义”上的,并不是实际的。只是告诉编译器,这个对象我不需要了,可以绑定到右值引用上了。
有个点需要注意一下,移动构造函数需要加上 noexcept
虽然不加也能正常执行,但是在使用容器等 STL 库时,如果对象的 移动构造函数没有 声明为 noexcept
可能会导致不会使用移动构造,而是使用拷贝构造。
再讨论一下上文中提到的 所有权,这个概念我最早是从 rust 中了解到的,后来在C++中,使用 std::unique_ptr
智能指针的时候,逐渐对所有权这个概念有了一些理解。
书接上面的例子
1void func1(std::unique_ptr<MyClass> ptr)
2{
3}
4
5void func2(std::unique_ptr<MyClass>& ptr)
6{
7}
8
9int main() {
10 std::unique_ptr<MyClass> ptr1 = std::make_unique<MyClass>(30);
11 auto ptr2 = ptr1; //编译报错
12 func1(ptr1); //编译报错
13
14 auto ptr3 = std::move(ptr1);
15 func2(ptr3);
16 func1(std::move(ptr2));
17}
ptr1
这个智能指针拥有该对象的所有权,如果尝试将这个指针复制给 ptr2
这个指针那么编译器会报错,因为 std::unique_ptr
类型的指针只允许一个指针对象持有 MyClass
这个对象的所有权。复制行为是将所有权共享,所以会提示错误。
能做到只能是将所有权转移,也就是 ptr1
指针放弃所有权,将所有权交给 ptr3
这个指针。同样的,在作为函数的参数传递时,也是同样的道理,作为 func1
函数的参数时,只能使用 move
,将所有权转移。
还有一种方式,借,在函数 func2
中,可以使用引用的方式,将所有权暂时 ‘借’ 给 func2
中的变量使用,但是所有权还在原来的所有者那里。
move函数的优先级
如果一个 class 实现了 移动构造函数,在赋值时,是否一定会使用 move
呢?答案是不一定,是否优先使用移动构造函数取决于具体的情境。
先来看一下 拷贝构造函数 和 移动构造函数 的签名
1MyClass(const MyClass& other);//拷贝构造
2MyClass(MyClass&& other) noexcept;//移动构造
可以看到,移动构造函数的参数类型是 右值引用。
也就是说,优先级规则是这样的
- 如果实参是 左值,编译器会调用拷贝构造函数
- 如果实参是 右值,并且存在移动构造函数,编译器会优先调用移动构造函数
但这里的关键是:复制 这个行为是否涉及右值。如果代码没有显式地将对象标记为右值(比如使用 std::move),即使有移动构造函数,也不会被调用。
使用move注意事项
移动后对象的状态:调用
std::move
后,源对象并没有被销毁,但它处于一个“有效但未指定”的状态(valid but unspecified state)。比如对于std::string
,它可能变为空字符串,但具体取决于类的实现。不保证移动:
std::move
只是请求移动,能否真正移动取决于目标类型是否实现了移动构造函数或移动赋值运算符。如果没有,编译器会回退到拷贝。不要滥用:只有当你确定某个对象不再需要时,才应该使用
std::move
。
forward
说完了 std::move
那就不得不提 std::forward
了。forward
也叫 完美转发
和 move
不同的地方,forward
常用在模板编程中,以确保参数能够按照原始值类别(左值或右值)传递给下一个函数,而不会丢失其属性。在模板编程中,一般都是希望将参数传递给另一个函数时,保留参数的原始值类型。
但是 std::move
总是将对象无条件转换为右值,而 std::forward
则根据参数的原始类型有条件地进行转发。
- 如果传入的是 左值,则以左值的形式转发。
- 如果传入的是 右值,则以右值的形式转发。
forward例子
通过一个简单的例子看一下:
1#include <iostream>
2
3void process(int& x) { std::cout << "Lvalue: " << x << "\n"; }
4void process(int&& x) { std::cout << "Rvalue: " << x << "\n"; }
5
6template<typename T>
7void forwarder(T&& arg) {
8 process(arg); // 直接传递 arg
9}
10
11int main() {
12 int a = 42;
13 forwarder(a); // 传入左值
14 forwarder(100); // 传入右值
15 return 0;
16}
执行结果:
1Lvalue: 42
2Lvalue: 100
a
是左值,转发为左值,调用process(int&)
100
是右值,转发为右值,调用process(int&&)
process
函数有两个重载,分别是 左值引用 和 右值引用,forwarder
函数可以根据不同的类型调用不同的 process
,也就实现了 转发
实现原理
std::forward
的实现依赖于 引用折叠和类型推导,下面是 forward
函数实现
1template<typename T>
2T&& forward(typename std::remove_reference<T>::type& arg) noexcept {
3 return static_cast<T&&>(arg);
4}
5
6template<typename T>
7T&& forward(typename std::remove_reference<T>::type&& arg) noexcept {
8 return static_cast<T&&>(arg);
9}
- 传入左值时,
T
推导为Type&
,T&&
折叠为左值引用 - 传入右值时,
T
推导为Type
,T&&
保持为右值引用
std::forward
与 std::move
相比,它更 智能,适用于需要动态适配参数类型的场景。一般用在模板编程中,确保参数在传递过程中保留原始的 左值 / 右值属性,避免不必要的拷贝。
RVO
RVO(Return Value Optimization,返回值优化,是C++编译器的一种优化技术,用于减少函数返回大对象时的拷贝开销。在没有 RVO 的情况下,函数返回一个对象时,会调用拷贝构造函数或移动构造函数来构造返回值对象。RVO 的目标是直接在调用者的内存空间中构造返回对象,从而避免额外的拷贝或移动。
RVO 有两种形式
- NRVO(Named Return Value Optimization):针对命名的局部变量
- URVO(Unnamed Return Value Optimization):针对临时对象(未命名的对象)
RVO例子
1#include <iostream>
2
3struct MyClass {
4 MyClass() { std::cout << "Constructor\n"; }
5 MyClass(const MyClass&) { std::cout << "Copy constructor\n"; }
6 MyClass(MyClass&&) noexcept { std::cout << "Move constructor\n"; }
7 ~MyClass() { std::cout << "Destructor\n"; }
8};
9
10MyClass create() {
11 MyClass obj;
12 return obj; // 返回局部对象
13}
14
15int main() {
16 MyClass a = create();
17 return 0;
18}
现在的编译器默认都会开启 RVO 优化,所以需要手动禁用,在clang中,可以在命令行中添加 -fno-elide-constructors
来禁用。
使用 clang++ -fno-elide-constructors -o main main.cpp
命令来编译,然后执行 main
,输出如下
1Constructor
2Move constructor
3Destructor
4Destructor
可以看到在 return obj
时,因为没有使用 RVO 优化,所以复制了一次对象,但是因为有移动构造函数,所以使用了 move
优化。
下面开启 RVO 优化看一下
使用 clang++ -o main main.cpp
命令编译,然后执行 main
输出如下
1Constructor
2Destructor
可以看到只调用了构造函数。
工作原理
在线汇编 使用这个工具,可以看到具体的汇编代码
下面是是让GPT加了注释的汇编代码
1create(): ; create() 函数定义开始
2 push rbp ; 保存当前栈帧基址指针
3 mov rbp, rsp ; 建立新的栈帧,将栈指针值赋给基址指针
4 sub rsp, 16 ; 分配16字节的栈空间用于局部变量
5 mov QWORD PTR [rbp-8], rdi ; 保存第一个参数(rdi)到栈上[rbp-8]位置,这是对象指针
6 mov rax, QWORD PTR [rbp-8] ; 将对象指针加载到rax寄存器
7 mov rdi, rax ; 将对象指针作为第一个参数(rdi)传递给构造函数
8 call MyClass::MyClass() [complete object constructor] ; 调用MyClass的构造函数初始化对象
9 mov rax, QWORD PTR [rbp-8] ; 将对象指针重新加载到rax作为函数返回值
10 leave ; 恢复栈帧(相当于mov rsp, rbp; pop rbp)
11 ret ; 从函数返回,返回值在rax中
12
13main: ; main函数定义开始
14 push rbp ; 保存当前栈帧基址指针
15 mov rbp, rsp ; 建立新的栈帧
16 push rbx ; 保存rbx寄存器的值(调用者保存的寄存器)
17 sub rsp, 24 ; 分配24字节的栈空间
18 lea rax, [rbp-17] ; 将栈上地址[rbp-17]加载到rax,这是为MyClass对象分配的空间
19 mov rdi, rax ; 将对象地址作为参数传递给create函数
20 call create() ; 调用create函数,初始化对象
21 mov ebx, 0 ; 将返回值0存储到ebx寄存器
22 lea rax, [rbp-17] ; 重新加载对象地址到rax
23 mov rdi, rax ; 将对象地址作为参数传递给析构函数
24 call MyClass::~MyClass() [complete object destructor] ; 调用MyClass的析构函数清理对象
25 mov eax, ebx ; 将返回值(0)从ebx移动到eax作为main函数的返回值
26 mov rbx, QWORD PTR [rbp-8] ; 恢复之前保存的rbx值
27 leave ; 恢复栈帧
28 ret ; 从main函数返回,返回值在eax中
可以看到开启 ROV 优化后,确实是先在 main
函数的栈空间 先初始化 MyClass
的对象,所以可以避免在 return 时拷贝。
RVO 的核心思想是:编译器在生成代码时,识别出函数返回的对象可以直接在调用者的目标位置构造,而不是先在函数栈上构造然后拷贝或移动到调用者。
在 C++17 之前,RVO 是一种编译器可选的优化,程序员不能完全依赖它。从 C++17 开始,标准对某些情况下的返回值优化做了强制要求,称为 Mandatory Copy Elision(强制拷贝消除)。这意味着在特定场景下,即使禁用优化,编译器也必须消除拷贝或移动。
RVO 和 std::move 的关系
一般来说,使用了 move 后就不会再使用 RVO 了
1MyClass create() {
2 MyClass obj;
3 return std::move(obj); // 显式移动
4}
改一下上面的例子,开启 ROV优化看一下
clang++ -O3 -o main main.cpp
编译 执行 main
输出:
1Constructor
2Move constructor
3Destructor
4Destructor
所以,在编写现代C++代码时,在 return 局部变量时没有必要使用 std::move
,使用了大概率会影响编译器优化,反而会适得其反,性能更差了。
所以相信编译器,很多情况下,编译器会比人更 ‘聪明’。
总结
std::move
不会改变对象的内容(不会移动),只是欺骗了编译器std::move
只是请求移动,能否真正移动取决于目标类型是否实现了移动构造函数或移动赋值运算符。如果没有,编译器会回退到拷贝- 左值:调用拷贝构造函数,右值: 如果有移动构造函数,优先调用移动构造函数;否则回退到拷贝构造函数
std::forward
有条件地转发参数,保留其原始值类别(左值或右值)- 函数返回一个对象时,除非需要显示调用移动或者拷贝构造,否则不要使用
std::move
,而是使用编译器的 RVO 优化