C++ 深浅拷贝:从默认拷贝到 Rule of Five

在 C++ 里,深浅拷贝不是语法关键字,而是资源管理策略

默认拷贝对裸指针是浅拷贝;深拷贝要自己写;现代 C++ 优先 Rule of Zero。


1. C++ 中拷贝发生在哪?

C++ 里有两个容易混淆的动作:

cpp 复制代码
Buffer a(10);
Buffer b = a;   // 拷贝构造:用 a 初始化 b
Buffer c(5);
c = a;          // 拷贝赋值:把 a 赋给已存在的 c
  • 拷贝构造T(const T& other),创建新对象。

  • 拷贝赋值T& operator=(const T& other),已有对象被赋值。

  • 如果自己不写,编译器会隐式生成默认版本。

默认版本做的事情是:成员逐一拷贝

intdouble 等标量,直接复制值。

std::stringstd::vector 等类类型成员,调用它们自己的拷贝构造。

裸指针成员,只复制指针地址。

所以关键不是"默认拷贝一定是浅拷贝",而是:

默认拷贝对裸指针/句柄是浅拷贝;对 RAII 类型成员,则依赖该类型自己的拷贝语义。


2. 浅拷贝:只复制指针,不复制资源

看一个危险例子:

cpp 复制代码
#include <cstring>

class Shallow {
public:
    explicit Shallow(const char* s)
        : data_(new char[std::strlen(s) + 1]) {
        std::strcpy(data_, s);
    }

    ~Shallow() {
        delete[] data_;
    }

    // 没有自定义拷贝构造和拷贝赋值
    // 编译器生成默认版本:浅拷贝

private:
    char* data_;
};

使用:

cpp

cpp 复制代码
int main() {
    Shallow a("hello");
    Shallow b = a; // 默认拷贝构造:b.data_ == a.data_
}

问题:

  • a.data_b.data_ 指向同一块堆内存。

  • 作用域结束时,ab 都会 delete[] data_

  • 同一块内存被释放两次:double free,程序崩溃或未定义行为。

浅拷贝还可能带来:

  • 悬垂指针;

  • 意外修改:改 b 影响 a

  • 资源泄漏或重复释放。


3. 深拷贝:分配新资源,复制内容

深拷贝要求:新对象拥有自己的资源,与原对象完全独立。

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

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

    explicit Buffer(const char* s)
        : size_(std::strlen(s)), data_(new char[size_ + 1]) {
        std::memcpy(data_, s, size_ + 1);
    }

    ~Buffer() {
        delete[] data_;
    }

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

    // 深拷贝赋值:copy-and-swap
    Buffer& operator=(const Buffer& other) {
        if (this != &other) {
            Buffer tmp(other);
            swap(tmp);
        }
        return *this;
    }

    // 移动构造:转移资源,不复制
    Buffer(Buffer&& other) noexcept
        : size_(other.size_), data_(other.data_) {
        other.size_ = 0;
        other.data_ = nullptr;
    }

    // 移动赋值
    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            size_ = other.size_;
            data_ = other.data_;
            other.size_ = 0;
            other.data_ = nullptr;
        }
        return *this;
    }

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

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

private:
    std::size_t size_;
    char* data_;
};

现在:

cpp 复制代码
Buffer a("hello");
Buffer b = a;      // 深拷贝,b 有自己的内存
Buffer c(3);
c = a;             // 深拷贝赋值,c 也独立
复制代码

修改 bc 不会影响 a


4. 拷贝赋值为什么要 copy-and-swap?

拷贝赋值最容易踩的坑:

  1. 自赋值a = a;

  2. 异常安全:分配新内存失败时,不能破坏原对象。

copy-and-swap 写法:

cpp 复制代码
Buffer& operator=(const Buffer& other) {
    if (this != &other) {
        Buffer tmp(other); // 先拷贝,失败也不影响 *this
        swap(tmp);         // 交换后,tmp 持有旧资源
    }
    return *this;
} // tmp 析构,释放旧资源
复制代码

优点:

  • 自赋值安全;

  • 强异常安全;

  • 拷贝逻辑复用拷贝构造。


5. 移动语义:不是拷贝,是资源转移

深拷贝很贵。如果源对象马上要销毁,就没必要复制资源,直接"偷"过来即可。

cpp 复制代码
Buffer a(100);
Buffer b = std::move(a); // 移动构造
复制代码

移动后:

  • b 拿到 a 原来的指针和大小;

  • a 被置空;

  • 没有堆内存复制,通常 O(1)。

移动构造和移动赋值应尽量标记 noexcept,这样 std::vector 扩容时才会优先移动而不是拷贝。

注意:

std::move 本身不移动,只是把表达式转换成右值引用。


6. Rule of Three / Five / Zero

Rule of Three

如果你需要自定义以下三者中的任意一个:

  • 析构函数;

  • 拷贝构造;

  • 拷贝赋值;

