ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++深拷贝与浅拷贝:拷贝构造函数、内存管理与避坑指南

C++深拷贝与浅拷贝:拷贝构造函数、内存管理与避坑指南 1. 从一个崩溃现场讲起为什么拷贝这件事必须较真先说个我踩过的坑。当时我在写一个日志缓冲模块核心结构体里有一个char*指针指向一块堆上分配的内存。模块运行一周都很平稳直到某一天缓存队列做了一次排空程序直接 double free。崩溃栈指向的竟然是std::vector的内部操作当时第一反应是“vector 怎么会有问题”。后来一点点剥开才发现问题出在我自己定义的结构体上它含指针成员却用了编译器默认生成的拷贝行为。那一刻我才真正意识到拷贝构造函数里的深拷贝和浅拷贝不是教科书里那种“背完就忘”的概念而是会直接决定程序能不能跑的工程细节。针对 C 初学者来说很多人写类的第一步是放几个int、string然后心满意足地编译通过。但一旦类里有了char*、int*、FILE*这类资源型成员或者某个成员是std::shared_ptr默认的“逐成员拷贝”就会变成定时炸弹。这篇内容适合正在学 C 的入门者也适合写了不少代码但没认真梳理过拷贝语义的开发者。我会把浅拷贝、深拷贝、拷贝构造函数背后的原理拆开配合可以直接跑的代码示例讲清楚为什么会有问题、怎么修以及怎么避免以后再踩。2. 从底层机制看浅拷贝与深拷贝的本质区别2.1 浅拷贝只复制值不复制资源浅拷贝shallow copy的本质很直白按位复制。也就是说源对象的每一个成员变量原封不动地复制到目标对象里。对于int、double、bool这种内置类型按位复制没有任何风险因为值本身就代表了全部信息。但对于指针成员按位复制的结果是两个对象的指针成员指向同一块地址。我用一句话概括浅拷贝让两个对象共享同一份资源但没有告诉编译器这个共享关系。于是后面只要其中一个对象修改了这块内存另一个对象看到的数据也变了更致命的是当两个对象都执行析构逻辑来释放这块内存时就会出现 double free。class Message { public: const char* data; Message(const char* str) { data str; // 假设指向全局字符串常量 } };如果这里的数据不是常量字符串而是用new char[]分配出来的那默认拷贝就会出问题。看下面这段class Buffer { public: char* data; int length; Buffer(int size) { length size; data new char[size]; } ~Buffer() { delete[] data; // 谁释放两个对象会重复释放 } }; int main() { Buffer a(1024); Buffer b a; // 默认拷贝构造b.data 和 a.data 指向同一块内存 // 函数结束时a 和 b 各自析构delete[] 同一块内存 → double free return 0; }这是典型的浅拷贝。程序崩不崩溃取决于析构顺序和编译器是否报错。在 Debug 模式下可能直接弹断言Release 模式下可能表现为随机崩溃或者更隐蔽的“数据被篡改”。2.2 深拷贝复制值也为资源开辟新空间深拷贝deep copy的目标是让两个对象完全独立不仅成员变量的值相同而且指针所指向的内容也各有一份。这样修改b的数据不会影响a析构时各自释放各自的内存互不干扰。深拷贝的关键动作是为目标对象的指针成员分配新内存然后把源对象指向的数据内容复制过来。听起来不复杂但它决定了拷贝构造函数、赋值运算符、析构函数这三者必须协同工作。光写一个拷贝构造函数还不够如果赋值运算符还保留默认行为a b依然会浅拷贝。class Buffer { public: char* data; int length; Buffer(int size) { length size; data new char[size]; } // 深拷贝构造函数 Buffer(const Buffer other) { length other.length; data new char[length]; memcpy(data, other.data, length); } // 深拷贝赋值运算符 Buffer operator(const Buffer other) { if (this other) return *this; delete[] data; // 释放原有资源 length other.length; data new char[length]; memcpy(data, other.data, length); return *this; } ~Buffer() { delete[] data; } };这才是完整的安全姿态。这里我要强调一点深拷贝不是一定比浅拷贝好。如果这个类在设计上就希望多个对象共享同一块只读数据比如程序里的常量配置那浅拷贝反而省内存、快。问题从来不在于“浅拷贝是错的”而在于“你选择了浅拷贝却没有把共享语义表达清楚”。2.3 明白控制权选择用哪种拷贝语义是设计决策做工程时最怕的就是默认行为。编译器的默认拷贝构造、默认赋值运算本质上都是浅拷贝它不会问你是否接受共享语义。所以当你显式定义了析构函数却忘了定义拷贝构造和赋值运算符时编译器会静默地生成浅拷贝行为——这在 C 里被称为“三法则”Rule of Three警告这三个函数要么都自定义要么都不自定义。我个人的经验是先问自己一个问题这个类的成员里有裸指针吗如果有是“观察型指针”还是“拥有型指针”观察型指针不负责释放浅拷贝没问题拥有型指针必须释放那就必须深拷贝或者用智能指针彻底替换裸指针。这个问题想清楚了拷贝语义的选择也就清楚了。3. 拷贝构造函数的触发时机、形式规定与引用传参的深层原因3.1 什么情况下会触发拷贝构造函数拷贝构造函数的定义形式是ClassName(const ClassName other)它在以下场合被调用用一个对象初始化另一个同类型对象ClassName b a;按值传参void func(ClassName obj);然后func(a);按值返回ClassName getObj() { return localObj; }这里在新标准下可能被移动语义优化掉但概念上仍是拷贝在标准容器中插入元素vec.push_back(a);如果a是左值会触发拷贝构造以对象初始化std::pair、std::tuple、std::optional等封装类型时特别容易踩错的一点是ClassName b a;和b a;根本不同。前者是初始化调用拷贝构造函数后者是赋值调用赋值运算符。很多新手在这两个地方搞混导致明明重载了拷贝构造函数赋值时却不生效。还有一点ClassName b a;看起来很像赋值但它不是。编译器在处理初始化时会优先匹配构造函数存在拷贝构造函数时直接调用没有拷贝构造函数但有接受const ClassName或右值引用的构造函数时逻辑上会被展开。推荐写法是ClassName b(a);语义更明确二者等价。3.2 为什么参数必须是 const T而不是 T拷构参数必须是引用否则会无限递归。假设我们写ClassName(const ClassName other); // 错误写法当ClassName b a;发生时为了构造other这个形参又要复制a于是又调用拷贝构造函数新参数又是按值传递又要复制……整个过程无限递归最终栈溢出。如果参数是ClassName而非const ClassName也能编译但有两类问题一是无法接受临时对象ClassName b ClassName();这类右值初始化会被拒绝二是按非 const 引用传参意味着函数可以修改源对象拷贝构造的本意不该是修改源对象所以几乎不会这么写。标准做法就是const T这也顺带保证了临时对象可以被绑定。3.3 用户没有写拷贝构造函数时编译器在做什么如果你没有提供任何构造函数编译器会生成一个默认构造函数以及一个成员逐一拷贝的默认拷贝构造函数。它的行为可以理解为ClassName(const ClassName other) : member1(other.member1), member2(other.member2) { }注意这里是“逐成员拷贝”不是“逐字节拷贝”。也就是说如果成员本身是std::string它会调用string的拷贝构造函数做深拷贝如果成员是char*它只复制指针值不做深拷贝如果成员是std::shared_ptrint它会增加引用计数共享所有权如果成员是std::unique_ptrint编译器会直接报错因为unique_ptr禁止拷贝。理解了这一点你会发现自己其实一直在和拷贝语义打交道只是没意识到。4. 手写深拷贝三件套一个完整的 MyString 类实现4.1 从需求倒推设计为了把深拷贝讲透我写一个极简字符串类MyString它内部维护char* m_data和size_t m_size。这个类需要支持构造、拷贝构造、拷贝赋值、析构最好还能输出内容。核心需求就一条任何一个对象被复制后都不能影响源对象。先看基础实现#include iostream #include cstring class MyString { public: MyString(const char* str ) { m_size strlen(str); m_data new char[m_size 1]; strcpy(m_data, str); } ~MyString() { delete[] m_data; } const char* c_str() const { return m_data; } private: char* m_data; size_t m_size; };这个版本没定义拷贝构造和赋值运算符所以MyString b a;会让b.m_data和a.m_data指向同一块内存。一旦a析构b就成了“悬垂指针”再访问b.c_str()就是未定义行为。4.2 深拷贝构造函数的细节实现以下是安全的拷贝构造MyString(const MyString other) { m_size other.m_size; m_data new char[m_size 1]; strcpy(m_data, other.m_data); }这里有两个容易忽略的点第一必须先读取other.m_size再分配内存。如果先分配自己的内存再读other一旦other在读取过程中发生什么异常理论场景资源管理就会出问题。虽然现实中很少遇到但养成先计算再分配的习惯有好处。第二strcpy之前要确保目标缓冲区足够大。我申请了m_size 1个字节其中最后一个字节是\0。为什么是m_size 1而不是m_size因为字符串需要零终止符。如果漏掉1strcpy会越界写入这个问题比浅拷贝还阴险程序可能几个月都不会崩直到某次堆布局变化才爆发。4.3 赋值运算符处理自赋值和异常安全赋值运算符写起来比拷贝构造麻烦因为要处理三个问题自赋值、旧资源释放、异常安全。先看常规写法MyString operator(const MyString other) { if (this other) return *this; // 自赋值保护 delete[] m_data; // 释放旧资源 m_size other.m_size; m_data new char[m_size 1]; // 如果这里抛出异常对象已被清空 strcpy(m_data, other.m_data); return *this; }这个写法在绝大多数场景够用但不完美。问题在于如果new char抛出异常内存不足m_data已经被delete[]删掉了对象处于“半毁坏”状态。对于一门强调异常安全的语言来说这不算完整的解决方案。更好的思路是“复制并交换”Copy-and-Swap。核心逻辑是先用拷贝构造函数创建一个临时对象再交换当前对象和临时对象的内部状态临时对象析构时带走旧资源。这样如果拷贝构造失败当前对象完全没有被修改。#include utility class MyString { public: // 拷贝构造保持不变 ... void swap(MyString other) noexcept { std::swap(m_data, other.m_data); std::swap(m_size, other.m_size); } MyString operator(const MyString other) { MyString temp(other); // 深拷贝若有异常在此抛 swap(temp); // 危险操作由 swap 完成temp 负责销毁旧数据 return *this; } };这个写法我强烈推荐。它不需要自赋值检查因为临时对象本身就是源对象的独立副本它天然异常安全因为异常只可能发生在temp的构造过程中它简洁逻辑清晰。代价是多一次构造和析构但相对正确性这点性能开销完全可以接受。4.4 析构函数与拷贝语义的关联析构函数的职责是释放对象持有的资源。如果拷贝构造函数做了深拷贝析构函数就要对称地释放自己的那一份如果拷贝构造函数没有做深拷贝析构函数释放资源时必然会踩到别人。所以析构函数的存在就是强制拷构语义一致性的信号。很多代码规范会建议如果类里有delete/delete[]就必须同时显式实现拷贝构造函数和拷贝赋值运算符否则就把拷贝禁用掉。这个建议是靠谱的。如果某类只是为了传递数据根本不想被复制完全可以把拷贝构造和赋值运算符设为 deleteclass NonCopyable { public: NonCopyable() default; NonCopyable(const NonCopyable) delete; NonCopyable operator(const NonCopyable) delete; };这样做的好处是把错误从“运行期崩溃”提前到“编译期报错”这是性价比最高的修复方式。5. 面向现代 C移动语义、智能指针与规则五法则5.1 用 std::vector 等容器成员自动获得深拷贝一个容易被忽略的点是如果类成员本身是std::string、std::vector、std::map这类标准容器编译器生成的默认拷贝构造函数本身就会深拷贝这些成员。比如class UserInfo { public: std::string name; std::vectorint scores; };这里不用写任何拷贝构造UserInfo b a;时b.name和b.scores各自深拷贝完全安全。这背后的原因是标准库容器类都遵循值语义它们的拷贝构造函数内部已经实现了深拷贝逻辑。所以一个关键建议是能用标准库容器就直接用别自己去造轮子管理裸指针。裸指针 手动new只在极少数场景下才是必要选择绝大多数情况std::string和std::vector能把深拷贝的活干得又稳又快。5.2 移动构造让“拷贝”变成“转移”C11 引入的右值引用带来了移动语义。移动构造函数的核心是“偷走”源对象的资源把源对象置为可析构的空状态避免深拷贝的开销。MyString(MyString other) noexcept : m_data(other.m_data), m_size(other.m_size) { other.m_data nullptr; other.m_size 0; }这里没有分配内存只是把other.m_data的地址移交给自己然后把other.m_data置空。这样源对象析构时不会释放我们正在使用的内存。移动赋值运算符类似但也需要释放旧资源MyString operator(MyString other) noexcept { if (this other) return *this; delete[] m_data; m_data other.m_data; m_size other.m_size; other.m_data nullptr; other.m_size 0; return *this; }加了移动语义后std::vectorMyString在扩容时如果元素是临时对象或通过std::move转移就不会触发深拷贝性能提升非常明显。现代 C 的“规则五”Rule of Five就是构造函数、析构函数、拷贝构造、拷贝赋值、移动构造、移动赋值这六个里面只需要手动管理资源时最好一起考虑。不过说实话手写移动构造的出错率比手写拷贝构造还高。更稳的路线是使用std::unique_ptr或std::shared_ptr作为成员把它们作为资源管理工具。unique_ptr禁止拷贝但允许移动shared_ptr拷贝时引用计数加一本质上是浅拷贝 共享语义。选哪种取决于你的设计目标。5.3 现代写法的典型样板带智能指针的类如果类成员是std::unique_ptrint你不需要手写拷贝构造——因为unique_ptr本来不让拷贝。如果确实需要拷贝可以使用std::shared_ptr#include memory class SharedBuffer { public: std::shared_ptrint[] data; SharedBuffer(size_t size) : data(new int[size]) { } // 不需要自定义拷贝构造默认拷贝会 shared_ptr 引用计数加一 };但要注意shared_ptr的拷贝是“共享”不是深度复制。如果你希望两个对象各自拥有独立的数据shared_ptr并不能帮你实现深拷贝。它是资源生命周期管理器不是语义转换器。5.4 什么时候选择禁用拷贝设计类的时候要问这个对象的语义是值、唯一所有权还是共享所有权对于文件句柄、互斥锁、数据库连接这类资源通常应该禁用拷贝。比如std::thread、std::mutex都禁止拷贝。禁用一个拷贝只需要写一句CopyableClass(const CopyableClass) delete; CopyableClass operator(const CopyableClass) delete;我个人在工程中的习惯是默认让类可拷贝除非有明确的资源独占需求一旦可拷贝且含资源成员一律写深拷贝三件套或用智能指针包裹。少想一步以后就少一次凌晨的崩溃排查。6. 深拷贝踩坑排查实录双重释放、内存泄漏与隐藏共享6.1 双重释放的精确定位双重释放是最经典的浅拷贝事故。现象上程序可能正常退出时崩溃也可能在运行途中断言失败。我分享一个定位思路用 AddressSanitizer 编译。编译命令g -fsanitizeaddress -g test.cpp -o test ./testASan 报错信息会精确指出哪一行delete触发了 double free以及这内存最早是在哪里分配的。这种工具定位效率远高于人看代码。但 ASan 也不是万能钥匙。它要求整个程序都启用了 ASan如果是链接了第三方静态库库内部可能不含 ASan 插装信息定位会困难。这时候可以退一步用调试器在析构函数里下断点观察谁正在析构同一地址的对象。6.2 自赋值陷阱自赋值一度是程序员容易忽略的边界条件。有了复制交换惯用法之后自赋值问题自然消失因为临时对象的构造不受影响swap也正确处理了同一对象。但如果你仍坚持手写赋值运算符务必保留if (this other) return *this;这行。测试时可以写MyString s(hello); s s; std::cout s.c_str() std::endl;如果输出不再是hello就说明自赋值处理有缺陷。这类 bug 在单元测试里很容易被漏掉因为正常的业务代码很少会触发自赋值但数组遍历、容器删除再赋值等场景都可能意外出现。6.3 “我改了 AB 怎么也跟着变了”的隐蔽共享浅拷贝的另一个常见症状是复制出来的对象修改了其中某个对象的内部数据另一个对象的数据也变了。这种问题在 C 里比 double free 更难发现因为程序不会崩溃只是在逻辑上“神秘地错”。典型场景一个对象缓存到std::vector里之后又用浅拷贝赋值给了另一个对象然后修改第二个对象的数据第一个对象的数据被污染。排查思路是确定成员里有哪些裸指针再沿着指针的赋值路径查引用关系。我把这类问题整理成速查表方便平时对照症状最可能原因排查方向程序结束时 double free裸指针成员 默认拷贝检查拷贝构造和赋值运算符修改一个对象影响另一个对象共享指针成员检查是否有深拷贝逻辑拷贝后原对象数据被破坏赋值运算符未管理旧资源检查赋值前是否 delete 旧数据类中有 delete 但无自定义拷贝违反三法则补全拷贝构造和赋值vector 扩容后数据错乱移动构造缺失或误用检查是否定义了移动构造复制构造函数参数不是引用无限递归导致栈溢出改为const T6.4 内存泄漏深拷贝也一样会漏别以为写了深拷贝就不会有内存泄漏。看这段MyString operator(const MyString other) { m_data new char[other.m_size 1]; // 少了 delete[] m_data strcpy(m_data, other.m_data); return *this; }每次赋值都让旧指针指向新地址旧内存没释放泄漏积累。轻则长期驻留内存不断增长重则高并发场景下直接把内存耗尽。所以赋值运算符的第一件事永远是释放当前对象持有的资源——或者在复制交换惯用法中让临时对象在交换后负责释放旧资源。还有一类泄漏不体现在new/delete数量不对称上而是体现在容器扩容时。如果类定义了拷贝构造但没有定义移动构造std::vector扩容时会逐个深拷贝元素老元素的临时对象析构新元素又复制了一份如果类里还有需要外部释放的嵌套资源比如文件句柄、数据库连接这个“复制”可能造成资源重复持有多份而只释放一份。6.5 借用标准库的视角做卫生检查排查深拷贝相关 bug 时我有个固定套路先grep类定义里的裸指针成员然后检查这个类有没有自定义析构函数。如果有自定义析构却没有自定义拷贝构造和赋值运算基本可以断定违反三法则问题已经潜伏。然后编译时加上-Wall -Wextra -Wdeprecated让编译器尽可能多报类似“-Wclass-memaccess”的警告。最后用 ASan 跑一轮。这个流程多次帮我快速定位问题。7. 我个人的深度体会拷贝语义是类设计的“人品底色”写拷贝构造函数这件事看起来很小但很能反映一个 C 工程师对资源生命周期的理解。我见过很多系统级别的崩溃最后都能归因到某个类少写了一个拷贝构造或者某处忽略了自赋值。也见过为了节约一次拷贝用移动语义硬生生把逻辑绕晕最后维护成本翻倍。拷贝语义的选型本质上是资源管理策略的选型。如果你现在正处在“刚接触深拷贝浅拷贝”的阶段我的建议是先写一个带裸指针的小类把三件套手写一遍再用 ASan 编译运行测试亲手触发一次 double free。真实复现一次问题比读五遍理论都管用。等有了体感再逐步了解移动语义和智能指针方案慢慢把代码从手动管理升级到 RAII 风格。到能用std::string、std::vector解决大部分问题时你会发现拷贝安全问题少了一大半剩下那些深水区才是真正考验设计功力的地方。
返回列表