C++11 工程级应用 09:告别无谓拷贝,解锁高性能移动语义

C++11 工程级应用 09:告别无谓拷贝,解锁高性能移动语义

写 C++ 的时候你是否遇到过这种糟心场景:函数返回一个大对象,临时对象轰轰烈烈完成深拷贝,用完立刻销毁,大量堆内存来回复制,CPU 和内存白白消耗;一不小心浅拷贝还会触发double free 重复析构崩溃。C++11 带来的右值引用、移动语义、std::movestd::forward,正是来解决这些痛点的利器。

Bilibili 同步视频

C++11 工程级应用 09:告别无谓拷贝,解锁高性能移动语义

一、传统拷贝的噩梦:浅拷贝崩溃与低效深拷贝

在 C++ 没有移动语义的年代,如果我们的类持有堆上的资源(指针、动态数组),默认生成的拷贝构造只会做浅拷贝 :仅仅复制指针的值,多个对象会指向同一块堆内存。等到对象析构,多个对象都会执行delete释放同一块内存,直接触发重复删除,程序直接 crash💥。

看下面这个典型踩坑代码:

cpp 复制代码
#include <iostream>
class A
{
public:
    A():m_ptr(new int(0)){}
    ~A(){
        delete m_ptr;
    }
private:
    int* m_ptr;
};

A Get (bool flag)
{
    A a;
    A b;
    if(flag)
        return a;
    else
        return b;
}

int main()
{
    A a=Get(false); // ❌运行报错,重复释放内存
    return 0;
}

这里Get函数返回局部对象,编译器默认浅拷贝,多个对象持有同一个m_ptr,析构阶段同一块内存被 delete 多次,程序直接崩溃。

为了解决崩溃问题,我们只能手写深拷贝拷贝构造函数:拷贝的时候重新开辟一块堆内存,复制内存里面的数据。

cpp 复制代码
A(const A& a):m_ptr(new int(*a.m_ptr))// ✅深拷贝,每个对象拥有独立资源
{
    std::cout << "copy construct" << std::endl;
}

深拷贝确实解决内存重复释放的 bug,但代价来了!

当返回 ** 临时对象(右值)** 的时候:临时对象马上就要销毁,我们仅仅为了接收返回值,完整复制一遍堆内存。如果对象持有超大内存缓冲区,这份拷贝开销会非常可观,拷贝完临时对象直接销毁,刚复制出来的原始资源直接丢弃 ------ 这完全是一笔毫无意义的性能开销😵。

简单总结痛点:

  1. 默认浅拷贝:程序崩溃,不可用;

  2. 手写深拷贝:安全,但临时对象场景产生大量无用拷贝,性能拉胯。

我们理想的方案:对于马上就要消亡的临时对象,不要拷贝资源,直接把资源 "抢夺" 过来,接管它的堆内存,原对象直接置空,析构的时候什么也不用释放 。这就是 C++11 的移动语义

二、移动语义 & 右值引用:资源 "搬家" 而不是复制📦

C++11 引入右值引用****T&& ,专门用来捕获临时对象(右值),基于它实现移动构造函数。移动构造不做内存复制,只做资源所有权转移。

改写上面的类,增加移动构造函数:

cpp 复制代码
#include <iostream>
class A
{
public:
    A():m_ptr(new int(0))
    {
        std::cout << "construct" << std::endl;
    }
    // 拷贝构造:深拷贝,给左值使用
    A(const A& a):m_ptr(new int(*a.m_ptr))
    {
        std::cout << "copy construct" << std::endl;
    }
    // 移动构造:右值引用,转移资源,不拷贝内存
    A(A&& a):m_ptr(a.m_ptr)
    {
        a.m_ptr = nullptr; // 将源对象指针置空,源对象析构不会释放资源
        std::cout << "move construct" << std::endl;
    }
    ~A()
    {
        std::cout << "destruct" << std::endl;
        delete m_ptr; // nullptr执行delete是安全的,不会报错
    }
private:
    int* m_ptr;
};

A Get (bool flag)
{
    A a;
    A b;
    if(flag)
        return a;
    else
        return b;
}

int main()
{
    A a=Get(false);
    return 0;
}

输出结果:

Plain 复制代码
construct
construct
move construct
destruct
destruct
destruct

