【ChernoC++笔记】移动语义

【89】【Cherno C++】【中字】C++移动语义

❓为什么使用移动语义(moving semantics)?#

很多时候,我们需要通过复制来传递对象:

  • 例如,把一个对象传递给一个函数,这个函数需要得到这个对象的所有权,我们需要在当前stack frame中构造一个一次性对象,然后复制到调用的函数中。
  • 同样的,想从函数返回一个对象,需要在函数中创建那个对象,然后返回,也就是需要复制。(可以通过*返回值优化来解决)

但是有些情况下,我们不想把一个对象通过复制传递。当要传递的对象需要堆分配内存,比如一个字符串,如果需要复制,必须创建一个全新的堆分配。如果我们只是移动对象而不是复制对象,将大大地提高性能。

▶️一个拷贝构造的例子#

class String {
public:
    String() = default;
    String(const char* string) {
        printf("Created!\\n");
        m_Size = strlen(string);
        m_Data = new char[m_Size];
        // 将源字符串string移动到数据缓冲区m_Data
        memcpy(m_Data, string, m_Size);
    }

    // 拷贝构造函数
    String(const String& other) {
        printf("Copied!\\n");
        m_Size = other.m_Size; 
        m_Data = new char[m_Size];
        memcpy(m_Data, other.m_Data, m_Size);
    }

    ~String() {
        printf("Destroyed!\\n");
        delete m_Data;  // char是简单类型,不需要delete[]析构
    }

    void Print() {
        for(uint32_t i = 0; i < m_Size; i++) {
            printf("%c", m_Data[i]);
        }
        printf("\\n");
    }

private:
    char* m_Data;   // 实际的字符串数据
    uint32_t m_Size;
};

class Entity {
public:
    Entity(const String& name) : m_Name(name) {}

    void PrintName() {
        m_Name.Print();
    }

private:
    String m_Name;
};

int main() {
    Entity entity(String("Cherno"));
    entity.PrintName(); 
}

运行代码打印如下:

Created!   // main函数中构造String实例
Copied!    // 拷贝构造entity中的String实例
Destroyed! // other实例析构
Cherno
Destroyed! // entity中的m_Name实例析构

可以看到,为了构造一个“Cherno”Entity对象,我们为它分配了两次内存:首先要在main函数的作用域创建了一个String对象(”Created!”),即第一次分配内存;再传递给Entity构造函数,使用拷贝构造函数再次分配空间,创建entity.m_Name这个String对象(”Copied!”)。

为什么不能在main函数中分配,然后直接移动到entity.m_Name中呢?➡️➡️移动语义

▶️利用移动语义来避免拷贝#

  • 将Entity的构造函数传入参数改为一个右值引用,以传入临时值。并且需要将name显示转换为临时值,即使用(String&&)namestd::move(name),因为将一个临时变量绑定到右值引用上时,这个右值引用是一个左值
  • String类的move构造函数:不再需要new一个新的buffer,进行memcpy逐个复制数据。只需要给指针赋值,把新的字符串实例中创建的指针,指向other.m_Data指向的同一块数据。
  • 但是,旧的那个String实例(other)被删除后,会将它的数据带走。所以我们需要将other指向nullptr,即置空。当旧的String实例被销毁时,delete m_Data;实际上删除了nullptr。如果不进行置空,打印如下,可以看到PrintName时由于旧实例已经被销毁,无法打印出新实例的m_Data:

    Created!
    Moved!
    Destroyed!
    
    Destroyed!
  • 这么做实际上是接管了旧的String实例,重新连接了指针,即浅拷贝。而不是通过复制所有的数据并分配新内存来进行深拷贝
class String {
public:
    String() = default;
    String(const char* string) {
        printf("Created!\\n");
        m_Size = strlen(string);
        m_Data = new char[m_Size];
        // 将源字符串string移动到数据缓冲区m_Data
        memcpy(m_Data, string, m_Size);
    }

    // 拷贝构造函数
    String(const String& other) {
        printf("Copied!\\n");
        m_Size = other.m_Size; 
        m_Data = new char[m_Size];
        memcpy(m_Data, other.m_Data, m_Size);
    }

    // move构造函数
    String(String&& other) noexcept {
        printf("Moved!\\n");
        m_Size = other.m_Size; 
        m_Data = other.m_Data;
        // 不再需要分配新的数据缓冲区
        // 偷取了other的资源,放回一个空对象,析构时只delete nullptr
        other.m_Data = nullptr;
        other.m_Size = 0;
    }

    ~String() {
        printf("Destroyed!\\n");
        delete m_Data;  // char是基本类型,不需要delete[]析构
    }

    void Print() {
        for(uint32_t i = 0; i < m_Size; i++) {
            printf("%c", m_Data[i]);
        }
        printf("\\n");
    }

private:
    char* m_Data;   // 实际的字符串数据
    uint32_t m_Size;
};

class Entity {
public:
    Entity(const String& name) : m_Name(name) {}

    // 传入右值,会调用该构造函数
    // Entity(String&& name) : m_Name(name) {}
    // 需要将name显示转换为临时值,因为将一个临时变量绑定到右值引用上时,这个右值引用是一个左值
    // Entity(String&& name) : m_Name((String&&)name) {}
    // 或者,使用std::move也可以做到
    Entity(String&& name) : m_Name(std::move(name)) {}
    
    void PrintName() {
        m_Name.Print();
    }

private:
    String m_Name;
};

int main() {
    // String("Cherno")是一个临时变量,调用Entity的move构造函数
    Entity entity(String("Cherno"));
    entity.PrintName(); 
    std::cin.get();
}

运行代码打印如下:

Created!
Moved! // 成功使用了move构造函数
Destroyed!
Cherno
Destroyed!

🌟总结#

在通过复制构造函数来实例化对象的时候,每次构造都需要复制大量数据来传递对象,对程序性能造成极大影响。移动语义可以将临时对象的内存直接转移,而不是复制。

  • 复制构造函数:形参是一个左值引用,也就是实参必须是左值,在复制构造函数中一般进行的是深复制,即在不能破坏实参对象的前提下复制目标对象,一般要重新分配内存。
  • 移动构造函数:形参是一个右值引用,接受一个右值,通过转移实参对象的数据以构造目标对象,也就是说实参对象是会被修改的。

一般通过指针替换操作实现,然后要置空实参对象,以保证实参对象析构的时候不会影响目标对象内存。在移动构造函数中使用了指针转移的方式构造目标对象,整个程序的运行效率得到大幅提升。

*移动语义的风险来自异常。在一个移动构造函数中,如果当一个对象的资源移动到另一个对象时发生了异常,也就是说对象的一部分发生了转移而另一部分没有,这就会造成源对象和目标对象都不完整的情况发生,这种情况的后果是无法预测的。所以在编写移动语义的函数时建议确保函数不会抛出异常,与此同时,如果无法保证移动构造函数不会抛出异常,可以使用noexcept说明符限制该函数。这样当函数抛出异常的时候,程序不会再继续执行而是调用terminate中止执行以免造成其他不良影响。

posted @   rthete  阅读(143)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 阿里最新开源QwQ-32B,效果媲美deepseek-r1满血版,部署成本又又又降低了!
· AI编程工具终极对决:字节Trae VS Cursor,谁才是开发者新宠?
· 开源Multi-agent AI智能体框架aevatar.ai,欢迎大家贡献代码
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
主题色彩
点击右上角即可分享
微信分享提示