C++ 中的浅拷贝与深拷贝:概念、规则、示例与最佳实践(笔记)

一文吃透:什么是浅拷贝?什么是深拷贝?默认拷贝做了什么?哪些类型需要你亲自写"深拷贝"?Rule of Three/Five/Zerocopy-swap、移动语义、智能指针与标准容器的真实拷贝语义是什么?

目录


1. 两个术语的最短定义

  • 浅拷贝(Shallow Copy) :只复制"指针值/句柄"等外壳 ,不复制其指向/管理的底层资源。两个对象共享同一块资源。

  • 深拷贝(Deep Copy) :复制对象自身 的同时,也复制它所管理的底层资源(开辟新存储、逐元素拷贝等),两个对象彼此独立。

判断标准不是"代码写得多与少",而是拷贝后两个对象是否共享底层资源

2. C++ 里"拷贝"发生在何处

以下场景会触发拷贝构造拷贝赋值

  • 以值传参/以值返回(未被移动/优化省略时)

  • 用一个对象初始化另一个对象:T b = a; / T b(a);

  • 赋值:b = a;

  • 容器在扩容、插入时复制元素(若没有移动可用)

  • 标准算法需要复制元素时

此外还有移动构造/移动赋值(C++11+),下文详解。

3. "默认拷贝"(编译器生成)的真实行为

当你没有显式定义 拷贝构造/拷贝赋值时,编译器会做成员逐一拷贝(memberwise copy)

  • 内置类型int/double/struct POD...)值拷贝

  • 类成员:调用它们各自的拷贝构造/赋值

  • 指针成员 :只是拷贝指针的值,不复制指针指向的数据(这就是浅拷贝)

这意味着:一旦你的类自己"管理资源"(裸指针指向堆内存、文件句柄、套接字、GPU 缓冲等),默认拷贝往往是危险的浅拷贝。

4. 浅拷贝的典型灾难:双重释放与悬垂指针

下面是反例

cpp 复制代码
#include <cstring>
#include <iostream>

class BufferBad {
public:
    BufferBad(size_t n)
        : size_(n), data_(new char[n]) { std::memset(data_, 0, n); }

    // ❌ 没有自定义拷贝构造/拷贝赋值,编译器会生成"浅拷贝"
    ~BufferBad() { delete[] data_; }

    void fill(char c) { std::memset(data_, c, size_); }
    char* data() { return data_; }

private:
    size_t size_;
    char*  data_;
};

int main() {
    BufferBad a(8);
    a.fill('A');

    BufferBad b = a; // ⚠️ 浅拷贝:b.data_ 和 a.data_ 指向同一块内存
    // 程序结束时 ~BufferBad() 被调用两次 → 同一指针 delete[] 两次 → 未定义行为(炸)
}

问题本质 :浅拷贝导致两个对象共享一份资源,而析构会释放两次;如果其中一个对象释放后另一个继续访问,这又变成悬垂指针

5. 正确实现深拷贝:Rule of Three/Five、copy-swap