对比深拷贝版本,输出里面消失了copy construct,取而代之是move construct

  • 移动构造函数拿到即将消亡临时对象的内部指针;

  • 直接接管堆内存地址,没有new,没有内存复制;

  • 将源临时对象内部指针置空;源对象析构时delete nullptr是安全操作,不会重复释放。

💡关键理解:

  • 拷贝语义:复制一份一模一样的资源,新旧对象各自拥有一份资源

  • 移动语义:资源搬家!原对象放弃资源所有权,新对象接管,原对象变成空壳

拿一个自定义字符串MyString类再加深理解。传统临时对象赋值场景,会触发昂贵的拷贝;增加移动构造、移动赋值之后,临时对象直接移交内部 char 数组,省去 malloc、memcpy、free 整套昂贵操作。

cpp 复制代码
#include <iostream>
#include <cstring>
class MyString{
private:
    char* m_data;
    size_t m_len;
    void copy_data (const char *s) {
        m_data = new char [m_len+1];
        memcpy(m_data, s, m_len);
        m_data[m_len]='0';
    }
public:
    MyString(){
        m_data = nullptr;
        m_len=0;
    }
    MyString (const char* p){
        m_len = strlen(p);
        copy_data(p);
    }
    //拷贝构造
    MyString(const MyString& str){
        m_len=str.m_len;
        copy_data(str.m_data);
        std::cout <<"Copy Constructor is called! source: "<< str.m_data << std::endl;
    }
    //拷贝赋值
    MyString&operator=(const MyString& str){
        if(this!=&str){
            m_len = str.m_len;
            copy_data(str.m_data);
        }
        std::cout <<"Copy Assignment is called! source: "<< str.m_data << std::endl;
        return *this;
    }
    //移动构造
    MyString(MyString&& str){
        std::cout <<"Move Constructor is called! source: "<< str.m_data << std::endl;
        m_len=str.m_len;
        m_data = str.m_data;
        str.m_len=0;
        str.m_data = nullptr;
    }
    //移动赋值
    MyString&operator=(MyString&& str) {
        std::cout <<"Move Assignment is called! source: "<< str.m_data << std::endl;
        if(this!=&str){
            //释放自身旧资源
            delete[] m_data;
            m_len = str.m_len;
            m_data = str.m_data;
            str.m_len=0;
            str.m_data=nullptr;
        }
        return *this;
    }
    virtual ~MyString(){
        delete[] m_data;
    }
};

void test(){
    MyString a;
    a=MyString("Hello"); // MyString("Hello")是临时右值,调用移动赋值
}
int main(){
    test();
    return 0;
}

📌开发规范:

实现移动构造 / 移动赋值,不要丢掉拷贝版本。当传入左值的时候,依旧可以回退到拷贝逻辑,保证代码兼容性和安全性。移动是优化手段,不是必须替代拷贝。

三、std::move:让左值也可以被移动🚚

移动语义默认只会匹配右值(临时对象)。那如果是一个实实在在存在的左值对象,我确定后续不会再使用它,能不能也触发移动?

C++11 提供std::move

⚠️划重点:std::move并不会真正移动任何数据!!

它的全部工作仅仅是:把一个左值强制转换为右值引用类型,让编译器有机会去调用移动构造 / 移动赋值。

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>
struct Element {
    Element()=default;
    Element (Element&& other) noexcept
        : m_children(std::move(other.m_children)){}
    Element (const Element& other) : m_children(other.m_children){}
private:
    std::vector<int> m_children;
};

void Test()
{
    Element t1;
    std::vector<Element>v;
    v.push_back(t1);          // t1是左值,调用拷贝构造,完整复制vector
    v.push_back(std::move(t1));// 将左值t1转成右值,触发移动构造,资源转移,无拷贝
}

这里std::move(t1)之后,t1变成被移出对象,它处于有效但是未定义的状态,不要再去读取 t1 内部数据,仅允许赋值或者析构。

举个容器的例子,大 list 直接拷贝代价巨大:

cpp 复制代码
std::list<std::string> tokens;
// ...填充大量数据
std::list<std::string> t = tokens;       // 全部深拷贝,性能开销巨大
std::list<std::string> t2 = std::move(tokens); // 仅仅转移内部链表指针,几乎零开销

❗误区澄清:

