🔷 第十章 类和对象
📄 一句话概括

构造函数有几条必须记牢的规则:名字=类名、无返回类型、可以重载、可以带默认参数;用"初始化列表"比在函数体里赋值更直接(尤其对 const 成员和引用成员,只能这么写);所有成员按"声明顺序"构造,而不是按初始化列表的书写顺序——这条最容易反直觉。只要写了任何一个构造函数,编译器就不再免费送默认构造函数了(10.2 讲过)。

📝 本节和前面几节的关系

10.2 讲了构造函数"是什么、为什么需要",本节把它的规则补全。用到的旧知识有三处:6.5 重载(多个构造函数怎么区分)、6.2.3 默认参数(怎么少写几个构造函数)、7.8.1 引用(引用成员必须在出生时就绑好,所以只能进初始化列表)。另外本节会点出 const 成员和引用成员的初始化难题,这两条规则没有就写不出合法的类。

一、四条核心规则

规则 说明 违反了会怎样
名字=类名 Point 类的构造函数只能叫 Point 名字不对就不是构造函数
没有返回类型 连 void 都不写 写了就成了普通函数,出生时不跑
可以重载 靠参数表区分(6.5) 参数表一样会报重定义
可以带默认参数 但必须从右往左连续(6.2.3) 编译报错

默认参数的例子(6.2.3 的老规矩,这里照样管用):

class Point {
private:
    int x, y;
public:
    Point(int a = 0, int b = 0) { x = a; y = b; }   // 两个都能省
};

Point p1;            // → Point(0, 0)
Point p2(3);         // → Point(3, 0)
Point p3(3, 4);      // → Point(3, 4)

一个小提醒:如果 Point(int a = 0, int b = 0) 已经能覆盖"无参"的情况,就不要再单独写一个 Point()——两个都能匹配 Point p; 会引发二义性(6.5 讲过的坑),编译报 call of overloaded 'Point()' is ambiguous。

二、初始化列表:更直接的写法

构造函数的函数体前面,可以加一段冒号开头的东西:

class Point {
private:
    int x, y;
public:
    //         ↓ 初始化列表
    Point(int a, int b) : x(a), y(b) {
        // 函数体:可以留空,也可以干别的活
    }
};

读法:x(a) 的意思是"用 a 的值直接初始化成员 x"。

它和"体内赋值"的差别:

初始化列表 : x(a) 函数体内赋值 { x = a; }
动作 一步:构造时就带着值出生 两步:先默认构造(无意义),再赋值覆盖
能用于 const 成员吗 ✅ 能 ❌ 不能(const 一旦出生就不能再改)
能用于引用成员吗 ✅ 能 ❌ 不能(引用必须出生就绑定,7.8.1)
效率 通常更好 差的类型(如 string)会多一次赋值

所以 const 成员和引用成员"只能"用初始化列表:

class Config {
private:
    const int   id;          // const 成员:出生后不能改
    int        &ref;         // 引用成员:出生后不能改绑
    int         value;

public:
    Config(int i, int &r, int v) : id(i), ref(r), value(v) { }
    //                              ↑ 这三个都必须在初始化列表里
};

如果不用初始化列表写上面这个类:id(i); 写在函数体里会报 assignment of read-only member;ref(r); 会报引用未初始化。这两个报错认脸,以后一眼就知道"该用初始化列表"。

三、最容易反直觉的一条:按"声明顺序"初始化

不是按你在初始化列表里写的顺序,而是按成员在类里声明的先后顺序。

class Bad {
private:
    int b;      // ← 声明在前
    int a;      // ← 声明在后
public:
    Bad(int v) : a(v), b(a) { }   // 列表里 a 在前,但实际 b 先初始化!
};

Bad x(5);
cout << x.a << " " << x.b << endl;   // 输出可能是 "5 乱码"

为什么:编译器严格按声明的顺序初始化——先 b,后 a。而 b(a) 执行时 a 还没初始化,于是给 b 塞了个垃圾值。

在有些编译器上还会给警告:

warning: 'Bad::b' will be initialized after 'Bad::a'

两条实用建议:① 初始化列表的顺序,尽量写成和声明顺序一致(看着也舒服);② 不要让一个成员的初始化依赖另一个成员(顺序这东西太容易记错)。

四、构造函数的执行顺序

一个对象出生时,按这个顺序走:

步骤 做什么
① 分配内存(按类的大小)
② 按声明顺序依次初始化成员(初始化列表里的先办,没提到的用默认方式)
③ 执行构造函数的函数体

看出来了吗:初始化列表比函数体先执行。所以:

Point(int a, int b) : x(a) {
    y = b;              // 函数体在最后
}

成员变量先到位,函数体后干活。