5.1 Rule of Three / Five / Zero

  • Rule of Three :如果类自己管理资源,你通常要同时自定义:

    1. 析构函数(~T()

    2. 拷贝构造(T(const T&)

    3. 拷贝赋值(T& operator=(const T&)

  • Rule of Five (C++11+):在上述三者基础上 加上:

    1. 移动构造(T(T&&) noexcept

    2. 移动赋值(T& operator=(T&&) noexcept

  • Rule of Zero :如果你把资源交给标准库类型 (如 std::vector/std::string/智能指针)管理,那么不需要自己写上面这些特殊成员函数(默认生成就好)。

5.2 手写一个"深拷贝 + 移动"的安全类

cpp 复制代码
#include <cstring>
#include <utility>
#include <stdexcept>

class Buffer {
public:
    explicit Buffer(size_t n = 0) : size_(n), data_(n ? new char[n] : nullptr) {
        if (data_) std::memset(data_, 0, n);
    }

    // 深拷贝:拷贝构造
    Buffer(const Buffer& other) : size_(other.size_), data_(other.size_ ? new char[other.size_] : nullptr) {
        if (data_) std::memcpy(data_, other.data_, size_);
    }

    // 深拷贝:拷贝赋值(强烈推荐 copy-swap 写法)
    Buffer& operator=(Buffer other) {  // ← 这里按值传参,构造一个临时副本(调用拷贝或移动构造)
        swap(other);                   // 与临时副本交换 → 强异常安全 & 自然处理自赋值
        return *this;                  // 临时副本析构时释放旧资源
    }

    // 移动构造(转移所有权,不分配/拷贝数据)
    Buffer(Buffer&& other) noexcept : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
    }

    // 移动赋值(同理,用 swap 即可)
    Buffer& operator=(Buffer&& other) noexcept {
        swap(other);
        return *this;
    }

    ~Buffer() { delete[] data_; }

    void fill(char c) {
        if (!data_) throw std::runtime_error("empty buffer");
        std::memset(data_, c, size_);
    }

    void swap(Buffer& rhs) noexcept {
        std::swap(size_, rhs.size_);
        std::swap(data_, rhs.data_);
    }

    size_t size() const noexcept { return size_; }
    const char* data() const noexcept { return data_; }

private:
    size_t size_;
    char*  data_;
};

要点

  • 深拷贝 :在拷贝构造中重新 new,并 memcpy 原数据。

  • 拷贝赋值 :用 copy-swap 惯用法 实现,天然处理异常安全与自赋值,代码简洁。

  • 移动语义 :移动构造/赋值只做指针偷取与置空,避免分配与拷贝。

6. 移动语义与深/浅拷贝的关系

  • 移动(move)既不是浅拷贝也不是深拷贝:它是**"转移资源所有权"**的第三种策略。

  • 有了移动语义,容器扩容、返回局部对象等场景可避免昂贵的深拷贝。

  • 当右值可用且你支持 T(T&&)/operator=(T&&) 时,标准库会优先移动而非拷贝。

7. 标准库类型的拷贝语义(必须搞清)

  • std::stringstd::vector<T>std::arraystd::map容器/字符串深拷贝"自己的存储" (逐元素拷贝),但元素本身如何拷贝取决于元素类型的拷贝语义。

    • std::vector<int>:拷贝就是复制每个 int

    • std::vector<char*>:只会浅拷贝指针值(指针指向的外部内存不会被复制)。

  • std::unique_ptr<T>独占所有权不可拷贝 (拷贝会编译失败),可移动。这能从语法上阻止"浅拷贝共享同一裸指针"的灾难。

  • std::shared_ptr<T>共享所有权 ,拷贝会增加引用计数 ------这不是深拷贝数据本身,而是浅拷贝指针 + 引用计数管理 。多个 shared_ptr 指向同一对象,最后一个销毁时才释放。

  • std::span<T>非拥有视图,拷贝只是复制"视图",绝不复制底层数据。

误区澄清:"容器拷贝是深拷贝"这句话要加前提------它只对容器自身所拥有的存储 成立;元素如果是指针/句柄,容器不会替你复制指针所指向的资源

8. 何时选深拷贝,何时避免拷贝

  • 你的类自己拥有并负责释放 底层资源(裸指针/句柄/文件/GPU 内存):要么实现深拷贝 + 移动 ,要么禁止拷贝(=delete)只允许移动

  • 性能敏感并且"复制"成本高:优先提供移动 ;必要时设计共享语义shared_ptr/引用计数/写时复制 COW)。

  • 能用 Rule of Zero 就不要手写特殊成员:把资源交给 std::vectorstd::stringstd::unique_ptr 等来管理。

9. 高频面试/作业陷阱清单

  1. 默认拷贝 + 裸指针 → 双重释放/悬垂指针(经典坑)。

  2. 只写了析构或拷贝构造,却忘了拷贝赋值/移动成员 → 违反 Rule of Three/Five。

  3. shared_ptr 拷贝是共享所有权,不是深拷贝底层对象(很多人误会)。

  4. 容器里放指针:容器拷贝只是浅拷贝这些指针;要深拷贝请放对象本体或自定义克隆逻辑。

  5. 自赋值 未处理:x = x; 可能崩;用 copy-swap 最省心。

  6. 异常安全:先构造副本,再交换,保证强异常安全。

  7. 移动操作缺 noexcept:容器优化可能退化为拷贝,性能直线下降。

10. 完整示例:从踩坑到优雅

10.1 错误版:浅拷贝导致双删

cpp 复制代码
class ImageBad {
public:
    explicit ImageBad(size_t n) : n_(n), pixels_(new uint8_t[n]) {}
    ~ImageBad() { delete[] pixels_; }
    // ❌ 未定义拷贝语义 → 默认浅拷贝(拷贝指针值)
private:
    size_t   n_;
    uint8_t* pixels_;
};

10.2 正确版 A:深拷贝 + 移动 + copy-swap

cpp 复制代码
#include <cstring>
#include <utility>

class Image {
public:
    explicit Image(size_t n = 0) : n_(n), pixels_(n ? new uint8_t[n] : nullptr) {}

    Image(const Image& rhs) : n_(rhs.n_), pixels_(rhs.n_ ? new uint8_t[rhs.n_] : nullptr) {
        if (pixels_) std::memcpy(pixels_, rhs.pixels_, n_);
    }

    Image& operator=(Image rhs) { // 拷贝或移动进来
        swap(rhs);                // 与临时对象交换
        return *this;             // rhs 析构释放旧资源
    }

    Image(Image&& rhs) noexcept : n_(rhs.n_), pixels_(rhs.pixels_) {
        rhs.n_ = 0; rhs.pixels_ = nullptr;
    }

    Image& operator=(Image&& rhs) noexcept {
        swap(rhs);
        return *this;
    }

    ~Image() { delete[] pixels_; }

    void swap(Image& other) noexcept {
        std::swap(n_, other.n_);
        std::swap(pixels_, other.pixels_);
    }

private:
    size_t   n_   = 0;
    uint8_t* pixels_ = nullptr;
};

10.3 正确版 B:Rule of Zero(推荐)

根本不自己管裸指针,资源交给标准库:

cpp 复制代码
#include <vector>
#include <cstdint>

class ImageSafe {
public:
    explicit ImageSafe(size_t n = 0) : pixels_(n) {}   // vector 自管内存
    // 无需写析构/拷贝/赋值/移动:默认全 OK(Rule of Zero)

private:
    std::vector<uint8_t> pixels_;
};

优点:代码最短、异常安全、天然深拷贝元素、自动支持移动、性能友好。


11. 总结与实践建议

  • 判断标准:拷贝后是否共享底层资源?共享 = 浅,不共享 = 深。

  • 默认拷贝是成员逐一拷贝:一旦自己管理资源,默认拷贝几乎必错。

  • 遵循 Rule of Three/Five/Zero

    • 能 Rule of Zero 就 Rule of Zero(容器/智能指针)。

    • 必须手写就三/五件套 + copy-swap + noexcept move

  • 移动语义优先:让昂贵对象在容器/返回值场景下移动而非拷贝。

  • 别把裸指针放进容器,放对象或智能指针(并理解其所有权语义)。

  • shared_ptr 拷贝不是深拷贝 ,而是共享所有权;如果需要克隆底层对象,请自定义 clone() 或自定义拷贝逻辑。

附:一个小型演示 main(可直接测试)

cpp 复制代码
#include <iostream>
#include <vector>
#include <string>

// 放上面定义的 Buffer 或 Image 类...

int main() {
    // 1) 深拷贝验证
    Buffer a(4);
    a.fill('X');
    Buffer b = a;      // 调用拷贝构造:深拷贝
    Buffer c; c = a;   // 调用拷贝赋值:深拷贝(copy-swap)

    // 2) 移动验证
    Buffer d = std::move(a); // a 资源被转移,a 置空
    Buffer e; e = std::move(b);

    // 3) Rule of Zero:vector 深拷贝元素
    std::vector<int> v1 = {1,2,3};
    std::vector<int> v2 = v1; // 拷贝了每个 int,互不影响
    v1[0] = 99;
    std::cout << v1[0] << " " << v2[0] << "\n"; // 99 1

    // 4) 指针元素只是浅拷贝指针值(演示语义,勿在生产中这样做)
    std::vector<const char*> p1 = {"abc", "def"};
    std::vector<const char*> p2 = p1; // 只是拷贝了指针
    std::cout << p1[0] << " " << p2[0] << "\n"; // 都指向同一字符串常量

    return 0;
}

最后一句话

在现代 C++ 中,能不用裸指针就不用 ,能交给 std::vector / std::string / 智能指针 管理就交给它们。这样你几乎不再需要自己写"深拷贝",自然获得正确、简洁且高性能的代码。

相关推荐
LEEBELOVED3 小时前
R语言高效数据处理-3个自定义函数笔记
开发语言·笔记·r语言
朝新_3 小时前
【SpringMVC】SpringMVC 请求与响应全解析:从 Cookie/Session 到状态码、Header 配置
java·开发语言·笔记·springmvc·javaee
2501_938782093 小时前
从实例到单例:Objective-C 单例类的线程安全实现方案
开发语言·macos·objective-c
浪裡遊3 小时前
css面试题1
开发语言·前端·javascript·css·vue.js·node.js
恒者走天下3 小时前
cpp / c++春招辅导5k吗
c++
喜欢吃燃面3 小时前
C++:红黑树
开发语言·c++·学习
佳哥的技术分享3 小时前
kotlin基于MVVM架构构建项目
android·开发语言·kotlin
给大佬递杯卡布奇诺3 小时前
FFmpeg 基本数据结构 URLContext分析
数据结构·c++·ffmpeg·音视频
zero13_小葵司3 小时前
JavaScript 性能优化系列(六)接口调用优化 - 6.4 错误重试策略:智能重试机制,提高请求成功率
开发语言·javascript·ecmascript