构造与析构:对象生命周期的“自动挡“

文章目录

  • 引言
  • [一、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 函数。销毁时,必须记得调用对应的 destroyfree

忘掉任何一步,就是 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 可以禁止:

cpp 复制代码
explicit 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;

cpp 复制代码
class 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 继承来的语法骨架上最重要的新人

  1. 构造函数保证对象从诞生那一刻起就是合法可用的------不会有"忘记 init"的漏洞
  2. 析构函数保证对象离开作用域时资源一定被回收------无论从哪个出口离开
  3. 成员初始化列表是真正的初始化,构造函数体内部是赋值------对非基本类型有性能影响

这三个保证合在一起,构成了 C++ 最核心的编程范式------RAII (资源获取即初始化)。有了它,我们才能写出"不需要手动 close、不需要手动 free、不需要在每一个错误路径上复制清理代码"的程序。

下一篇文章,我们将深入 this 指针和成员函数 ------理解成员函数在底层到底是怎么被调用的,以及 p.move(x, y)point_move(&p, x, y) 本质上是同一件事。


📝 动手练习

  1. 把第2篇练习中的 Thermometer 类加上构造函数(接受摄氏度/华氏度/开尔文三种参数并转换为开尔文存储),加上析构函数打印日志
  2. 写一个 Tracer 类追踪对象生命周期,在多个作用域中创建它,验证析构顺序是否是构造逆序
  3. 故意在一个类的构造函数中申请堆内存、在析构中释放,然后在 main 中用 return 提前退出,确认析构自动运行
相关推荐
redaijufeng1 小时前
C/C++程序从编译到链接的过程
c语言·开发语言·c++
点云学徒1 小时前
【PCL中Ptr释放问题 aligned_free 的2种解决方法】
c++·pcl·点云处理
草莓熊Lotso1 小时前
【CMake】 工程实战:可执行文件从编译、链接到安装全流程深度拆解
linux·运维·服务器·网络·c++·cmake
王老师青少年编程2 小时前
2026年全国青少年信息素养大赛算法应用主题赛(C++赛项-初赛-赛前冲刺模拟卷1:文末附答案和解析)
c++·全国青少年信息素养大赛·答案·初赛·模拟卷·2026年·算法应用主题赛
alwaysrun2 小时前
C++之轻量级JSON序列库jsoncpp
c++·json·编程语言
咩咦2 小时前
C++学习笔记09:内联函数 inline
c++·学习笔记·inline·内联函数·宏函数
计算机安禾2 小时前
【c++面向对象编程】第19篇:多继承与菱形继承(二):虚拟继承的内存模型与复杂性
开发语言·c++
思麟呀2 小时前
在C++基础上理解CSharp-1
开发语言·c++·c#
学习,学习,在学习2 小时前
Q工控仪器程序框架设计详解(工控)
c++·qt·架构·qt5