很多人刚开始学C的时候看到“拷贝构造函数”这几个字就开始头疼觉得又是一个跟指针纠缠不清的抽象概念。其实这个东西远没有想象中那么难它本质上就是一个“用已有对象来创建新对象”的特殊构造函数。但真正让新手乃至工作几年的开发者栽跟头的往往不是它本身的语法而是它背后的浅拷贝shallow copy和深拷贝deep copy机制。一旦牵扯到动态内存、指针成员用错拷贝方式轻则数据互相干扰重则程序直接崩溃。这篇文章就从一个最简单的类开始一步步拆解拷贝构造函数把浅拷贝和深拷贝的原理、代码、坑点一次说清楚希望给正在啃C的你一点帮助。1. 拷贝构造函数到底是个什么东西1.1 先从构造函数说起在聊拷贝构造函数之前得先明确一个基础概念构造函数。一个类的构造函数负责在对象创建时初始化对象比如下面这个极其简单的例子class Student { public: Student(const string name, int age) { m_name name; m_age age; } private: string m_name; int m_age; };这里的Student(...)就是构造函数它规定了“创建一个 Student 对象时要给名字和年龄赋上什么初值”。但问题来了如果我已经有了一个学生对象stu1想创建一个和stu1一模一样的新对象stu2该怎么办最直白的思路当然是Student stu2 stu1;这条语句看起来简单背后却隐藏着一个关键问题编译器怎么知道如何把stu1的数据复制到stu2里答案就是拷贝构造函数。拷贝构造函数就是一个特殊的构造函数它的参数是“同类型对象的常量引用”专门用来完成“用一个对象初始化另一个对象”这件事。它的典型写法长这样Student(const Student other) { m_name other.m_name; m_age other.m_age; }只要一个类没有显式定义拷贝构造函数编译器就会自动生成一个默认版本。对于只包含string、int这类普通成员的类编译器生成的默认拷贝构造函数完全够用它能逐成员地把数据复制过去不会出什么问题。1.2 拷贝构造函数在哪些场景下会被触发很多初学者有个误区以为只有写成Student stu2 stu1;这种形式才叫拷贝构造。其实它的触发场景远不止这一种这也是面试和笔试特别喜欢考的点。我用实际代码把几种典型场景列出来Student stu1(张三, 20); // 场景1直接初始化用 stu1 构造 stu2 Student stu2 stu1; // 场景2拷贝初始化本质和场景1一样只是写法不同 Student stu3(stu1); // 场景3函数按值传参 void func(Student s) { ... } func(stu1); // stu1 被复制到形参 s触发拷贝构造 // 场景4函数按值返回 Student getStu() { Student tmp(李四, 21); return tmp; // 返回时可能触发拷贝构造 }需要特别说明的是场景3和场景4。按值传参时形参是实参的一个副本这个副本的创建过程就是拷贝构造按值返回时返回值是局部对象的一个副本同样涉及拷贝。这两个场景恰恰是资源管理类比如自己管理内存的类最容易出问题的地方因为默认的浅拷贝在传递过程中会把一块内存的所有权“复制”出多份导致同一块内存被多重释放。注意C11 之后按值返回局部对象时编译器往往会做“返回值优化”RVO, Return Value Optimization或“具名返回值优化”NRVO直接在被调函数的栈上构造返回值从而省掉一次拷贝构造。这是编译器的优化行为不代表拷贝构造在返回场景中彻底消失了——当编译器无法优化时比如返回的对象是函数参数包装而来拷贝或移动构造依然会被调用。2. 浅拷贝看似一切正常实则暗藏杀机2.1 为什么默认拷贝构造函数是浅拷贝为了说清楚浅拷贝的问题得先引入一个带指针成员的类。假设我们要写一个简单的字符串类类似简化版string里面用一个char*指向堆上的字符数组class MyString { public: MyString(const char* str ) { if (str) { m_size strlen(str); m_data new char[m_size 1]; strcpy(m_data, str); } else { m_data nullptr; m_size 0; } } ~MyString() { delete[] m_data; } void print() const { cout m_data endl; } private: char* m_data; size_t m_size; };这个类的析构函数里做了delete[] m_data用来释放构造函数中new出来的堆内存。看起来逻辑闭环了构造时申请内存析构时释放内存。但问题是这个类没有定义拷贝构造函数。当执行下面的代码时MyString s1(hello); MyString s2 s1;编译器会用默认拷贝构造函数而默认拷贝构造函数对m_data这个指针成员做的事情是按位复制也就是把s1.m_data的地址值原封不动地赋给s2.m_data。结果就是s1和s2里的m_data指向了同一块堆内存。这就好比你有一把钥匙别人找你“拷贝”一把一模一样的钥匙结果你直接把原钥匙给了他最后你们俩手里拿的是同一把钥匙。这在大多数业务场景下绝对不是你想要的。2.2 浅拷贝的典型崩溃现场浅拷贝带来的最直接的后果就是双重释放double free。还是用上面的MyString类演示void testShallowCopy() { MyString s1(hello); MyString s2 s1; // 浅拷贝s2.m_data 和 s1.m_data 指向同一块内存 // 函数结束时s2 先析构delete[] 释放了那块内存 // 然后 s1 析构再次 delete[] 同一块内存 崩溃 }运行这个函数大概率会在程序退出时抛出一个类似double free or corruption的错误或者直接触发段错误Segmentation fault。原因一目了然s1和s2共享同一块堆内存两个对象的析构函数都会去释放它第一重释放是合法的第二重释放就是非法的。除了双重释放浅拷贝还会带来数据窜改的问题。比如s2修改了字符串内容s1看到的也会跟着变因为本质上它们操作的是同一块内存void testModify() { MyString s1(hello); MyString s2 s1; // 假设 MyString 提供了修改内容的方法 s2.modify(world); s1.print(); // 输出的竟然也是 world因为 s1 和 s2 共享内存 }所以在涉及动态内存、文件句柄、网络连接、互斥锁等资源的类中默认的浅拷贝是绝对不能直接用的。这也是为什么“哪些类必须自定义拷贝构造函数”是C八股文里绕不开的高频考点。提示并不是所有指针成员都一定要深拷贝。比如指向某个全局对象或静态对象的裸指针、非拥有关系的观察指针它们只负责“看”不负责“删”这种情况下浅拷贝反而没问题。真正危险的是那些拥有资源所有权的指针也就是在析构函数里需要delete或delete[]的指针这类成员必须深拷贝或者干脆用智能指针替代。3. 深拷贝自己动手丰衣足食3.1 手写一个标准的深拷贝构造函数深拷贝的核心思想是在拷贝的时候为目标对象单独分配一块内存并把源对象的内容一个一个地复制到新内存里。这样两个对象虽然内容相同但各自持有独立的资源互不干扰析构时也不会重复释放。针对上面的MyString类深拷贝构造函数应该这么写MyString(const MyString other) { m_size other.m_size; if (other.m_data) { m_data new char[m_size 1]; strcpy(m_data, other.m_data); } else { m_data nullptr; } }关键点在于m_data不是直接等于other.m_data而是用new char[...]重新申请一块内存再把other.m_data里的字符串内容拷贝到新内存中。这里为了简化没有处理异常安全比如new抛出bad_alloc时但在生产代码中通常需要配套使用 RAII 或异常安全处理机制。为了让你对比得更清楚我把浅拷贝构造函数和深拷贝构造函数并列展示// 浅拷贝编译器默认生成的长这样注意这是示意代码 MyString(const MyString other) { m_size other.m_size; m_data other.m_data; // 直接把指针值拷过来共享内存 } // 深拷贝我们需要自己写的 MyString(const MyString other) { m_size other.m_size; m_data new char[m_size 1]; strcpy(m_data, other.m_data); }区别就一行代码m_data other.m_data变成了m_data new char[...]; strcpy(...)。但这一行代码决定了两个对象之间的关系是“共享”还是“独立”。3.2 深拷贝的验证与使用效果写完之后跑一遍之前的崩溃场景void testDeepCopy() { MyString s1(hello); MyString s2 s1; // 深拷贝s2 拥有自己独立的堆内存 // s1 和 s2 的 m_data 指向不同地址内容相同 // 函数结束时s2 先析构释放自己的内存s1 再析构释放自己的内存 // 两次释放各自独立互不影响程序正常退出 }这个时候让s2去修改内容s1也完全不受影响因为它们各自维护着自己的那块堆内存。这才是符合直觉的“拷贝”语义副本是独立的。从编码规范的角度来说凡是在析构函数里释放了资源的类几乎一定要自定义拷贝构造函数以及与之配套的拷贝赋值运算符operator。C有一条著名的“三法则”Rule of Three如果类需要自定义析构函数那么通常也需要自定义拷贝构造函数和拷贝赋值运算符。如果用了C11之后的智能指针如shared_ptr、unique_ptr大多数情况下可以靠智能指针自动管理资源再配合default关键字让编译器生成拷贝逻辑从而避免手写内存管理代码。3.3 拷贝赋值运算符也不能放过说完拷贝构造函数必须提一下它的“孪生兄弟”拷贝赋值运算符。很多新手在写完深拷贝构造函数之后以为万事大吉结果一用s2 s1赋值不是初始化又崩了原因就是赋值走的是operator不是拷贝构造函数。拷贝赋值运算符的深拷贝写法通常长这样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; }注意这里先释放了旧内存再分配新内存最后拷贝。如果不做自赋值检查就可能会出现s s这种极端情况先delete掉自己的数据然后拿着一个悬空指针去strcpy直接读野内存属于未定义行为。虽然在平时业务代码里很少有人写s s但在容器操作、链式赋值等场景中自赋值检查是一个可靠的习惯。不过deletenew这种写法在异常安全性上有瑕疵如果new char[]抛出异常对象会陷入“旧资源已释放、新资源未建立”的尴尬状态。更推荐的方式是用“拷贝并交换”copy-and-swap惯用法MyString operator(MyString other) { // 注意这里按值传参会调用拷贝构造 swap(*this, other); // 交换内部资源 return *this; }这个手法非常经典先通过按值传参产生一个临时副本然后直接交换两个对象的内容。这样即使拷贝构造过程中抛异常原对象也不受影响同时利用局部变量的析构自动释放掉旧资源。它写起来简洁、安全性好强烈建议新手一开始就学习这种写法。提示拷贝构造函数和拷贝赋值运算符虽然都负责“复制数据”但它们的调用场景完全不同。初始化时MyString s2 s1;或MyString s2(s1);走拷贝构造函数已存在的对象间赋值s2 s1;走拷贝赋值运算符。区分好这两者的触发时机是避免写出“赋值当构造、构造当赋值”这类错误的关键。4. 避开这些坑拷贝构造函数的高频雷区4.1 拷贝构造与函数传参的纠缠我在带新人或者辅导初学者的时候总会让他们做这样一个小测试写一个传入类对象但不修改它的函数然后用三种不同的方式去传参让它们比较性能差异// 方式1按值传递调用拷贝构造慢 void funcByValue(MyString s) { ... } // 方式2按引用传递不调用拷贝构造快 void funcByRef(MyString s) { ... } // 方式3按常量引用传递不调用拷贝构造也不担心修改原对象推荐 void funcByConstRef(const MyString s) { ... }对于不需要修改原始对象语义的函数强烈推荐用const T传参既避免了拷贝开销也保证了调用方数据不被修改。这只是“避免不必要的拷贝”的一个例子但很多人直到工作后写代码还在大量按值传大对象然后埋怨程序跑得慢其实根子就在这里。还有一个程序员经常踩的坑是构造函数里用初始化列表和函数体内赋值效果看似一样实际性能差异巨大。对于拷贝构造来说如果没有在初始化列表里初始化成员那么编译器会先对成员做默认初始化然后再执行函数体内的赋值操作相当于多了一次默认构造和一次赋值白白浪费效率。所以更规范的写法是MyString(const MyString other) : m_size(other.m_size), m_data(other.m_data ? new char[other.m_size 1] : nullptr) { if (m_data other.m_data) { strcpy(m_data, other.m_data); } }不过对于初学者用花括号或普通函数体先写清楚逻辑也完全没问题等到对初始化列表熟练之后再逐步优化写法。4.2 编译器优化RVO/拷贝消除对初学者判断造成的干扰之前在讲触发场景的时候提到了RVO这里展开聊一聊。很多初学者会写出下面这样的函数MyString getString() { MyString local(temp); return local; // 你以为一定会触发拷贝构造 }然后调用它MyString s getString();学过触发场景的人会下意识认为这里至少发生了一次拷贝把local拷贝到返回值再把返回值拷贝到s。但在现代编译器里这种代码极大概率一次拷贝构造都不触发编译器直接把s的存储空间复用为local的存储空间这就是所谓的拷贝消除copy elision其中返回值优化是最常见的一种。这个知识点对初学者最大的意义在于别用“断言某行代码一定会调用几次拷贝构造”这种方式去测试自己对机制的理解因为优化选项、编译器版本和代码上下文都会影响结果。更好的办法是自己在拷贝构造函数里加输出语句对比开不开-O2优化时的调用情况这样能直观感受到编译器优化的威力同时也避免被“反直觉”的现象误导。如果真的要强制禁用拷贝消除来观察逻辑行为编译时可以加上-fno-elide-constructorsGCC/Clang这样能看到原始的语义行为。但请放心默认编译选项下RVO是合法的、被广泛支持的优化不是bug。4.3 成员对象是容器或智能指针时怎么处理很多初学者以为只有裸指针需要深拷贝遇到容器比如vector、string或智能指针就完全不写拷贝构造函数了。这种做法大多数时候是对的因为 C11 之后的容器和智能指针都遵循值语义拷贝一个vector里面的元素会被逐个复制拷贝一个shared_ptr引用计数会加一管理的对象不会被复制但也不会被提前释放。这些行为已经替你处理好了资源安全问题所以不需要手动重写。但这里有个极易踩的坑unique_ptr是只移动、不可拷贝的。如果一个类包含unique_ptr成员那么编译器不会自动生成拷贝构造函数一旦尝试拷贝这个类对象就会直接编译报错。这不是坏事它是在提醒你“这个类代表了独占资源不应该被复制”。如果你确实需要两个对象共享同一块资源就改用shared_ptr或者自己定义拷贝构造函数并明确决定如何创建新的unique_ptr指向的资源副本。另外需要注意把“拷贝构造函数”和“移动构造函数”区分开。移动构造是C11引入的它“偷走”源对象的资源把源对象置为有效但未指定状态从而避免深拷贝的大量开销。很多新手刚学会深拷贝之后遇到每个类都先手写一个拷贝构造但完全没有考虑移动语义导致本该能高效返回资源的场景白白做了几次深拷贝。这一点扩展来看就是“Rule of Five”的范畴但不展开讲初学者先把拷贝构造和深拷贝弄扎实移动语义可以作为下一阶段的学习目标。5. 常见问题与排查技巧实录5.1 程序崩溃在 delete 或析构时现象程序能正常编译运行但在函数结束、对象销毁时崩溃报错信息里能看到double free、corruption或者段错误信息。原因基本可以锁定为浅拷贝导致多个对象指向同一块堆内存析构时对同一地址释放多次。排查方式也很直接如果类里有裸指针成员且没有自定义拷贝构造函数那十有八九就是这个问题。在拷贝构造函数里加上输出语句或者在关键析构处打印一下指针地址能很快验证。解决给类补上深拷贝构造函数和拷贝赋值运算符。如果不想手写就把裸指针换成vectorchar、string或shared_ptr让现代C自动替你管理资源。5.2 修改一个对象影响另一个对象现象两个看似独立的类对象改了其中一个的内部状态另一个也跟着变化。原因浅拷贝导致两个对象的指针成员指向同一块数据。主观感受就是“我的修改被串改了”本质上和双重释放同根同源。解决和上一个问题完全一样做深拷贝或者更换资源管理方式。这里额外提醒一句如果你确实希望多个对象共享同一份数据比如只读配置、大块纹理数据那么应该显式使用shared_ptr把“共享”语义体现在代码里而不是用浅拷贝悄悄共享这样代码可读性和安全性都更高。5.3 编译报错拷贝构造函数被删除现象编译错误信息类似use of deleted function MyClass::MyClass(const MyClass)或者错误里提示unique_ptr不可拷贝。原因一个类包含不可拷贝的成员如unique_ptr、mutex、atomic等时编译器会默认删除拷贝构造函数。这是C的一种安全保护这些成员本身就不应该被复制所以强制的拷贝操作会被禁止。解决根据业务需求重新设计。如果对象确实不应该被复制就用引用、指针或std::move来传递对象如果一定要复制则把unique_ptr换成shared_ptr或者自定义拷贝构造函数对资源做出明确的深拷贝策略。5.4 断点调试时发现拷贝了太多次现象函数内部明明只是读一下对象却多次进入拷贝构造函数程序性能下降。原因最常见的是到处按值传参、按值返回而惰于使用const T传递。另一个原因是使用erase、insert等容器操作时隐式构造临时对象引发拷贝。解决先把“不需要修改对象就传const T”养成肌肉记忆。再配合移动语义在确实需要传递所有权时用std::move显式转移资源。对于容器操作优先考虑范围型操作或者移动插入减少不必要的临时对象。5.5 快速自查清单我每次给项目做代码审查时都会有意识地核对一遍“拷贝行为自查清单”这里也分享出来类里是否有裸指针/文件句柄/原始资源如果有是否定义了拷贝构造函数和拷贝赋值运算符拷贝构造函数里是否做了深拷贝而不是简单的“指针地址复制”是否处理了自赋值如果用的是 copy-and-swap是否引入了正确的swap重载按值传参和按值返回的地方是否存在可以改成const T或移动语义但没改的如果类里有unique_ptr是否明确禁止了拷贝并通过移动构造/移动赋值来传递资源拷贝构造函数是否加了explicit通常拷贝构造函数不应该加explicit否则初始化语法会变得不自然参考标准库实践。6. 从拷贝构造函数延伸出去现代C的最佳实践聊到现在拷贝构造函数的核心概念、浅拷贝和深拷贝的区别以及坑点都已经过了一遍。最后我再分享几个实际开发中验证过的体会帮你在工程里少走弯路。优先依赖值语义和标准库容器。能用string、vector、map这类标准容器时就别用裸指针 new。容器的拷贝天然是深拷贝编译器帮你把底层细节全部处理妥当你只需要关注业务逻辑。绝大多数初学者遇到的内存崩溃问题都是因为自己手写资源管理类又没写对如果换成标准库容器问题瞬间消失。善用编译器生成和智能指针。对于多数只需要值语义的类直接在默认构造函数、析构函数等处写 default让编译器按成员的拷贝方式自动生成拷贝逻辑。如果涉及多态对象需要共享访问用shared_ptr如果强调独占所有权用unique_ptr然后通过移动语义转移资源。拷贝构造的适用面在现代C中其实比早期窄了不少但理解它的原理依然极其重要——因为你读的旧代码、维护的遗留系统很可能还到处裸奔着浅拷贝。遇到崩溃先怀疑资源所有权。在实际调试中凡是程序在退出时崩溃、析构时崩溃、或者莫名其妙出现“改了A影响B”的现象第一反应就该去检查代码里是否存在浅拷贝。哪怕经验丰富的程序员也偶尔会在复杂继承体系中不小心漏写了拷贝构造函数导致一个看似无关紧要的赋值操作把两个对象的底层资源绑定在一起。调试这种问题的一个高效方法是在拷贝构造函数和析构函数里临时打印this指针和资源指针地址观察对象生命周期内的地址变化能快速定位共享关系。从无到有理解拷贝构造函数再亲手写出深拷贝版本然后知道哪里该用标准容器、哪里该用智能指针、哪里该上移动语义这个过程本身就是C入门最关键的质变点之一。等哪天你不再需要刻意去思考“这里会不会触发浅拷贝”时说明你对C的资源管理和对象生命周期已经有了属于自己的直觉。希望这篇文章能帮你把这一关迈过去。
阅读完成 · 觉得有帮助?