Lesson 0013 · 类:构造函数与析构函数

C++ 进阶 — 第 6 课

三句话速览
  1. 构造函数在对象出生时自动跑,名字就是类名。
  2. 初始化列表比「先默认再赋值」少走一步。
  3. 析构在对象销毁时自动跑,new 了就要 delete。
上节课:class 是把数据和函数打包(0009)。Student s; s.name = "Alice"; —— 分两步初始化,又啰嗦又容易忘。
这节课:让对象创建时自动初始化(构造函数)、销毁时自动清理(析构函数)。这是面向对象的真正起点,也是后面所有「资源管理」话题的地基。

1. 构造函数 — 出生时自动执行

之前你写 s.name = "Alice"; s.score = 95; —— 又啰嗦又容易忘。构造函数让你一行搞定:

class Student {
public:
    string name;
    int score;

    Student(string n, int s) {      // 构造函数:名字跟类名一样,没有返回值
        name = n;
        score = s;
    }
};

Student s1("Alice", 95);            // 创建时直接传参
Student s2 = {"Bob", 87};           // 初始化列表写法,等效
cout << s1.name;                    // Alice
三个特点:① 函数名 = 类名 ② 没有返回类型(连 void 都不写) ③ 创建对象时自动调用

第 ③ 点最值钱:你根本没写「调用」这两个字,编译器在你声明对象的那一刻就帮你调了。所以只要构造函数写对,就不存在「忘了初始化」这种 bug。

2. 初始化列表 — 更优雅的写法

Student(string n, int s) : name(n), score(s) {
    // 构造体可以为空
}

name(n), score(s) 叫初始化列表,对比:

赋值写法初始化列表
name = n; score = s;: name(n), score(s)
先默认初始化,再赋值(两步)直接初始化(一步,更高效)

两种写法结果一样,但初始化列表是「一步到位」:成员直接用地道的方式造出来,而不是先造一个空的再改。成员一多,这个差别就是实打实的开销。

3. 析构函数 — 临终前自动执行

class Student {
public:
    string name;
    int* scores;                        // 动态分配的数组

    Student(string n, int cnt) : name(n) {
        scores = new int[cnt];          // 构造时 new
    }

    ~Student() {                        // 析构函数:~类名,无参数无返回值
        delete[] scores;                // 销毁时自动 delete
        cout << name << " 被销毁了" << endl;
    }
};

{
    Student s("Alice", 5);             // 构造
}                                      // 离开作用域 → 自动调用析构 → delete[] scores

注意最后两行:} 一出现,s 就离开了作用域,析构函数立刻被调用,不需要你写任何一行「清理」代码。

铁律(RAII):构造函数里 new 了什么,析构函数里就 delete 什么。这叫「资源获取即初始化」 —— C++ 最重要的编程哲学。

4. 默认构造函数

不写构造函数时,编译器自动生成一个什么都不做的默认构造函数。但你一旦写了带参数的构造函数,默认的就没了:

class Student {
public:
    string name;
    Student(string n) : name(n) {}     // 写了这个
};

Student s1;           // ❌ 报错!没提供参数
Student s2("Alice");  // ✅

想两种都用,自己加一个无参版本:

Student() {}                           // 默认构造函数
Student(string n) : name(n) {}         // 带参构造函数

这两个版本可以同时存在,因为它们参数不同 —— 这叫构造函数重载。析构函数就不行,它只能有一个。

5. 完整示例

class Timer {
    int seconds;
public:
    Timer() : seconds(0) {}                        // 默认构造
    Timer(int s) : seconds(s) {}                   // 带参构造
    ~Timer() { cout << "计时器销毁" << endl; }    // 析构

    void tick() { seconds++; }
    int get() { return seconds; }
};

int main() {
    Timer t(60);           // 调用带参构造,倒计时 60 秒
    t.tick();              // 61
    cout << t.get();
}                          // t 离开作用域 → 自动析构

输出是 61 然后是 计时器销毁。这两行的顺序就是对象的完整一生:构造(0)→ 干活(61)→ 析构。

6. 坑点清单

7. 小测

Q1. 构造函数的名字有什么要求?
Q2. 析构函数什么时候被调用?
Q3. 写了带参构造函数后,默认构造函数会怎样?

8. 动手练习

题目:写一个 Counter 类

- 构造函数接受一个初始值
- inc() 方法 +1
- dec() 方法 -1
- get() 返回当前值
- 析构函数打印 "Counter 销毁,最终值: X"

加练一道:把 Counter 再加一个「名字」字段,用初始化列表初始化它;然后创建两个 Counter,一个在 main 里,一个在花括号 { } 里,看析构打印的先后顺序 —— 你能亲眼看到「离开作用域」这件事。

卡住时的自查顺序

  1. 先编译:看第一个错误行,八成是少了分号或者类名拼错。
  2. 构造函数名和类名一字不差吗?有没有手滑加了 void 或者返回类型?
  3. 创建对象那一行传参对不对:带参构造要传参,默认构造不能传参。
  4. 析构打印出来了吗?没出来 → 对象还在作用域里,或者你把它 new 在堆上了。
  5. 数值不对:inc() / dec() 改的是成员变量,还是不小心改成了局部变量?
  6. 都看过了还不对:把类定义和 main 一起贴过来。

本课小结

下一课预告
0014 文件读写 —— 对象的一生管明白了,下一步让数据也活下来:ofstream 写文件、ifstream 读文件、getline 读整行,以及「文件打不开怎么办」。
这一课你会用到刚才学的两件事:打开一个文件时先检查它开没开(像检查对象状态),以及让流对象离开作用域时自动收尾(RAII 的另一个例子)。

← 上一课:复杂度分析 · 课程对照表

视频 黑马第 93-105 集 · 书籍 Primer Plus 第 10-12 章