五、什么时候写哪种构造函数

场景 怎么写
想要"默认对象" 写 Point(),或给参数默认值 Point(int a = 0, int b = 0)(二选一,别都写)
成员里有 const / 引用 必须用初始化列表
成员是"复杂类型"(如 string) 优先用初始化列表(省一次赋值)
需要做参数校验 校验放函数体(校验要在值确定之后做才合理)
想阻止"复制" 10.2.2 讲

六、动手 Demo:初始化列表的三种写法对照

#include <iostream>
using namespace std;

class Player {
private:
    const int   id;          // const 成员:只能用初始化列表
    int        &belt;        // 引用成员:只能用初始化列表
    int         hp;
    int         mp;

public:
    // 初始化列表:按声明顺序写(先 id、belt、hp、mp)
    Player(int i, int &b, int h, int m) : id(i), belt(b), hp(h), mp(m) {
        if (hp < 0) hp = 0;          // 函数体:做校验
        if (mp < 0) mp = 0;
    }

    void show(const char *who) {
        cout << who << " → id=" << id
             << " hp=" << hp << " mp=" << mp
             << " belt=" << belt << endl;
    }
};

int main() {
    int myBelt = 99;                 // 外部的一个变量
    Player p1(1001, myBelt, 120, 30);
    p1.show("p1");                   // p1 → id=1001 hp=120 mp=30 belt=99

    Player p2(1002, myBelt, -50, -8);   // 故意给非法值
    p2.show("p2");                   // p2 → id=1002 hp=0 mp=0 belt=99

    myBelt = 200;                    // 改外部变量
    p1.show("p1 再看");               // belt=200 ← 引用跟着变
    return 0;
}

看到什么算成功:

p1 → id=1001 hp=120 mp=30 belt=99
p2 → id=1002 hp=0 mp=0 belt=99
p1 再看 → id=1001 hp=120 mp=30 belt=200

读结果三个要点:

  1. id 是 const、belt 是引用——它们只能靠初始化列表出生。
  2. hp = -50 被构造函数兜成 0——校验放在函数体里,从出生第一刻数据就是合法的。
  3. 最后一行 belt=200——引用成员真的就是外部变量的别名(7.8.1):外部一改,对象里看到的也跟着改。这也是"引用成员要慎用"的原因:它让对象和外部变量绑在了一起。

30 秒实验:把 const int id; 的 const 删掉,再把构造函数的初始化列表 : id(i), belt(b), hp(h), mp(m) 改成 { id = i; belt = b; hp = h; mp = m; }(全部搬进函数体)。你会看到 belt 报错(引用成员没初始化)。把 const 加回去,还会多一条 assignment of read-only member。 两条报错都在提醒你:这两类成员,只认初始化列表。

七、常见误区

  1. "初始化列表和体内赋值差不多,随便用"——对普通 int 差别不大,对 const/引用/string 差别巨大(前者报错,后者多一次赋值)。养成习惯:成员初始值一律写进初始化列表,函数体只留校验和额外逻辑。
  2. "初始化列表写的顺序就是执行顺序"——不是。执行顺序永远按成员的声明顺序。写不一致时,轻则警告、重则拿到垃圾值(第三节的 Bad 类)。
  3. "构造函数里能调用虚函数、能干各种大事"——要小心。构造期间对象的类型还在"半成型"状态,调虚函数不会按你预期的方式跳转(后面学继承时会讲)。本阶段的原则:构造函数只做"把状态设合法",别做正经业务。
  4. "构造函数必须把每个成员都写进初始化列表"——不必。没写的成员会走它自己的默认初始化(对 int 就是垃圾值,对 string 就是空串)。该初始化的别偷懒(尤其 int、double、指针)。
  5. "给所有参数默认值就等于有无数个构造函数"——效果上差不多,但注意二义性:如果同时存在 Point() 和 Point(int a = 0, int b = 0),写 Point p; 编译器就不知道选哪个了。同一个"出生方式",只留一条路。
自测三问(点开对答案) 1. 什么成员**只能**用初始化列表初始化?为什么?——**`const` 成员**(出生后不能改)和**引用成员**(出生后不能改绑)。它们在函数体里赋值已经太晚,会分别报 `assignment of read-only member` 和"引用未初始化"。 2. 成员初始化的**实际顺序**由什么决定?——**由成员在类中的声明顺序决定**,与初始化列表的书写顺序无关。所以**别让一个成员的初值依赖另一个成员**。 3. `Point()` 和 `Point(int a = 0, int b = 0)` 同时存在会怎样?——写 `Point p;` 时**两者都能匹配**,编译器报**二义性**(`ambiguous`)。**同一个出生方式只保留一条路。**

相关笔记