ARTICLE DETAIL

资讯详情

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

深入理解C++构造函数与析构函数:从默认成员函数到RAII资源管理

深入理解C++构造函数与析构函数:从默认成员函数到RAII资源管理 1. 先重新认识“默认成员函数”这四个字很多刚接触 C 的同学第一次在书上看到“类的默认成员函数”这个说法第一反应是是不是我跟编译器说一声它就会自动帮我生成几个函数其实这个理解已经沾边了但还不够准确。所谓默认成员函数指的是当你在类里没有显式定义某些函数时编译器会按照规则替你生成的那几个特殊函数。今天重点讲其中两个构造函数和析构函数。这两个函数为什么值得单独拿一篇出来讲因为它们在 C 里承担着“对象的出生”和“对象的消亡”这两件大事。类比到现实生活构造函数相当于你租房入住时签合同、拿钥匙、检查水电的过程析构函数相当于退租前打扫房间、归还钥匙、结清水电费的过程。房子还是那间房子但入住和退租都有固定的流程漏掉任何一步都会出乱子。C 里对象的一生其实非常清晰先分配内存然后在内存上执行构造函数完成初始化当对象生命周期结束时先执行析构函数完成清理再回收内存。如果你没有好好理解这个过程后面遇到内存泄漏、悬空指针、double free 这些经典问题基本只能靠玄学调试。反过来只要把构造函数和析构函数吃透再看智能指针、RAII、资源管理这些进阶内容就会顺很多。这篇文章适合谁看正在学 C 入门、刚学到类与对象、对“为什么类里有时要自己写构造函数”感到困惑的人。如果你已经有一定经验也可以直接跳到第 5 节那里整理了新手最容易踩的坑。2. 构造函数到底在“构造”什么2.1 内存分配不等于初始化这是必须要纠正的一个误区。很多人以为“构造函数负责分配内存”其实不是。对象所占的内存在构造函数执行之前就已经分配好了。不管你是在栈上创建对象还是用 new 在堆上创建对象内存空间的获取发生在构造函数进入之前。那构造函数到底干了什么它做的是初始化——把这块已经拿到的内存里的内容设置成有效状态。比如你有一个 Student 类里面有 name、age、score 三个成员。内存分配完毕后这三块区域里可能是随机值构造函数的工作就是把它们分别赋成“张三”、18、90.5。有个很直观的例子class Student { public: Student() { // 这里的代码执行时对象的内存已经分配好了 name ; age 0; score 0.0; } private: std::string name; int age; double score; };当你写下Student s;时编译器做的事大致是先在栈上划出 sizeof(Student) 大小的空间然后跳转到构造函数里执行初始化代码。正因为初始化发生在内存分配之后你才可以在构造函数里放心大胆地给成员赋值。2.2 默认构造函数、带参构造函数与重载默认构造函数指不需要传任何参数就能调用的构造函数。如果你在类里一个构造函数都没写编译器会自动生成一个什么都不做的默认构造函数。问题来了编译器生成的默认构造函数真的什么都不做吗答案是不一定。如果类里有 string、vector 这种带默认构造函数的成员编译器生成的默认构造会去调用它们的默认构造来完成初始化如果成员全是 int、double 这类基本类型那就真的什么都不做成员就保持着不确定的随机值。这也是为什么“写了构造函数”往往比“依赖编译器生成”更安全。比如下面这段代码class Counter { public: int count; }; int main() { Counter c; std::cout c.count; // 输出什么没人知道 }count 的输出是个随机数因为编译器生成的默认构造函数压根没碰它。要解决这个问题你就得自己写构造函数class Counter { public: Counter() : count(0) {} int count; };构造函数也支持重载可以写多个不同参数列表的构造函数编译器会根据你传入的参数选择调用哪一个。这一点跟普通函数重载的思路完全一致class Rectangle { public: Rectangle() : width(0), height(0) {} Rectangle(double w, double h) : width(w), height(h) {} private: double width; double height; };这里Rectangle()和Rectangle(double, double)构成重载分别服务于“创建一个默认矩形”和“创建一个指定大小的矩形”两种场景。2.3 初始化列表能不用赋值就不要赋值构造函数写初始化时有两种姿势。第一种是在函数体里赋值第二种是用初始化列表。很多新手觉得这两种方式效果一样但实际上差别还挺大。class Person { public: // 方式一函数体内赋值 Person(std::string n, int a) { name n; age a; } // 方式二初始化列表 Person(std::string n, int a) : name(n), age(a) {} private: std::string name; int age; };那到底差在哪关键在于“初始化”和“赋值”这两个动作发生在不同阶段。成员变量在进入构造函数函数体之前就已经完成初始化了初始化列表就是在那个阶段直接给成员设置初值如果你不在初始化列表里写成员会先被默认初始化等进了函数体再被赋值一遍。对于 int 这种基本类型多一次赋值几乎没感觉但对于 string、vector 这类稍微复杂的类型等于白白多做了一次默认构造再加一次赋值操作。更重要的是有些成员只能初始化不能赋值。典型的就是 const 成员和引用成员class Config { public: Config(int v) : value(v), ref(v) {} // 必须用初始化列表 private: const int value; int ref; };const 成员和引用成员必须在定义时就给初值函数体里的赋值根本来不及。所以我的建议很直接所有的构造函数能写初始化列表就写初始化列表这不仅是性能问题更是正确性问题。2.4 explicit 与隐式转换的坑还有一个细节值得单独提醒。C 允许只带一个参数的构造函数做隐式类型转换这在某些时候很方便但也很容易制造出让人摸不着头脑的 bug。class String { public: String(const char* s) { /* ... */ } }; void printString(const String s) { /* ... */ } int main() { printString(hello); // 编译通过发生了隐式转换 }这里printString明明要求传 String你却传了const char*编译器会悄悄调用 String 的构造函数把字符串字面量转成一个临时 String 对象。方便吗看起来方便。可怕吗有时候真的很可怕因为这种转换是隐式的代码读起来会产生歧义。解决办法就是在构造函数前加一个 explicit 关键字class String { public: explicit String(const char* s) { /* ... */ } };加了 explicit 之后上面那种隐式转换就编译不过了必须显式写出printString(String(hello))。我在实际项目里的习惯是单参数的构造函数除非我明确想支持隐式转换否则一律加上 explicit。宁可多打几个字也不要让编译器替我做主。3. 析构函数为什么很多人写不好3.1 栈对象和堆对象的析构时机先分清两个对象创建方式栈对象和堆对象。栈对象也就是直接声明的对象它的生命周期由作用域决定。当程序执行到声明它的那个作用域结束时析构函数会被自动调用。看这个例子class Logger { public: Logger() { std::cout 构造\n; } ~Logger() { std::cout 析构\n; } }; int main() { Logger log; { Logger inner; } // 这里 inner 析构 // 这里 log 析构 }输出的顺序是构造、构造、析构、析构。内层作用域先结束所以 inner 先析构然后才是 log。这个顺序实际上是严格按照栈的“后进先出”原则来执行的。堆对象就不一样了。你用 new 创建对象时析构函数不会自动调用必须手动 delete 才会触发Logger* p new Logger(); // 析构不会被自动调用 delete p; // 这里才调用析构函数问题就出在这。如果你 new 了对象却忘记 delete析构函数永远不被执行对象内部管理的资源就会泄漏。C 里“资源泄漏”这个词很常见一般指的就是堆内存、文件句柄、网络连接、数据库连接这些系统资源没有被释放。3.2 析构函数里到底该干什么析构函数的本质是给你一个“最后收拾现场”的机会。如果你的类没有持有任何外部资源比如只有一个 int 和一个 double那析构函数什么都不做也无所谓但如果你的类里有一个指向堆内存的指针、一个打开的文件流、一个锁那析构函数就是你释放它们的地方。class FileHandler { public: FileHandler(const char* path) { file fopen(path, r); } ~FileHandler() { if (file) { fclose(file); } } private: FILE* file; };这里的关键动作是析构函数里把 fopen 打开的文件用 fclose 关掉。如果不写析构函数文件句柄会一直占用着直到程序结束系统回收。如果你在一个长时间运行的服务里反复打开文件又不关闭很快就能把文件描述符耗尽。一个很容易犯的错是在析构函数里用 delete 释放了不该释放的内存或者重复释放。我见过不少人这样写class Holder { public: Holder() : data(new int[100]) {} ~Holder() { delete data; // 错new[] 配 delete[]不是 delete } private: int* data; };new[] 分配的内存要用 delete[] 释放new 分配的单个对象要用 delete。虽然对基本类型来说很多编译器在底层可能做了兼容但别依赖这种未定义行为。正确写法是delete[] data;。3.3 为什么基类析构函数要加 virtual这个问题在面试里出现频率极高但在实际开发中踩坑的成本也极高。先说结论如果一个类会被当作基类使用它的析构函数必须声明为 virtual否则通过基类指针删除派生类对象时析构行为是未定义的常见的表现就是派生类的析构函数根本不被调用资源泄漏。看个例子class Base { public: ~Base() { std::cout Base 析构\n; } }; class Derived : public Base { public: ~Derived() { std::cout Derived 析构\n; } }; int main() { Base* p new Derived(); delete p; // 这里可能只调用 Base 的析构 }编译器看到 p 的类型是 Base*调用析构函数的时候如果没有 virtual 修饰就不会进行动态绑定结果只执行 Base 的析构Derived 里申请的资源就漏了。改成virtual ~Base()之后delete p 会先调 Derived 的析构再调 Base 的析构顺序正确。这里还要注意一个顺序问题派生类析构函数执行完毕后编译器会自动调用基类析构函数不需要你手动去调也不能手动去调。这个顺序跟构造顺序正好相反——构造时先构造基类部分再构造派生类部分析构时先析构派生类部分再析构基类部分。4. 编译器替你生成的构造和析构什么时候够用、什么时候会出事4.1 默认生成的规则比你想的简单也复杂如果你在类里什么都没写编译器会自动生成一个默认构造函数和一个析构函数。注意这里有个特殊规则只要你自己写了任何一个构造函数编译器就不会再自动生成默认构造函数。这就是为什么很多新手写了MyClass(int x) {}之后再写MyClass obj;会报“没有合适的默认构造函数”。我之前在排查一个新手项目时就见过这个报错。对方写了一个类只写了带参数构造函数然后在 main 里试图创建一个不传参的对象编译报错他完全没明白问题出在哪。所以记牢这条规则没了默认构造函数就不能做默认构造。编译器自动生成的析构函数本身什么都不做。它唯一的作用是确保这个类能被“正常析构”——也就是它会调用成员变量自己的析构函数以及基类的析构函数。如果你的类成员全是 int、double、string、vector 这类自带完整生命周期管理的类型编译器生成的析构函数完全够用因为你不需要释放任何东西。4.2 指针成员与深浅拷贝构造函数没写好的连锁反应这是 C 新手最经典的翻车现场。当一个类里有指针成员时编译器自动生成的拷贝操作执行的是浅拷贝——也就是只拷贝指针本身的值不拷贝指针指向的内存内容。两个对象的指针成员指向同一块堆内存析构时各自 delete 一次程序直接崩溃。先看这段代码class String { public: String(const char* s) { size strlen(s); data new char[size 1]; strcpy(data, s); } ~String() { delete[] data; } private: char* data; int size; }; int main() { String s1(hello); String s2 s1; // 默认拷贝构造s2.data 和 s1.data 指向同一块内存 } // s2 先析构delete[] datas1 再析构再次 delete[] data —— 崩溃要解决这个问题你得自己写拷贝构造函数和拷贝赋值运算符实现深拷贝为新对象分配独立的内存再把内容拷贝过去。这里我也提一下拷贝构造函数它本质上也是构造函数的一种只是参数是“自身类型的 const 引用”。String(const String other) { size other.size; data new char[size 1]; strcpy(data, other.data); }如果你暂时不想深入拷拷贝这些还有一个更省心的方案用 std::string 代替 char*用 std::vector 代替裸指针管理动态数组。现代 C 优先使用标准库容器就是为了避免手动管理内存带来的一堆麻烦。4.3 RAII 思想把资源放进类的生命周期里讲构造函数和析构函数不可能不提 RAII。RAII 的全称是 Resource Acquisition Is Initialization中文常译作“资源获取即初始化”但我觉得更本质的理解是把资源的生命周期与对象的生命周期绑定。资源在构造函数中获取在析构函数中释放。只要对象活着资源就在对象死了资源一定被释放。这种思想带来的好处是巨大的。想想看如果你用裸指针管理堆内存每 new 一次都要记得 delete但如果把这个指针封装进一个类在类里写析构函数释放内存那对象的作用域结束时析构函数自动调用你不需要在每一处使用的地方都写 delete。代码更短也更不容易错。现代 C 里的 std::unique_ptr、std::shared_ptr、std::lock_guard全是 RAII 思想的产物。你看 std::lock_guard它的构造函数里对互斥量加锁析构函数里解锁。不管代码中间发生什么哪怕是抛出异常导致提前离开作用域lock_guard 的析构函数也一定会被执行锁一定会被释放。这就是构造函数和析构函数真正的价值所在它们不只是“类的两个函数”而是 C 资源管理的基石。理解了这一点你再看智能指针的源码就会觉得一切都很自然。5. 实操排查新手最容易遇到的几个报错5.1 编译环境的坑先解决再学习在正式写构造函数、析构函数之前环境没配好会浪费大量时间。很多同学的依赖安装和环境配置步骤可以参考一些成熟的配置方案比如在 Windows 上使用 Visual Studio 或者 VS Code 集成相关编译器工具链。如果你用的是 VS Code常见的报错包括“无法打开源文件 iostream”“检测到 #include 错误”。排查时优先检查编译器是否安装完整、扩展是否正确识别编译器路径顺序不要搞反否则很容易在某个环节卡很久。另外 VSCode 在配置 C/C 环境时经常遇到所谓“include 路径识别不了”的问题本质上就是 c_cpp_properties.json 里的 compilerPath 或 includePath 没设置对。这个文件可以在命令面板里搜索 C/C: Edit Configurations (UI) 来编辑界面化配置会直观很多。还有一种非常常见的报错出现在 Windows 上通过 pip 安装含 C 扩展的 Python 包时提示 “Microsoft Visual C 14.0 or greater is required”。这个报错的本意是系统里缺 C 编译工具链而不是 Python 本身的问题。解决思路很直接安装 Visual Studio Build Tools并勾选“使用 C 的桌面开发”工作负载。安装完成后重启终端一般情况下就不会再报这个错了。很多人在这一步卡了一下午其实就是没装编译工具链。5.2 与构造函数相关的典型编译错误下面是几个我在答疑时经常碰到的报错每个背后都对应着一个知识点。第一个“no matching function for call to Student::Student()”。原因就是前面提过的你手动写了带参数的构造函数编译器不再自动生成默认构造函数而你在创建对象时又不传参数。解决办法要么显式补充一个无参构造函数要么创建对象时传参。第二个“invalid new-expression of abstract class type”。这个报错一般跟构造函数本身没关系而是类里有纯虚函数不能实例化。但很多新手会把报错归因于构造其实不是。检查类里有没有 0结尾的虚函数有的话这个类就是抽象类不能直接创建对象只能被继承。第三个“call to implicitly-deleted default constructor of ...”。这个问题多出现在类里有 const 成员、引用成员或者某个成员没有默认构造函数的时候。编译器想帮你生成默认构造但发现生成不了就会报这个错提醒你自己动手写构造函数。还有一类运行期崩溃很常见报错信息里有 “double free”。原因往往就是浅拷贝导致两个对象析构时释放同一块内存。排查思路是检查类里有没有指针成员有没有自己写拷贝构造函数或者拷贝赋值运算符。如果没有写而类里又有裸指针且在析构里 delete那就要优先怀疑浅拷贝。5.3 与析构相关的运行错误析构相关的问题崩溃现场往往比较隐蔽因为报错不一定直接指向析构函数。常见的表现有程序退出时崩溃、在释放堆内存时报 “HEAP CORRUPTION DETECTED”、在日志里看到析构函数被执行了两次。排查这类问题我一般按三步走。第一步确认所有 new 对应的 delete包括 new[] 对 delete[]。第二步确认类的拷贝行为。如果类里有指针成员检查拷贝构造函数和拷贝赋值运算符是否实现正确没实现的话优先考虑把类设计成不可拷贝的把拷贝构造函数设置成 delete。第三步确认类的析构函数是否可能被调用多次比如同一个对象指针在多个位置都被 delete 了一次。这里给一个实际项目中常见的错误写法class Node { public: Node* next; int value; ~Node() { delete next; } };看起来没问题但如果链表里有环或者同一个 Node 被多个节点引用析构时就会反复 delete 同一个地址。这种问题非常难调因为崩溃的位置可能离真正出错的地方很远。我的建议是链表、树这类数据结构里的节点类不要用析构函数去释放下游节点而是把释放逻辑放在容器或管理器里统一处理职责清晰不容易出问题。6. 一点个人体会和练习建议先说个人体会。教了这么多年 C我发现一个规律凡是构造函数、析构函数理解得扎实的人后面学到移动语义、智能指针、多态时都特别快凡是这些基础含糊的人学到后面总会三天两头回来补课。这俩函数不是 C 里的边角料而是整个资源管理体系的支点。给新手几个可以照着做的练习建议。第一个写一个字符串类要求支持默认构造、带参构造、拷贝构造、析构函数并且用深拷贝解决复制问题。第二个写一个 Resource 类构造函数里申请一块内存析构函数里释放然后观察它在不同作用域下的行为。第三个写一个基类和派生类基类析构带 virtual 和不带 virtual 各试一次对比输出结果。这三个练习做完你对构造和析构的理解就会上一个台阶。最后再分享一个小技巧。如果你不确定某个对象的析构函数什么时候被调用临时在构造函数和析构函数里各加一行打印跑一下程序看输出直观理解远比盯着代码苦想有效。这个办法看着笨但排查生命周期问题的时候比什么调试工具都好使。
返回列表