构造函数有几条必须记牢的规则:名字=类名、无返回类型、可以重载、可以带默认参数;用"初始化列表"比在函数体里赋值更直接(尤其对 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
读结果三个要点:
id是const、belt是引用——它们只能靠初始化列表出生。hp = -50被构造函数兜成 0——校验放在函数体里,从出生第一刻数据就是合法的。- 最后一行
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。 两条报错都在提醒你:这两类成员,只认初始化列表。
七、常见误区
- "初始化列表和体内赋值差不多,随便用"——对普通
int差别不大,对const/引用/string差别巨大(前者报错,后者多一次赋值)。养成习惯:成员初始值一律写进初始化列表,函数体只留校验和额外逻辑。 - "初始化列表写的顺序就是执行顺序"——不是。执行顺序永远按成员的声明顺序。写不一致时,轻则警告、重则拿到垃圾值(第三节的
Bad类)。 - "构造函数里能调用虚函数、能干各种大事"——要小心。构造期间对象的类型还在"半成型"状态,调虚函数不会按你预期的方式跳转(后面学继承时会讲)。本阶段的原则:构造函数只做"把状态设合法",别做正经业务。
- "构造函数必须把每个成员都写进初始化列表"——不必。没写的成员会走它自己的默认初始化(对
int就是垃圾值,对string就是空串)。该初始化的别偷懒(尤其int、double、指针)。 - "给所有参数默认值就等于有无数个构造函数"——效果上差不多,但注意二义性:如果同时存在
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`)。**同一个出生方式只保留一条路。**- ☐ 10.2.2 复制构造函数(下一节)