std::move不是万能魔法。对于 int、普通数组这类基础类型,就算调用std::move,依然会执行拷贝。移动语义只对持有堆、句柄这类资源的对象才有收益。标准库 STL 容器全部已经实现移动语义,日常开发可以直接享用这份性能红利。

四、std::forward 完美转发:保留参数原本的值类别🎯

这里会遇到一个容易踩坑的模板问题:

模板函数接收右值引用参数,但是参数进入函数体内之后,它会变成一个左值!哪怕传入的原本是右值。直接继续向下传递,会丢失右值属性,无法触发移动语义。

cpp 复制代码
template<typename T>
void forwardValue(T&& val)
{
    processValue(val); // val在函数内部是左值!哪怕外面传进来是右值
}

不管入参原来是左值还是右值,直接传进去,全部退化成普通左值,移动语义就失效了。

我们需要一种转发工具:保持参数原本的值类别,左值传进去还是左值,右值传进去依旧是右值 ,这就是std::forward完美转发。

std::forward<T>(args)会根据模板推导出来的 T 类型,还原参数原始的左 / 右值属性。

我们可以基于可变模板参数,写一个通用万能函数包装器:

cpp 复制代码
#include <iostream>
#include <string>
template<class Function, class... Args>
inline auto FuncWrapper(Function && f,Args &&...args)
->decltype(f(std::forward<Args>(args)...))
{
    return f(std::forward<Args>(args)...);
}

void test0()
{
    std::cout <<"void func run"<<std::endl;
}
int test1()
{
    return 1;
}
int test2(int x)
{
    return x;
}
std::string test3(std::string s1, std::string s2)
{
    return s1 + s2;
}

int main()
{
    FuncWrapper(test0);
    std::cout << FuncWrapper(test1) << std::endl;
    std::cout << FuncWrapper(test2, 666) << std::endl;
    std::cout << FuncWrapper(test3,"hello ","c++11") << std::endl;
    return 0;
}

包装器中通过std::forward<Args>(args)...,完整保留每一个参数的左、右值属性。调用目标函数时,如果传的是临时对象右值,目标函数依旧收到右值,可以正常触发移动语义。

✨一句话理解三者关系:

  1. 右值引用 **T&&**:语法基础,用来捕获临时对象;

  2. std::move:强制把左值转为右值引用;

  3. std::forward:模板场景,原样转发参数的左 / 右值属性,实现完美转发。

五、开发实践小结📝

  1. 当你的类持有堆内存、文件句柄这类独占资源,除了拷贝构造、拷贝赋值,建议实现移动构造、移动赋值。临时对象返回、容器插入场景,可以规避昂贵深拷贝;

  2. std::move只是类型转换,移动后的源对象不要继续使用;只有资源型对象使用才有性能收益;

  3. 模板封装转发函数,请使用std::forward做完美转发,保护参数的值类别,防止移动语义失效;

  4. 移动语义是性能优化,不能完全替代拷贝。移动构造函数抛出异常会造成资源混乱,建议加上noexcept修饰,STL 容器遇到 noexcept 的移动构造才会优先启用移动。

C++11 右值引用这套特性,让我们不用牺牲内存安全,就把大量无谓对象拷贝消灭掉,是现代 C++ 高性能编程绕不开的核心知识点。

相关推荐
2601_965742221 小时前
全媒体运营与短视频代运营,两者有什么区别?
大数据·数据结构·人工智能·算法·ai·媒体
MC皮蛋侠客2 小时前
OPC UA 系列(一):标准全景与 Python 最小闭环——让第一条设备数据流动起来
开发语言·python·opcua
彧azz2 小时前
数据结构:关于图的学习
c语言·数据结构·笔记·学习·算法
SamChan902 小时前
PDF翻译后的格式完整性校验:用Python自动比对译文与原文档的表格与段落结构
开发语言·python·ai·pdf·机器翻译
hetao17338372 小时前
校内场-提高组 ZYZSC-S-Round 4
c++·算法
君顾12 小时前
外卖CPS系统开发实战指南:从架构设计到部署全流程解析
java·开发语言·外卖
彧azz3 小时前
二叉搜索树学习记录
c语言·数据结构·笔记·算法
是立不是利3 小时前
前端交互基石:深入剖析JavaScript三级联动背后的设计哲学
开发语言·前端·javascript