每日一个C++知识点|对象资源传递机制

43 阅读7分钟

C++是一门对内存资源配置要求较高的语言,其中对象资源传递在C++开发中无处不在,下面我将在浅拷贝、深拷贝、左值右值、移动语义、完美转发这5个方面层层递进地讲解C++对象资源传递机制,争取做到知识串联,深入浅出~

浅拷贝

我们从一个实际场景入手:写一个Image类,存储图片的像素数据,代码如下:

#include <iostream>
using namespace std;

// 图片类:管理堆内存中的像素数据
class Image {
public:
    // 构造函数:分配堆内存(相当于“买快递箱装图片数据”)
    Image(int w, int h) : width(w), height(h) {
        // 每个像素占4字节(RGBA),分配一大块堆内存
        pixels = new char[width * height * 4]; 
        cout << "构造函数:分配内存,图片尺寸:" << width << "x" << height << endl;
    }

    // 析构函数:释放堆内存(相当于“扔掉快递箱”)
    ~Image() {
        if (pixels != nullptr) {
            delete[] pixels;
            cout << "析构函数:释放了内存" << endl;
        }
    }

private:
    int width, height;
    char* pixels; // 指向像素数据的指针(核心资源)
};

int main() {
    Image img1(1000, 1000); // 创建1000x1000的图片
    Image img2 = img1;      // 拷贝img1到img2
    return 0;
}

这段代码看起来没问题,却会触发内存错误。原因就是编译器默认拷贝方式是 浅拷贝

那么什么是浅拷贝呢?浅拷贝只拷贝成员变量的值,不拷贝资源本身,会造成两个对象共享同一块堆内存,相当于两个快递单号指向同一个快递箱。当多个对象共享资源,析构函数运行时就会崩溃。在上述代码中只把img1pixels指针地址复制给img2,没有把资源本身复制一份,导致程序结束后析构时双重释放img2先析构释放内存,img1析构时又去释放已经被释放的内存,直接崩溃

深拷贝

那么怎么解决浅拷贝带来的程序崩溃问题呢?一个简单的方法是使用深拷贝。深拷贝不仅拷贝成员变量,还为新对象重新分配资源并复制数据,使每个对象拥有独立资源,提升安全性。下面我们给Image类添加深拷贝构造函数(在原有代码的基础上直接添加,其他地方保持不变):

// 深拷贝构造函数:参数是const左值引用(const 类名&)
Image(const Image& other) {
    // 第一步:复制基础属性
    width = other.width;
    height = other.height;
    // 第二步:关键!重新分配新的堆内存(买新快递箱)
    pixels = new char[width * height * 4]; 
    // (实际开发中会复制像素数据,这里重点是“新分配内存”)
    cout << "深拷贝构造函数:新分配了内存" << endl;
}

以上拷贝构造函数会在编译器中自动执行,因而无需在main函数添加。

此时再运行代码,img1img2各自拥有独立内存,程序正常结束。但是深拷贝的内存分配和数据复制会带来巨大性能开销,如果是为了处理临时数据而产生这么大的开销,有点浪费资源。那么我们可不可以在深拷贝完成之后对临时数据进行删除呢?

假设我们有一个函数,生成一张临时的Image对象:

// 返回临时Image对象(无名字,是“即将销毁”的右值)
Image createWhiteImage(int w, int h) {
    Image temp(w, h);
    return temp;
}

因为Image temp(w, h)是在函数里实现的,也就是在栈内实现的,所以对象在函数执行时可以自动创建,函数运行结束后自动释放销毁;

再用深拷贝接收这个临时对象:

Image img3 = createWhiteImage(2000, 2000); // 深拷贝:耗时耗内存

这样就实现了在深拷贝完成之后对临时数据进行删除,但是这就像 “把快递里的东西复制一份,再把原快递箱扔掉”,完全没必要,这时候移动语义就该登场了。

左值和右值

在了解移动语义之前,我们需要了解一个重要的概念——左值右值

左值通常在等号左边,右值通常在等号右边,但是左值并非是在等号左边的对象,右值也并非是在等号右边的对象

左值是有名字、能取地址的对象,是持久存在 的对象。

右值是无名字、不能取地址的临时对象,是即将销毁的对象。

在上述代码中,img1是左值,int a = 10;中的a是左值;createWhiteImage()的返回值是右值。了解左值和右值的基本概念后,我们就能在移动语义中使用它们了~

移动语义

移动语义本质是转移右值的资源所有权,而非执行资源拷贝,所以可以达到减少资源浪费的效果

移动构造函数

要实现移动语义,需要给Image类添加移动构造函数:

// 移动构造函数:参数是右值引用(类名&&),通常加noexcept
Image(Image&& other) noexcept {
    // 第一步:“偷”走源对象的资源(仅复制指针地址,无内存分配)
    width = other.width;
    height = other.height;
    pixels = other.pixels;

    // 第二步:关键!将源对象置为空(作废原快递单号,避免析构冲突)
    other.pixels = nullptr;
    other.width = 0;
    other.height = 0;

    cout << "移动构造函数:直接转移资源,无内存分配!" << endl;
}

此时再接收临时对象:

Image img3 = createWhiteImage(2000, 2000); // 触发移动构造,瞬间完成

这就像 “直接把快递箱的地址改成自己的,不用复制里面的东西”,性能直接拉满~

std::move

std::move是把左值 “伪装” 成右值的小工具,如果想把左值的资源转移给其他对象,可以用std::move

Image img4(1500, 1500); // 左值
Image img5 = std::move(img4); // 触发移动构造,img4变为空

注意:它只是强制转换类型,不会真的移动数据

完美转发

移动语义解决了临时对象的拷贝问题,但在模板函数中,会遇到新问题:参数的左值和右值属性会丢失。

如下代码所示:

template <typename T>
void wrapper(T x) {
    Image img = x; // 无论x是左值还是右值,都触发深拷贝
}

// 调用:传入右值,却还是深拷贝
wrapper(createWhiteImage(1000, 1000));

由于模板参数x是拷贝后的对象,已经变成了左值,丢失了原来的右值属性

这时候需要用到完美转发来解决上述问题,完美转发在模板中保留参数的左值 / 右值属性,它需要两个核心要素:万能引用std::forward

万能引用

T&&是万能引用符号,仅在模板中使用,能绑定左值或右值

std::forward

std::forward:根据参数的原始类型,转发为左值或右值

用代码举例如下:

template <typename T>
void wrapper(T&& x) { // 万能引用
    Image img = std::forward<T>(x); // 完美转发:保留属性
}

// 测试:属性保留
Image img6(800, 800);
wrapper(img6); // 传入左值,触发深拷贝(符合预期)
wrapper(createWhiteImage(800, 800)); // 传入右值,触发移动构造

完美转发就像 “快递包装不拆,直接原封不动转发”,确保参数的属性不丢失。

总结

以上便是C++对象资源传递机制的主要内容,从浅拷贝、深拷贝、左值右值、移动语义、完美转发层层递进,如下图所示:

  1. 浅拷贝:绝对禁用(除非类无动态资源),会导致内存崩溃。
  2. 深拷贝:解决浅拷贝的内存崩溃,但需要更多内存开销。
  3. 移动语义:处理右值的性能方案,用资源转移代替拷贝,std::move可把左值转为右值。
  4. 完美转发:在模板中使用,保障移动语义在参数传递中生效。

如果这篇文章文章对你有用的话, 欢迎点赞收藏加关注哦~