☰
深入理解C++构造函数与析构函数:从默认成员函数到RAII资源管理
2026/9/29 2:04:10 网站建设 项目流程

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[] data;s1 再析构,再次 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 各试一次,对比输出结果。这三个练习做完,你对构造和析构的理解就会上一个台阶。

最后再分享一个小技巧。如果你不确定某个对象的析构函数什么时候被调用,临时在构造函数和析构函数里各加一行打印,跑一下程序看输出,直观理解远比盯着代码苦想有效。这个办法看着笨,但排查生命周期问题的时候,比什么调试工具都好使。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询