通常三个都需要。

因为这说明类在管理资源。只写析构,不写拷贝,就会掉进浅拷贝陷阱。

Rule of Five

C++11 之后,再加上:

  • 移动构造;

  • 移动赋值。

合称五法则。上面的 Buffer 就是五法则实现。

Rule of Zero

最推荐:不要自己管理资源

std::stringstd::vectorstd::unique_ptrstd::shared_ptr 等 RAII 类型作为成员,让编译器生成正确的拷贝、移动和析构。

cpp 复制代码
class Person {
public:
    Person(std::string name, std::vector<int> scores)
        : name_(std::move(name)), scores_(std::move(scores)) {}

private:
    std::string name_;
    std::vector<int> scores_;
};

这个类不需要写析构、拷贝构造、拷贝赋值、移动构造、移动赋值。

编译器生成的版本会正确调用 std::stringstd::vector 的拷贝/移动。


7. 常见 RAII 类型的拷贝语义

类型 拷贝行为 说明
std::string 深拷贝 新字符串拥有自己的缓冲区
std::vector<T> 深拷贝 新 vector 拥有自己的元素存储
std::unique_ptr<T> 禁止拷贝 只能移动,独占所有权
std::shared_ptr<T> 共享所有权 拷贝只增加引用计数,不复制对象
裸指针 T* 浅拷贝 只复制地址,危险
自定义资源类 看实现 需要自己决定深/浅/移动

示例:

cpp 复制代码
std::vector<int> a = {1, 2, 3};
std::vector<int> b = a; // 深拷贝
b[0] = 100;
// a[0] 仍然是 1
复制代码
cpp 复制代码
auto p1 = std::make_shared<Buffer>(10);
auto p2 = p1; // 共享同一个 Buffer,不是深拷贝
复制代码

如果你想要 shared_ptr 的深拷贝,需要手动克隆:

cpp 复制代码
auto p3 = std::make_shared<Buffer>(*p1); // 调用 Buffer 的拷贝构造
复制代码

8. 面试常见问题

Q:默认拷贝构造是浅拷贝吗?

A:默认拷贝构造是成员逐一拷贝。对裸指针成员,它复制地址,是浅拷贝;对 std::stringstd::vector 等成员,会调用它们的拷贝构造,通常是深拷贝。

Q:浅拷贝会导致什么问题?

A:共享资源,可能出现 double free、悬垂指针、意外修改。

Q:深拷贝怎么实现?

A:在拷贝构造中分配新资源并复制内容;在拷贝赋值中用 copy-and-swap 或先释放再复制,并处理自赋值。

Q:拷贝赋值为什么需要自赋值检查?

A:防止先释放自己的资源,再从已释放的资源拷贝。

Q:移动和拷贝的区别?

A:拷贝复制资源;移动转移资源,源对象变成有效但未指定状态。

Q:什么时候用 Rule of Zero?

A:优先使用 RAII 成员,避免手动管理资源,让编译器生成特殊成员函数。


9. 总结

  • C++ 默认拷贝是成员逐一拷贝

  • 裸指针成员默认拷贝是浅拷贝,共享资源。

  • 深拷贝需要自己分配资源并复制内容。

  • 管理资源的类通常遵循 Rule of Three / Five

  • 现代 C++ 优先 Rule of Zero :用 stringvector、智能指针。

  • 移动语义用于转移资源,不是拷贝。

  • unique_ptr 不可拷贝,shared_ptr 拷贝是共享所有权,不是深拷贝。

一句话:

裸指针默认浅拷贝,资源类要深拷贝,现代 C++ 尽量零法则。

相关推荐
青山木1 小时前
Hot 100 --- 划分字母区间
java·数据结构·算法·leetcode·贪心算法
Zzzzmo_1 小时前
SpringBoot 配置文件
java·spring boot
君顾11 小时前
外卖CPS小程序开发实战指南:从零到上线的完整流程
java·开发语言·外卖
無a伟1 小时前
RabbitMq高级特性:消息确认,持久化,重试机制
java·分布式·rabbitmq
blue_ice .1 小时前
DDS原理及简易实现
开发语言·经验分享·笔记·嵌入式硬件·fpga开发
励志不掉头发的内向程序员1 小时前
【LibreCAD 2D架构】鼠标点下的坐标为什么会被“吸”走?LibreCAD 对象捕捉系统解析
开发语言·c++·qt·学习·系统架构
流烟默1 小时前
xxl-job 接入 Spring 的两种方式:手动装配与自动装配(含 initMethod 双启动踩坑实录)
java·spring·xxl-job
鹅城剑仙1 小时前
Spring Boot 3 全局异常处理与统一响应封装进阶实战
java·spring boot·后端
Generalzy1 小时前
像 gofmt 一样格式化 Python:Black、Ruff、YAPF、autopep8 谁才是 2026 年的首选?
开发语言·python