YAOTU INSIGHTS

C++类与对象(下)精讲:默认成员函数、拷贝构造与浅拷贝陷阱全解析

C++类与对象(下)精讲:默认成员函数、拷贝构造与浅拷贝陷阱全解析
C类与对象这个话题很多人在学完基本语法后总会卡在“下篇”这层窗户纸上。类与对象(下)讲的全是那些看着简单、用起来全是坑的东西默认成员函数、拷贝构造、赋值重载、static、友元、内部类。我当年也是被六大默认成员函数绕晕的人之一尤其是浅拷贝导致的二次释放真真切切崩到怀疑人生。这篇文章我就按自己的学习路线和踩坑记录把类与对象(下)里最难啃的部分拆开揉碎讲清楚配合可以直接跑的示例代码和调试思路。适合刚掌握类的基础语法、想把生命周期和资源管理彻底拿下的人也适合准备面试想系统梳理C对象模型的进阶者。1. 六个默认成员函数真正要解决什么问题1.1 编译器不是替你“写好了”只是替你“草稿了一个”很多人听到“默认成员函数”就会产生幻觉类不写就能用编译器已经帮我们完成了。这句话没错但默认生成的是“最朴素”的版本。六个默认成员函数分别是构造函数、析构函数、拷贝构造函数、赋值运算符重载、普通对象取地址、const对象取地址。对于内置类型成员编译器默认生成的构造函数基本不做事在C11之前像int _a;这种成员读完值是随机的完全不是0。这个细节让我第一次写二分搜索树时吃了大亏——定义一个节点对象成员变量以为默认是空结果全是垃圾值导致插入逻辑疯狂死循环。你现在可以做个测试struct Demo { int a; double b; }; int main() { Demo d; std::cout d.a std::endl; // 未定义行为可能打印负数或垃圾值 return 0; }默认构造函数的“不作为”并非设计缺陷而是C追求性能的表现对于内置类型不初始化是零成本对于自定义类型会递归调用成员对象的构造函数。这是理解整个类与对象(下)的起点所谓默认不等于安全只等于“形式上存在”。1.2 构造函数和析构函数本质是生命周期管理构造函数不是用来“构造”的它是用来“初始化”的。对象在创建时编译器会把你构造函数的代码插到对象内存准备好之后成员变量可以初始化。析构函数则是对象销毁时的收尾动作对于栈上的对象生命周期结束时自动调用对于堆上new出来的对象只有显式delete才会调用析构函数。这里最应该建立的是RAII意识。什么叫RAII资源获取即初始化。比如用智能指针、文件句柄、线程锁封装资源把资源的释放绑定在析构函数上。你不需要记得释放时机反正对象出了作用域析构函数一定会执行。下面这段代码能很直观看到生命周期class Logger { public: Logger() { std::cout Logger构造\n; } ~Logger() { std::cout Logger析构\n; } }; void run() { Logger log; // 构造 std::cout run函数执行\n; } // run结束析构自动调用 int main() { run(); return 0; }这个点看似基础但实际操作中有无数人写出“忘记delete”的痛苦代码。如果你懒得自己管理至少也要理解为什么析构函数会被频繁调用临时对象会在表达式结束时析构函数参数会在函数结束时析构返回值在接收完成或优化后析构。把握了构造和析构的时机写代码时才能判断哪些资源是安全的哪些会悬空。1.3 什么时候必须自己写析构函数什么时候必须重写拷贝判断是否要显式定义析构函数其实有一条很简单的经验如果类里有动态申请的资源比如malloc或new出来的内存、打开的文件、网络连接就需要自己写析构来释放如果类成员全是int、double、标准库容器这种自动管理资源的类型那么默认析构就够了。但更常见的坑藏在“自定义析构函数”出现之后。C社区有一句俗话如果你需要自定义析构函数那大概率也需要自定义拷贝构造和赋值重载这就是所谓的“三/五法则”。为什么因为默认的拷贝是浅拷贝它会把一个对象的成员值逐字复制到另一个对象。假如成员里有个指针两个对象就指向同一块堆内存等到两次析构执行同内存被释放两次直接崩溃。这个规律我后面会用一个完整的例子演示你可以把它当作模板反复套用。2. 拷贝构造、赋值重载与浅拷贝陷阱核心2.1 三种“看起来不像拷贝”的拷贝场景很多人一听说拷贝构造就会盯着Date d2(d1);这种写法但实际工程里拷贝往往藏在更容易忽略的角落而且每个都是坑。第一种最常见的场景是函数传参。当你写void print(Date d)时实参传给形参的过程就会调用拷贝构造。如果Date内部有动态内存而你没有写深拷贝形参一销毁原对象的内存就悬空了。第二种是函数返回值。假设函数返回一个对象编译器在早期实现中会创建一个临时对象再把临时对象拷贝给接收变量。现代编译器做了返回值优化RVO经常会把拷贝省略掉但你不能依赖优化来弥补设计缺陷。第三种是标准库容器操作。比如std::vectorMyString v; v.push_back(s);在vector扩容时元素会被反复拷贝。如果拷贝构造是浅拷贝程序可能在第一次扩容就崩。来看一段最典型的浅拷贝场景class MyString { public: MyString(const char* str ) { _str new char[strlen(str) 1]; strcpy(_str, str); } ~MyString() { delete[] _str; } private: char* _str; }; int main() { MyString s1(hello); MyString s2(s1); // 默认拷贝构造只复制指针 return 0; // 崩溃s1和s2共用同一块堆内存析构两次 }解决方式很简单自己实现深拷贝开辟新空间拷贝内容。你可能会想这种问题不是一眼就能看出来吗真正写项目时一个类经常嵌套多层对象拷贝会层层触发你根本没法靠眼睛追踪只能靠规则约束。2.2 浅拷贝和深拷贝一个道理解释清楚浅拷贝就是只拷贝“值”。如果值是地址拷贝出的新对象和原对象持有同一个地址指向同一块资源。深拷贝则是把资源本身复制一份让每个对象拥有独立的资源。画一张内存图可能会更清楚s1._str指向地址0x1234这块内存里存着字符串浅拷贝时s2._str也指向0x1234深拷贝时你申请新地址0x5678把字符串内容复制进去s2._str指向0x5678。之后各自释放互不干扰。实现深拷贝也不是什么高深技巧记住两步申请新空间、逐字节复制。下面是一个完整的实现大纲class MyString { public: // 拷贝构造 MyString(const MyString s) { _str new char[strlen(s._str) 1]; strcpy(_str, s._str); } // 赋值重载 MyString operator(const MyString s) { if (this ! s) { // 自赋值检测 delete[] _str; // 释放旧空间 _str new char[strlen(s._str) 1]; strcpy(_str, s._str); } return *this; } private: char* _str; };注意赋值重载不能直接拷贝因为当前对象已经有资源了必须先释放再申请新内存。如果不释放就开新坑旧内存就泄漏了。2.3 赋值重载的返回值为什么必须是引用自赋值检查真是形式主义先解决一个最简单也最容易猜的疑问为什么赋值运算符返回引用因为C支持链式赋值比如a b c;解析成a.operator(b.operator(c))。如果返回值是对象中间会多一次拷贝构造和析构。返回引用既避免了拷贝也不影响链式赋值。你只要知道这个设计是为了效率本身也就理解了为什么主流的写法几乎都是MyString operator(const MyString s)。自赋值检查更是个经典话题。有人觉得if (this ! s)是多余的反正自己不给自己赋值也不会出大问题。但这种想法在资源管理类中是致命的。如果类里有指针成员自赋值时先释放了老资源再用老资源去赋值结果可能把内存二次释放了。写一个极端的例子MyString operator(const MyString s) { // 没检查自赋值 delete[] _str; _str new char[strlen(s._str) 1]; strcpy(_str, s._str); return *this; }当执行s s;时第一步释放了this的资源第二步strlen(s._str)访问了已经释放的内存结果是未定义行为。自赋值检查虽然只占一行但对资源管理类的正确性有决定性意义。3. 初始化列表、explicit、default/delete被低估的三个小细节3.1 初始化列表和构造函数体不是“两种写法”的关系很多教材把初始化列表和构造函数体并列介绍导致初学者误以为它们只是等价的不同风格。实际上它们之间有本质区别初始化列表发生在对象成员真正构造的那一时刻函数体中的赋值是对象已经构造完成以后的操作。直接看一个const成员的问题class Date { public: Date(int year, int month, int day) { _year year; // 错误const成员不能赋值 _month month; _day day; } private: const int _year; int _month; int _day; };这里为什么会报错因为const成员变量在进构造函数体之前必须完成初始化后续不能再赋值。初始化列表正是做这件事的Date(int year, int month, int day) : _year(year), _month(month), _day(day) {}引用成员和没有默认构造函数的自定义类型成员也有同样的要求。如果你在函数体里写赋值等于先让引用或自定义类型“空构造”了一把再修改这在语义上是不允许的。认清“初始化列表负责构造函数体负责再修改”的分工后你就不会再对这些规则感到莫名其妙。3.2 初始化顺序由成员声明顺序决定初始化列表有个反直觉的规则初始化顺序不是按照列表里写的顺序而是按照成员在类中的声明顺序。这个坑非常隐蔽可能连工作多年的开发者都踩过。class Demo { public: Demo(int v) : _b(v), _a(_b) {} void print() { std::cout _a _b std::endl; } private: int _a; int _b; }; int main() { Demo d(10); d.print(); // 输出垃圾值 10还是 10 10 }如果按初始化列表顺序先初始化_b再初始化_a输出就是10 10。但实际是先声明_a所以_a(_b)执行时_b还没有被初始化_a得到的是一个随机值。现代编译器即使没有开启-Wall也可能给你一个顺序警告。记住一句话初始化列表别自作主张排序永远和成员声明的顺序保持一致。否则后续维护代码的人会非常痛苦。3.3 explicit 比你想的更实用看这段代码class Date { public: Date(int year) : _year(year) {} private: int _year; }; int main() { Date d 2024; // 隐式调用构造 return 0; }看起来很方便但隐式转换会引发一些奇怪的场景。比如你写一个函数void setDate(const Date d)调用setDate(2024)也是合法的它会把2024隐式构造出一个临时Date对象。这种可读性和意图性都打折扣。解决办法就是在构造函数前加explicitexplicit Date(int year) : _year(year) {}加上之后Date d 2024;就成了编译错误必须写Date d(2024);。别觉得这限制了灵活性很多C项目因为隐式转换在重载解析中选出你根本不想要的版本加explicit是防御性编程的好习惯。对单参构造函数我建议一律explicit除非你有非常明确的转换语义需求。再顺带提一下default和delete。default能让编译器重新生成默认实现适合你声明了拷贝构造但还想保留默认行为的情况delete则直接禁用某个函数。比如想做一个不可拷贝的类class NonCopyable { public: NonCopyable(const NonCopyable) delete; NonCopyable operator(const NonCopyable) delete; };这两兄弟极大改善了“声明了函数就必须定义”的旧时代痛苦也让“禁止拷贝”有了统一的表达方式。4. static成员、友元、内部类与匿名对象对象的“邻居”们也大有玄机4.1 static成员属于类但不属于对象static成员变量是所有对象共享的一份数据它存储在静态区不是放在某个对象内部。声明在类内但定义和初始化必须在类外进行C17之后inline static可以在类内初始化早期版本不行。最经典的应用是统计类的实例数量class Counter { public: Counter() { _count; } Counter(const Counter) { _count; } ~Counter() { --_count; } static int getCount() { return _count; } private: static int _count; }; int Counter::_count 0; int main() { Counter c1; Counter c2(c1); std::cout Counter::getCount() std::endl; // 2 return 0; }在main里构造了两个对象计数是2。如果你忘记在拷贝构造中加计数统计就会缺一这种细节常见于各种监控系统和内存池设计。static成员函数没有this指针所以它不能访问普通成员变量只能访问static成员。理解了这句话你就不会犯“在static函数里调用普通成员函数”的编译错误。4.2 友元打破封装要慎重但有时真得靠它“友元函数”和“友元类”能访问类的私有成员相当于在封装墙上开了个门。很多初学阶段第一反应是“既然是私有就该禁止外部访问”可现实中总有些场景让你不得不破例。最典型的是重载流插入和流提取运算符。标准库的cout和cin应该怎么输出你的自定义类型由于左操作数是std::ostream你没法把它定义为成员函数成员函数的左操作数是this只能写成全局函数class Point { friend std::ostream operator(std::ostream out, const Point p); friend std::istream operator(std::istream in, Point p); public: Point(int x 0, int y 0) : _x(x), _y(y) {} private: int _x; int _y; }; std::ostream operator(std::ostream out, const Point p) { out p._x , p._y; return out; }这里友元是必要的因为没有它operator就无法读取私有成员。友元的代价是削弱封装任何一个新增的友元都会提高类与外部环境的耦合度。我的建议是能用公有接口解决就不用友元必须用就用但尽量收敛在运算符重载或特定工具函数里。4.3 内部类和匿名对象各有各的用法在类里面再定义类称为内部类。很多人误以为内部类对象与外部类对象绑在一起其实完全不是。内部类是独立的类只是定义在外部类的类域中。内部类有个特点它是外部类的友元内部类可以直接访问外部类的私有成员但外部类不能直接访问内部类的私有成员。匿名对象就是直接类名()创建出来的临时对象它没有名字生命周期停留在当前行的表达式末尾。实际用途可以是简化代码class Show { public: void print() { std::cout print\n; } }; int main() { Show().print(); // 匿名对象直接调用成员函数 return 0; }再比如你只关心返回值不关心对象// 原本Data d(1, 2); d.start(); Data(1, 2).start(); // 匿名对象匿名对象的析构时机很容易被人忽略。假如调用链里还有延迟求值匿名对象可能比预期更早析构导致悬空引用。所以在使用时要确保调用的方法不依赖对象超长生命周期或者干脆别在长作用域里用匿名对象保存引用。5. 常见问题与排查技巧实录5.1 程序崩溃在析构先查是不是二次释放如果你看到一个程序在退出时崩溃栈回溯指向某个析构函数多半是浅拷贝引起的堆内存重复释放。我遇到过初学者写的栈类内部用arr new int[_size]默认拷贝构造导致两个栈对象共享同一块arr当它们依次析构第二次释放未分配内存直接触发abort。排查思路分三步第一步看崩溃栈里是不是同一个delete执行了两次第二步在拷贝构造和赋值重载里打印日志检查是不是有意外拷贝第三步用调试器监控堆指针的地址确认两个对象的成员指针是否相同。找到相同地址基本可以锁定浅拷贝。写代码时还有一种常见的动作先释放了旧资源再因为异常没有分配新空间让对象处于空悬状态。处理这个问题可以在赋值重载里使用“先创建临时对象再交换数据”的思路也就是copy-and-swap。这个模式不仅代码简洁而且天然豁免自赋值问题。5.2 自赋值检查被忽略了但其实就是一行的事自赋值问题在链式表达式里尤其容易发生。假设你重载了operator但不做检查并执行a b a此时中间过程会触发自赋值情况。默认的拷贝行为会把资源释放最终拿到一个悬空指针。我记得用调试器单步的时候发现事情的发展路径极其诡异先进入operator释放了“要拷贝的那个对象”的资源然后源对象的内容已经没了。加了自赋值检查后一切正常。如果你觉得检查多余请记住编译器不会阻止你写出s s;而后果只有运行期才浮现这种“看不见的崩溃”在大型项目里最难排查。另一个注意点是赋值重载里的delete顺序。无论是否检查自赋值都不要在申请新内存之前释放源对象资源。很多人写出来的版本是释放旧资源、再拷贝源对象但如果源对象和当前对象有某种共享关系释放动作会波及源对象。稳妥的写法是先用局部变量保存或复制需要的值再释放旧资源最后赋值。5.3 如何区分拷贝构造和赋值重载到底谁被调用这个坑在学习阶段特别常见因为它们的语法长得太像了MyString s1(hello); MyString s2(s1); // 拷贝构造 MyString s3 s1; // 也是拷贝构造不是赋值 s3 s1; // 赋值重载判断标准很简单对象在创建时哪怕用了等号只要本质是用已有对象初始化新对象调用的就是拷贝构造对已经存在的对象做赋值才调用赋值重载。想知道编译器到底调用了哪个函数最朴素的方法就是在两个函数内部打印日志。不过还需要留意现代C编译器会做返回值优化和移动语义某些你以为是拷贝构造的场景实际可能被优化掉了例如MyString func() { MyString tmp(hi); return tmp; // 可能不调用拷贝构造被NRVO优化 }在写高要求代码时不能只依赖日志判断逻辑因为优化开关会影响输出。比较稳妥的办法是禁用优化编译或者在对象内部用static计数统计调用次数。这样你至少能清楚“理论调用点”和“实际优化结果”的差异。最后再分享两点实践心得第一写类之前先画“内存图”。把每个成员变量画成盒子动态资源画成外挂的指针。然后问自己三个问题每个指针由谁分配由谁释放如果对象被拷贝指针指向的资源是否要复制一份这套流程做熟了深浅拷贝的坑基本能避掉一半。第二类与对象(下)这块内容不是背规则就能学会的一定要动手写。我建议你自己实现一个MyString完整写出构造、析构、拷贝构造、赋值重载再往标准库容器里使用它观察扩容时的拷贝次数。接着试试把explicit、static计数器、匿名对象都用上去然后编译、运行、调试、看栈帧。只有亲自见过析构函数二次释放的崩溃现场才算真正掌握了类与对象(下)的精髓。