Featured image of post C++中的移动语义与完美转

C++中的移动语义与完美转

深入探讨C++中的右值引用、std::move和std::forward的使用方法及其实现原理,结合实例讲解RVO(返回值优化)的工作机制,帮助开发者高效管理资源。

上一篇文章《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
  1. MyClass(20) 构造一个临时对象
  2. 调用移动构造函数,这个临时对象传入到 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 推导为 TypeT&& 保持为右值引用

std::forwardstd::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,使用了大概率会影响编译器优化,反而会适得其反,性能更差了。

所以相信编译器,很多情况下,编译器会比人更 ‘聪明’。

总结

  1. std::move 不会改变对象的内容(不会移动),只是欺骗了编译器
  2. std::move 只是请求移动,能否真正移动取决于目标类型是否实现了移动构造函数或移动赋值运算符。如果没有,编译器会回退到拷贝
  3. 左值:调用拷贝构造函数,右值: 如果有移动构造函数,优先调用移动构造函数;否则回退到拷贝构造函数
  4. std::forward 有条件地转发参数,保留其原始值类别(左值右值
  5. 函数返回一个对象时,除非需要显示调用移动或者拷贝构造,否则不要使用 std::move,而是使用编译器的 RVO 优化
发表了60篇文章 · 总计141.46k字
本博客已稳定运行
© QX
使用 Hugo 构建
主题 StackJimmy 设计