文章目录
- 引言
- [一、C 的"手动挡":init 和 destroy 的痛苦](#一、C 的"手动挡":init 和 destroy 的痛苦)
-
- [1.1 一个典型的 C 模式](#1.1 一个典型的 C 模式)
- [1.2 真实项目中更糟](#1.2 真实项目中更糟)
- 二、构造函数:对象从诞生那一刻就是合法的
-
- [2.1 第一个构造函数](#2.1 第一个构造函数)
- [2.2 构造函数重载](#2.2 构造函数重载)
- [2.3 成员初始化列表:比构造函数体更高效](#2.3 成员初始化列表:比构造函数体更高效)
- 三、析构函数:自动打扫战场
-
- [3.1 基本语法](#3.1 基本语法)
- [3.2 析构的调用时机](#3.2 析构的调用时机)
- [3.3 析构函数的保证:即使出错也会运行](#3.3 析构函数的保证:即使出错也会运行)
- 四、编译器默认生成的构造函数和析构函数
- [五、完整演进:从 C 到 C++ 的生命周期管理](#五、完整演进:从 C 到 C++ 的生命周期管理)
-
- [阶段1:C ------ 处处手动](#阶段1:C —— 处处手动)
- [阶段2:C++ ------ 构造和析构接管一切](#阶段2:C++ —— 构造和析构接管一切)
- 六、常见陷阱
-
- [6.1 虚析构函数(预告)](#6.1 虚析构函数(预告))
- [6.2 不要在析构函数中抛出异常](#6.2 不要在析构函数中抛出异常)
- [6.3 构造函数中不要调用 virtual 函数](#6.3 构造函数中不要调用 virtual 函数)
- 总结
本系列为《C++深度修炼:基础、STL源码与多线程实战》第3篇
前置条件:理解 C 语言手动 init/destroy 模式,读过第2篇了解 class 与封装
引言
在 C 语言中,创建一个结构体对象需要两步:先声明变量,再手动调用 init 函数。销毁时,必须记得调用对应的 destroy 或 free。
忘掉任何一步,就是 bug------未初始化的字段包含垃圾值,未释放的内存就是泄漏。
C++ 的构造函数和析构函数把这个模式变成了"自动挡":对象诞生时,编译器自动 调用构造函数来初始化;对象消亡时,编译器自动调用析构函数来打扫战场。你不需要记得------编译器替你记。
这不是语法糖,而是 C++ 整个资源管理体系(RAII)的基石。
一、C 的"手动挡":init 和 destroy 的痛苦
1.1 一个典型的 C 模式
cpp
// file_buffer.c --- C风格手动管理
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
struct FileBuffer {
char *data;
size_t size;
FILE *file;
};
// 初始化函数------必须手动调用
void file_buffer_init(struct FileBuffer *fb, const char *path) {
fb->file = fopen(path, "r");
if (!fb->file) {
fb->data = NULL;
fb->size = 0;
return;
}
// 获取文件大小
fseek(fb->file, 0, SEEK_END);
fb->size = ftell(fb->file);
rewind(fb->file);
// 分配内存
fb->data = malloc(fb->size);
if (fb->data) {
fread(fb->data, 1, fb->size, fb->file);
}
}
// 清理函数------必须手动调用
void file_buffer_destroy(struct FileBuffer *fb) {
free(fb->data);
fb->data = NULL; // 避免悬空指针
if (fb->file) fclose(fb->file);
}
int main() {
struct FileBuffer fb;
// 问题1:如果忘记 init,fb.data 是垃圾值
file_buffer_init(&fb, "data.txt");
// 中间可能有多个 return、goto,或者提前出错退出
if (fb.size == 0) {
return 1; // 问题2:提前 return,忘记 destroy!内存泄漏!
}
printf("Read %zu bytes\n", fb.size);
file_buffer_destroy(&fb); // 问题3:必须记得调用
return 0;
}
这段代码有三个典型风险点:
| 风险 | 场景 | 后果 |
|---|---|---|
| 未初始化 | 声明后、init 前使用对象 | 垃圾数据、段错误 |
| 未清理 | 提前 return / goto / 异常 | 内存泄漏、文件句柄泄漏 |
| 重复清理 | 多次调用 destroy | 双重 free、未定义行为 |
1.2 真实项目中更糟
cpp
int process_files(const char **paths, int count) {
struct FileBuffer *buffers = malloc(count * sizeof(struct FileBuffer));
for (int i = 0; i < count; i++) {
file_buffer_init(&buffers[i], paths[i]);
if (!buffers[i].data) {
// 出错了!需要清理前 i 个已经初始化的对象
for (int j = 0; j < i; j++)
file_buffer_destroy(&buffers[j]);
free(buffers);
return -1;
}
}
// ... 处理数据 ...
// 正常清理
for (int i = 0; i < count; i++)
file_buffer_destroy(&buffers[i]);
free(buffers);
return 0;
}
每增加一个错误路径,就要多写一段清理代码。C 程序里,清理代码的体量常常超过业务逻辑。
二、构造函数:对象从诞生那一刻就是合法的
2.1 第一个构造函数
cpp
// file_buffer_v1.cpp
#include <cstdio>
#include <cstdlib>
#include <cstring>
class FileBuffer {
public:
// 构造函数:名字与类名相同,没有返回类型
FileBuffer(const char *path) {
file_ = fopen(path, "r");
if (!file_) {
data_ = nullptr;
size_ = 0;
return;
}
fseek(file_, 0, SEEK_END);
size_ = ftell(file_);
rewind(file_);
data_ = malloc(size_);
if (data_) {
fread(data_, 1, size_, file_);
}
}
// 普通成员函数
size_t size() const { return size_; }
const char *data() const { return static_cast<const char*>(data_); }
private:
void *data_;
size_t size_;
FILE *file_;
};
int main() {
FileBuffer fb("data.txt"); // 构造自动调用,无需手动 init
printf("Read %zu bytes\n", fb.size());
// 即使提前 return,析构函数也会自动清理(见下一节)
return 0;
}
关键变化就一个地方:FileBuffer fb("data.txt") 声明的同时,构造函数自动执行了。 你不再需要(也不能)手动调用 init。编译器保证:只要对象存在,构造函数就一定已经执行过。
2.2 构造函数重载
和普通函数一样,构造函数可以重载------用不同的参数组合初始化对象:
cpp
#include <iostream>
class Point {
public:
Point() : x_(0), y_(0) { // 默认构造
std::cout << "Point()\n";
}
Point(int x, int y) : x_(x), y_(y) { // 带参数构造
std::cout << "Point(" << x << ", " << y << ")\n";
}
Point(int v) : x_(v), y_(v) { // 单参数构造
std::cout << "Point(" << v << ")\n";
}
private:
int x_, y_;
};
int main() {
Point a; // Point()
Point b(3, 5); // Point(3, 5)
Point c(7); // Point(7)
Point d = 7; // 同样调用 Point(7) ------ 隐式转换
}
⚠️
Point d = 7这行会调用Point(7),因为单参数构造函数默认允许隐式类型转换。大多数时候你不希望这样------加explicit可以禁止:
cppexplicit Point(int v) : x_(v), y_(v) {} // Point d = 7; // ❌ 现在不允许了 // Point d(7); // ✅ 显式调用仍然可以
2.3 成员初始化列表:比构造函数体更高效
构造函数体 {} 内部的 = 是赋值 ,不是初始化 。真正的初始化发生在进入构造函数体之前。对于非基本类型成员,这意味着"先默认初始化,再赋新值"------多了一次操作:
cpp
#include <string>
class User {
public:
// 不好的写法:先默认构造 name_(空 string),再在函数体内赋值
User(const std::string &name, int age) {
name_ = name; // 赋值,不是初始化
age_ = age; // 对 int 来说没区别
}
// 好的写法:初始化列表,直接调用 string 的拷贝构造函数
User(const std::string &name, int age)
: name_(name) // 初始化,不是赋值
, age_(age) // 对 int 来说等价,但风格统一
{}
private:
std::string name_;
int age_;
};
📌 核心规则 :初始化列表的执行顺序只看成员声明顺序,与初始化列表中的书写顺序无关。为了避免混淆,始终按声明顺序书写初始化列表。
cpp
class Widget {
public:
Widget(int v) : b_(v), a_(b_ + 1) {} // 危险:a_ 先用 b_ 初始化,但 b_ 还没初始化!
// 实际执行顺序:先 a_(b_ + 1)(b_ 是垃圾值!),后 b_(v)
// 因为 a_ 声明在 b_ 前面
private:
int a_; // 声明在前
int b_; // 声明在后
};
三、析构函数:自动打扫战场
3.1 基本语法
析构函数的名字是 ~类名(),没有参数,没有返回值,一个类只有一个析构函数:
cpp
class FileBuffer {
public:
FileBuffer(const char *path) {
// ... 构造逻辑 ...
}
~FileBuffer() { // 析构函数
free(data_);
if (file_) fclose(file_);
}
private:
void *data_;
size_t size_;
FILE *file_;
};
析构函数在对象生命周期结束时自动被调用:
cpp
void process() {
FileBuffer fb("data.txt"); // 构造
// ... 使用 fb ...
if (some_error()) {
return; // 提前退出------fb 的析构自动运行!
}
// 正常结束时------fb 的析构也会自动运行
} // <-- 无论从哪个出口离开,析构都会执行
3.2 析构的调用时机
cpp
#include <iostream>
struct Tracer {
const char *name_;
Tracer(const char *name) : name_(name) { std::cout << name_ << " 诞生\n"; }
~Tracer() { std::cout << name_ << " 消亡\n"; }
};
Tracer global("全局对象");
int main() {
std::cout << "进入 main\n";
Tracer local("局部对象");
{
Tracer block("块作用域对象");
std::cout << "离开块作用域\n";
} // block 在此析构
std::cout << "离开 main\n";
return 0;
} // local 在此析构,global 在程序退出时析构
text
$ g++ -std=c++17 tracer.cpp && ./a.out
全局对象 诞生
进入 main
局部对象 诞生
块作用域对象 诞生
离开块作用域
块作用域对象 消亡
离开 main
局部对象 消亡
全局对象 消亡
析构顺序 = 构造顺序的逆序。局部对象先于全局对象析构,栈上的后构造先析构。
3.3 析构函数的保证:即使出错也会运行
这是析构函数最核心的价值。看一个对比:
cpp
// C 版本
int process() {
FILE *f = fopen("data.txt", "r");
if (!f) return -1;
char *buf = malloc(1024);
if (!buf) {
fclose(f); // 手动清理 f
return -1;
}
int result = read_and_process(f, buf);
// 如果 read_and_process 内部有什么 goto 或者 longjmp......
// 下面的清理根本执行不到!
free(buf);
fclose(f);
return result;
}
// C++ 版本
int process() {
FileBuffer fb("data.txt"); // 构造函数打开文件、分配内存
if (fb.size() == 0) return -1; // 提前 return,fb 的析构自动运行
return process_contents(fb); // 无论发生什么,fb 的析构都会运行
}
💡 这就是 RAII(Resource Acquisition Is Initialization) 的雏形:在构造函数中获取资源,在析构函数中释放资源。 编译器保证析构一定执行,从而保证资源一定释放。完整论述见第12篇。
四、编译器默认生成的构造函数和析构函数
如果你不写任何构造函数,编译器会生成以下"默认成员":
| 默认生成 | 行为 |
|---|---|
默认构造函数 T() |
调用每个成员的默认构造函数(基本类型不初始化!) |
析构函数 ~T() |
调用每个成员的析构函数 |
拷贝构造函数 T(const T&) |
逐成员拷贝 |
拷贝赋值 T& operator=(const T&) |
逐成员赋值 |
cpp
struct Data {
int x; // 基本类型:默认构造函数不初始化它
double y; // 同上
std::string s; // 类类型:默认构造函数调用 string()
};
int main() {
Data d; // 调用编译器生成的默认构造函数
// d.x 和 d.y 是未定义的垃圾值!
// d.s 是空 string(string 的默认构造函数保证的)
}
⚠️ 关键规则 :一旦你手动定义了任何构造函数 ,编译器就不再生成默认构造函数 。如果你还需要默认构造行为,必须显式加上
= default;:
cppclass Foo { public: Foo() = default; // 显式要求编译器生成 Foo(int x) : x_(x) {} // 自定义构造函数 private: int x_ = 0; };
五、完整演进:从 C 到 C++ 的生命周期管理
以一个简单的"动态字符串"为例,展示 C 到 C++ 的完整演进:
阶段1:C ------ 处处手动
cpp
// string_c.c
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
struct DynString {
char *data;
size_t len;
};
void ds_init(struct DynString *s, const char *init) {
s->len = strlen(init);
s->data = malloc(s->len + 1);
if (s->data) strcpy(s->data, init);
}
void ds_destroy(struct DynString *s) {
free(s->data);
s->data = NULL;
s->len = 0;
}
// 使用:每个函数调用点都要保证配对
void do_stuff() {
struct DynString s;
ds_init(&s, "hello");
// ... 如果这里有 return/error,就漏了 ds_destroy
ds_destroy(&s);
}
阶段2:C++ ------ 构造和析构接管一切
cpp
// string_cpp.cpp
#include <cstring>
#include <cstdlib>
#include <iostream>
class DynString {
public:
DynString(const char *init) {
len_ = strlen(init);
data_ = static_cast<char*>(malloc(len_ + 1));
if (data_) strcpy(data_, init);
std::cout << "构造: " << data_ << '\n';
}
~DynString() {
std::cout << "析构: " << (data_ ? data_ : "(null)") << '\n';
free(data_);
}
const char *c_str() const { return data_; }
private:
char *data_;
size_t len_;
};
void do_stuff() {
DynString s("hello"); // 构造自动调用
printf("%s\n", s.c_str());
if (s.c_str()[0] == 'h') {
return; // 提前退出------析构自动运行 ✅
}
// 析构在这里也会自动运行
}
// 输出:
// 构造: hello
// hello
// 析构: hello
六、常见陷阱
6.1 虚析构函数(预告)
如果类会被继承,析构函数通常应该声明为 virtual。这不是本篇的重点(详见第36篇),但先留下印象:
cpp
class Base { public: ~Base() {} }; // 非虚析构------危险!
class Derived : public Base { /* ... */ };
Base *p = new Derived();
delete p; // 只调用了 Base::~Base(),Derived 的部分泄漏了!
// 解法:Base 应该写 virtual ~Base() = default;
6.2 不要在析构函数中抛出异常
析构函数如果抛出异常且未被捕获,std::terminate 会被调用------程序直接终止。C++11 起析构函数默认是 noexcept 的。
6.3 构造函数中不要调用 virtual 函数
在构造函数执行期间,对象还没有"完全变成"派生类,virtual 函数的调度是基类版本,不是派生类版本。这是第37篇的主题。
总结
构造与析构是 C++ 从 C 继承来的语法骨架上最重要的新人:
- 构造函数保证对象从诞生那一刻起就是合法可用的------不会有"忘记 init"的漏洞
- 析构函数保证对象离开作用域时资源一定被回收------无论从哪个出口离开
- 成员初始化列表是真正的初始化,构造函数体内部是赋值------对非基本类型有性能影响
这三个保证合在一起,构成了 C++ 最核心的编程范式------RAII (资源获取即初始化)。有了它,我们才能写出"不需要手动 close、不需要手动 free、不需要在每一个错误路径上复制清理代码"的程序。
下一篇文章,我们将深入 this 指针和成员函数 ------理解成员函数在底层到底是怎么被调用的,以及 p.move(x, y) 和 point_move(&p, x, y) 本质上是同一件事。
📝 动手练习:
- 把第2篇练习中的
Thermometer类加上构造函数(接受摄氏度/华氏度/开尔文三种参数并转换为开尔文存储),加上析构函数打印日志- 写一个
Tracer类追踪对象生命周期,在多个作用域中创建它,验证析构顺序是否是构造逆序- 故意在一个类的构造函数中申请堆内存、在析构中释放,然后在
main中用return提前退出,确认析构自动运行