ARTICLE DETAIL

资讯详情

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

C++构造函数与析构函数详解:对象生命周期与RAII资源管理

C++构造函数与析构函数详解:对象生命周期与RAII资源管理 学C的类逃不开“默认成员函数”这个说法而一开始最容易把新手绕晕的就是构造函数和析构函数这对组合了。构造函数负责对象创建时的初始化析构函数负责对象销毁时的善后清理两个合在一起决定了一个C对象从诞生到消亡的完整生命周期。这篇笔记不扯虚的直接把构造函数和析构函数的语法、调用时机、经典坑、实战用法一条条掰开讲清楚适合刚学到类和对象的同学也适合那些代码写着写着遇到“对象没初始化”“内存泄漏”却一脸懵的初学者。很多人在初学阶段会问编译器不是会自动生成构造函数和析构函数吗那我自己写和不写有什么区别我又为什么要手动去管这些这些疑问每天都会出现在各种学习群里。既然说“超详细解析”我就把这背后的原理、底层的资源管理逻辑以及真正常见的报错场景一次说明白。看完之后你会发现构造函数和析构函数不仅不神秘反而是你写靠谱代码的第一道保险。1. 先把构造和析构放回类里看什么是“默认成员函数”1.1 默认成员函数其实是一整套“自动服务”C里的类除了你自己写的成员函数之外编译器还悄悄给你准备了一组函数。只要你没显式定义编译器就会在需要时自动生成逻辑。这一组函数统称为“默认成员函数”总共六个默认构造函数、默认析构函数、拷贝构造函数、拷贝赋值函数、移动构造函数、移动赋值函数。后面三个和“拷贝/移动”相关的涉及到对象之间互相复制或者转移资源所有权的问题属于进阶内容。而构造函数和析构函数则是所有对象都躲不开的两个基本动作。打个比方对象就像一个租来的房间。构造函数是你办理入住交押金、拿钥匙、把水电开通这时候房间才能正常使用。析构函数是退租时的流程检查物品、交回钥匙、确认没有欠款。你住的时候有多潇洒退租的时候就有多需要这个流程。只不过C要求的不是“流程”而是你程序里每一个对象在出生和消亡时都必须有明确的初始化动作和清理动作。1.2 为什么必须从“生命周期”的角度来理解我辅导过很多同学一上来就背语法构造函数和类同名没有返回值析构函数前面加个波浪号。背得很熟写代码还是一脸茫然。根源就在于没有建立“生命周期”思维。每个对象从声明那一刻起有“构造”到离开作用域或者被 delete 那一刻有“析构”。这两个节点是你在设计类时最先要想清楚的事。构造函数要把这个对象的成员变量设置成合理状态不能让成员变量处于“未定义”的值析构函数要把这个对象运行期间占用的资源释放掉不能让内存泄漏或者文件句柄一直挂着。很多线上问题比如内存持续上涨、文件被占用删不掉说到底就是构造和析构没有配对好。从“生命周期”的视角去理解后面所有语法细节都很好记。你写构造函数就是在回答一个问题这个对象出生时需要准备什么你写析构函数就是在回答另一个问题这个对象消失时需要归还什么这两个问题想清楚了类的设计就至少不会出方向性错误。2. 构造函数让对象一出生就处于“可用状态”2.1 构造函数的定义规则和调用时机构造函数从语法上看很特殊函数名必须和类名完全一致没有返回类型连 void 都不能写。这意味着构造函数不能被当作普通函数随便调用它只会在对象创建时由编译器自动触发。比如下面这段代码class Student { public: Student() { // 构造函数体 std::cout Student 对象创建了 std::endl; } }; int main() { Student s; // 这一行触发构造函数 return 0; }当你写下Student s;这行代码时编译器就在这个位置插入了一次对构造函数的调用。也就是说构造函数不是你在某个地方手动调用的而是对象定义的那一刻自动执行的。这个概念再往前推一步构造函数里写的初始化代码必须在对象可用之前完成。如果构造函数里某个分支提前 return 了对象大概率处于残缺状态这就是隐患。还有一个容易忽略的细节构造函数可以重载。因为构造函数本质上是成员函数当然可以像普通函数一样通过参数列表区分。你可以写出无参构造、带参数构造、带默认参数构造等调用哪个构造函数由你创建对象时传入的实参决定。2.2 默认构造函数编译器何时自动生成这里先说一个概念陷阱。术语“默认构造函数”一般指的是“不需要传参数就能调用的构造函数”。它有两种来源一种是你在类里显式写了一个无参构造或者所有参数都有默认值的构造另一种是编译器自动生成的那个版本。编译器自动生成默认构造函数是有条件的只要你在这个类里写了任何构造函数编译器就不会再帮你自动生成默认的。这句话极其重要很多新手在这里翻车。看个例子class Score { public: Score(int v) { value v; } // 写了一个带参构造 int value; }; int main() { Score s; // 编译报错no matching function for call to Score::Score() return 0; }明明我写了一个构造函数为什么Score s不行原因你写了一个带参构造之后编译器认为你已经接管了对象的构造工作于是不再自动生成无参版本。你想用无参构造就必须自己加上Score() default;或者手动补充一个无参构造函数。新手经常在写链表节点、边缘场景类时被这个绊住报错信息里出现no matching function时第一反应就该审查是不是构造函数重载覆盖导致默认版本消失了。2.3 初始化列表成员初始化的“正路”构造函数里给成员变量赋初值有两种写法一种是在函数体里用赋值操作name_ name;另一种是通过初始化列表在函数体执行之前就把成员变量初始化好。后者才是正规玩法。初始化列表是在冒号后面依次列出成员变量及其初值class Student { public: Student(const std::string name, int age) : name_(name), age_(age) { } private: std::string name_; int age_; };为什么推荐初始化列表首先成员变量是在进入构造函数体之前就完成构造的。如果函数体里再赋值等于先让成员执行了一次默认构造然后又执行了一次赋值多了一次无用操作。对 int 这种内置类型可能感觉不明显但如果成员是一个复杂类性能差异就会显现。其次有些成员变量只能通过初始化列表初始化在函数体内赋值是编译不过的典型场景包括成员变量是 const 类型、是引用类型、或者是没有默认构造函数的类类型。初始化列表还有一个老生常谈的坑成员的初始化顺序不取决于你在初始化列表里写的顺序而取决于成员变量在类里的声明顺序。比如class Test { public: Test() : b_(1), a_(b_) {} int a_; int b_; };先声明的是a_所以构造时会先拿b_的值初始化a_但此时b_还是垃圾值。结果就是a_拿到一个未定义值。写完初始化列表后一定要对照类里成员的声明顺序检查一遍避免这种隐蔽bug。2.4 构造函数里别做太重的事也别忘记逃逸手段构造函数里做太多耗时操作会影响对象创建的效率这在高频创建对象的场景里很明显。比如一个日志类构造函数里就去打开文件、申请一大块内存、建立网络连接结果整个程序可能只是需要临时记录几次日志资源就被白白占住了。更合理的做法是延迟初始化把真正重的资源获取放到第一次实际使用的时候。遇到只有个别成员需要特殊初始化的情况还可以结合默认参数让构造函数具备多种用法。比如class Config { public: Config(const std::string path default.conf) : path_(path) {} private: std::string path_; };这样Config c;和Config c(my.ini);都能编译通过。构造函数的默认参数和无参版本不能重复否则会产生歧义这也是一个编译报错高发点。3. 析构函数对象“善后”工作一肩挑3.1 析构函数的语法和调用规则析构函数在语法上更特殊函数名是~类名没有返回值不接受任何参数也不允许重载。也就是说一个类只能有一个析构函数这是它和构造函数最明显的区别之一。class Student { public: ~Student() { std::cout Student 对象销毁了 std::endl; } };析构函数什么时候触发规则比构造函数更复杂一点。局部对象在离开所属作用域时触发析构堆上对象在调用 delete 时触发析构全局对象在 main 函数结束之后触发析构静态局部对象在程序结束时触发析构。这几条规则记牢最常见的“什么时候析构”问题就基本解决了。3.2 为什么析构函数必须存在一切都要“成对出现”很多新手觉得析构函数可有可无反正不写编译器也会生成一个空析构。这种想法在小程序里没问题一旦涉及资源管理就会出大事。典型场景构造函数里用new[]申请了一块内存析构函数里如果什么都不做这块内存就永远没人释放了泄漏就发生了。在C里资源不只是内存。你可能打开了文件、连接了数据库、加了一把锁、注册了一个事件回调这些都算资源。所有资源都遵循同一个原则谁申请谁释放。而析构函数就是那个“保证释放一定执行”的保险。只要对象生命周期结束析构函数的代码必然执行于是资源一定能得到释放。这就是后面第四部分要讲的 RAII 思想的根基。3.3 析构函数里不能做的事抛异常、虚函数多态、无脑释放析构函数最忌讳的是抛出异常。C11 开始析构函数默认是 noexcept 的一旦析构函数里抛了异常程序会直接调用 terminate 终止非常难以排查。析构函数里的清理动作要尽量简单把可能失败的操作留到普通成员函数里处理。另一个常被误解的点析构函数里调用虚函数不会触发动态绑定。基类指针析构时调用的还是基类版本的析构函数除非把析构函数本身声明为 virtual。这也是为什么多态体系下基类析构函数必须写成 virtual否则通过基类指针 delete 派生类对象时派生类的析构函数根本不会执行当初派生类构造函数申请的资源全都会泄漏。最后析构函数里释放资源时要考虑“这个资源现在是不是真的属于我”。如果你把内部指针赋值给了另一个对象或者做了所有权转移当前对象的析构函数就不能盲目释放。这涉及到拷贝构造和移动语义属于进阶内容但至少要有这个意识释放前想清楚这块内存到底归谁管。4. 实战场景用构造和析构管好资源RAII 思想的入门体验4.1 一个简单的内存管理类说了这么多理论来写点能跑的东西。假设我们要写一个简单的字符串类用char*存放字符数据。构造函数负责申请内存析构函数负责释放内存这就是一个最小可用的 RAII 案例#include iostream #include cstring class MyString { public: MyString(const char* str ) { len_ strlen(str); data_ new char[len_ 1]; strcpy(data_, str); std::cout 构造函数执行申请内存 (void*)data_ std::endl; } ~MyString() { std::cout 析构函数执行释放内存 (void*)data_ std::endl; delete[] data_; } const char* c_str() const { return data_; } private: char* data_; int len_; }; int main() { MyString s(hello); std::cout s.c_str() std::endl; return 0; }运行结果里你会看到构造函数先执行打印申请到的地址main 函数结束前析构函数执行打印释放的地址。申请和释放严格配对内存泄漏的隐患从源头消失了。这个思路虽然简单但它是后面智能指针、锁管理类等一系列封装的雏形。4.2 用析构函数做“作用域计时器”除了资源释放析构函数还有一个经典玩法利用它能自动执行的特性在对象离开作用域时做收尾统计。比如给某段代码计时不用手动在函数结尾写计时逻辑而是定义一个 Timer 类#include iostream #include chrono class Timer { public: Timer() { start_ std::chrono::steady_clock::now(); } ~Timer() { auto end std::chrono::steady_clock::now(); auto duration std::chrono::duration_caststd::chrono::milliseconds(end - start_); std::cout 耗时 duration.count() 毫秒 std::endl; } private: std::chrono::steady_clock::time_point start_; }; int main() { Timer t; int sum 0; for (int i 0; i 1000000; i) { sum i; } // 这里不需要手动计时t 离开作用域时析构函数自动输出 return 0; }这种写法的好处是你想给一个函数计时只需要在函数开头写一个Timer t;后续不管函数从哪个分支返回析构函数都会触发计时永远可靠。手动计时的话你得在每一个 return 之前都补一行输出最后一不小心就漏掉了。RAII 的核心价值就在这把“必须执行”的收尾动作交给对象生命周期去保证而不是依赖程序员记得调用。4.3 构造和析构的调用顺序到底该怎么记实际代码里会遇到多个对象顺序问题也成了一个经典考点。规则可以概括成两句话同一作用域内后定义的对象先析构。比如先建obj1再建obj2那么析构顺序是obj2先、obj1后和栈结构一致。对象的成员变量先按声明顺序构造析构时按逆序进行。基类先构造析构时最后析构基类。拿标准库的类型来看std::ofstream走出作用域自动关闭文件std::lock_guard走出作用域自动解锁都是利用了析构函数这种“自动触发”的能力。把这些规则记成“构造像排队进电梯先到的先进后到的先出析构像坐电梯出来先进的后出后进的先出”就不容易混了。5. 小白最容易踩的构造/析构坑和排查思路5.1 “我看了半天就是没初始化”类的问题构造函数最典型的坑就是成员变量没有被初始化。有人会说那我构造函数体里不是已经写赋值了吗问题往往出在不是所有路径都走到了赋值。构造函数里提前 return、某个分支忘了赋值都会造成成员变量取到不确定值程序表现就是“时好时坏”。这类问题最直接的排查方式是在构造函数里把所有成员变量的初始化都放到初始化列表用列表初始化保证“无论构造函数体走哪个分支成员都已经有确定初值”。对内置类型尤其有效不要依赖编译器帮你清零。5.2 编译报错报在“对象创建”上默认构造被覆盖了我在 2.2 里提到的场景也是群里提问率极高的问题。自己写了一个带参构造然后想在别处定义这个类的对象数组或者把它塞进std::vector结果大量报错指向的是“找不到默认构造函数”。原因就是默认构造被你的带参构造覆盖了。一个快速自查清单问题现象常见原因解决办法no matching function for call to Class::Class()类里定义了其他构造函数默认构造不再自动生成补一个无参构造或显式写Class() default;delete之后程序崩溃同一个堆内存被释放两次检查拷贝行为是否导致多个对象持有同一块指针必要时禁用拷贝局部对象离开作用域后资源没释放析构函数里没有释放逻辑检查析构函数体或改用 RAII 封装析构函数一运行就 terminate析构函数里抛出了异常清理操作不要抛异常改为普通成员函数完成程序结束时全局对象的析构顺序崩溃全局对象之间存在互相依赖尽量避免全局对象或使用函数内静态局部变量替代5.3 调试构造和析构最朴素的办法也最管用遇到构造析构相关的问题日志输出永远是最直观的排查手段。在构造函数和析构函数入口加上唯一标识程序一跑谁先谁后、谁没执行、谁执行了两次一目了然。等确认逻辑正确了再把日志删掉或者用条件编译宏包起来。gdb 里也可以给构造函数下断点break Student::Student给析构函数下断点break Student::~Student。观察调用栈能看到对象到底是在哪个位置被创建的又是在哪个位置被销毁的。还有一个经验之谈当代码的规模变大以后不要试图“猜”问题出在哪。先在纸上把对象的创建、作用域、删除点全部列一遍用箭头画出生命周期再结合日志确认。事实是大多数构造析构的问题在画出生命周期图的瞬间就已经暴露了根本不用靠猜。5.4 环境问题也顺手提一嘴在 Windows 上做 C 练习新手常会遇到两个环境相关报错一个是安装 Python 包时提示Microsoft Visual C 14.0 or greater is required这其实是你本机缺少 C 编译工具链装一个对应版本的 Visual Studio Build Tools 或 Visual C Redistributable 就能解决另一个是用 VS Code 配 C/C 环境时出现连接器错误多半是编译器路径没配对。环境问题不影响你对构造函数析构函数的理解但会消耗大量耐心所以建议初学者一开始就选一个稳定的环境比如直接用 Visual Studio Community 或者 VS Code 配好 MinGW把精力留到语法本身。6. 写构造函数和析构函数时我的几条习惯你问我现在写类怎么处理构造和析构我基本遵循一套很省心的习惯。一个类只要涉及资源不管是动态内存、文件句柄还是锁构造函数里完成获取析构函数里完成释放绝不在普通成员函数里偷偷做资源回收。这样对象在哪个作用域生存资源就在哪个作用域内被管住不会出现“忘了关”或者“不知道什么时候关”的局面。另一个习惯是构造函数尽量保持“不干活”的状态把复杂的初始化拆到一个专门的init()函数里或者用工厂函数去创建对象。很多人不理解觉得多写几个函数不是更麻烦吗实际上一旦构造函数需要处理网络错误、读配置、解析文件这类可能失败的操作对象创建就很难保证“要么完全成功要么完全失败”很容易出现半初始化的对象。保持构造函数轻量会让对象生命周期管理的思路清晰得多。这个问题新手阶段不会遇到但提前有这个意识后期的维护会舒服很多。最后一点也是我觉得最值得分享的如果你还没自信掌握构造和析构就多写一点会打印日志的小例子自己盯着输出去推敲。写十个八个带构造析构的小类之后你会发现所谓“生命周期管理”其实比想象中直觉得多。构造函数和析构函数不是用来背的语法它们是你和C之间关于资源承诺的契约。
返回列表