在 C++ 中,“成员初始化列表(Member Initializer List)” 是一种专门用来给类成员赋初值的语法,平时我们也常简称为 “初始化列表”。
前面编写构造函数时,我们都是在函数体的花括号 {} 内部使用赋值语句设置初始值。而使用初始化列表,我们可以在程序进入构造函数体之前,直接在成员变量创建出来的那一刻完成初始化。
C++ 初始化列表的基本用法
在 C++ 中,初始化列表写在构造函数参数列表的小括号 () 之后、函数体的左花括号 { 之前。
语法:
类名(参数列表) : 成员1(初始值1), 成员2(初始值2) {
// 构造函数体
}说明:
初始化列表以一个英文冒号(:)开头,后面紧跟需要初始化的成员变量名,并在小括号 () 或花括号 {} 中填入初始值。如果有多个成员变量需要初始化,不同成员之间使用英文逗号(,)隔开。
刚接触 : name(n) 这种写法时,不少初学者容易分不清小括号内外哪个是成员变量、哪个是函数参数。其实规律非常简单:小括号外面的名字固定是类的成员变量,小括号里面的内容则是要赋给它的初始值。
正因为小括号内外的角色是固定的,哪怕参数名和成员变量名完全相同,写成 : name(name) 编译器也能准确区分,不会产生冲突。等程序跨进花括号 {} 开始执行构造函数体时,初始化列表里的工作就已经全部完成了。
示例 1:使用初始化列表
#include <iostream>
#include <string>
class Student {
private:
std::string name;
int age;
public:
// 使用初始化列表初始化成员
Student(std::string n, int a) : name(n), age(a) {}
void showInfo() {
std::cout << name << ",年龄:" << age << '\n';
}
};
int main() {
Student stu("Jack", 20);
stu.showInfo();
return 0;
}运行结果如下。
Jack,年龄:20分析:
在这个例子中,当我们执行 Student stu("Jack", 20); 创建对象时,形参 n 接收到 "Jack",形参 a 接收到 20。随后初始化列表直接拿 "Jack" 初始化了 name,拿 20 初始化了 age。
这里构造函数的花括号 {} 之所以为空,是因为所有成员都已经在初始化列表中完成了初始化。如果还需要打印提示信息或检查数据合法性,我们依然可以把代码写在 {} 里面。
提示 如果某个成员变量既写了类内默认初始值,又出现在了构造函数的初始化列表中,创建对象时会以初始化列表指定的值为准。只有未写进初始化列表的其他成员,才会继续使用各自的类内默认初始值。
初始化列表与函数体内赋值的区别
在初始化列表里写 : name(n),和在花括号里写 name = n;,最后都能把值放进去,那这两种写法到底有什么区别呢?我们可以先把这 2 种方式放在一起对比一下。
// 方式 1(推荐)
Student(std::string n, int a) : name(n), age(a) {}
// 方式 2
Student(std::string n, int a) {
name = n;
age = a;
}在 C++ 创建对象时,类的所有成员变量在程序进入构造函数的左花括号 { 之前,就必须全部创建完毕。因此,上面这两种方式在底层的执行过程存在明显区别:
- 方式 1(初始化列表):属于 “一步直接初始化”。在创建
name成员变量的那一刻,直接拿参数n的内容把它构造出来,一步到位。 - 方式 2(函数体内赋值):属于 “先默认初始化,再重新赋值”。以
std::string类型的name为例,进入左花括号{之前,name已经先被默认初始化成了空字符串""。等进入函数体执行name = n;时,才把新内容赋值进去覆盖掉原来的空字符串。
简单来说,进入左花括号 { 之前是 “造出成员变量并赋初值” 的阶段,进入 { 之后则是 “对已有成员重新赋值” 的阶段。对于 std::string 这类对象成员,方式 1 省去了一次多余的默认初始化,因此在实际开发中我们更推荐使用方式 1。
必须使用初始化列表的情况
对于普通的 int 或 std::string 成员,就算不使用初始化列表也能编译通过。但在 C++ 中,有以下 3 种特殊的成员,语法上要求必须在创建的那一刻完成初始化,不能等进入函数体 {} 以后再赋值:
- const 成员变量:常量一旦创建完成就不能再修改,必须在初始化列表中直接给出初始值。
- 引用类型成员:引用在定义时必须绑定到一个已有的变量上,不能先悬空再绑定。
- 没有默认构造函数的成员对象:如果类中包含另一个类的对象作为成员,且那个类没有无参默认构造函数,就必须在初始化列表中显式传参。
1. const 成员与引用成员
无论是 const 常量还是引用,都必须在定义的同时完成初始化。由于程序进入构造函数的左花括号 { 时,类的所有成员变量都已经创建出来了,因此这两类成员只能通过初始化列表来完成初始化。下面我们来看一个例子。
示例 2:初始化 const 与引用成员
#include <iostream>
class Student {
private:
const int id; // const 成员变量
int& scoreRef; // 引用类型成员
public:
// 使用初始化列表初始化
Student(int stuId, int& sc) : id(stuId), scoreRef(sc) {}
void showInfo() {
std::cout << id << " 号,成绩:" << scoreRef << '\n';
}
};
int main() {
int score = 95;
Student stu(1001, score);
stu.showInfo();
score = 98; // 修改外部变量 score
stu.showInfo();
return 0;
}运行结果如下。
1001 号,成绩:95
1001 号,成绩:98分析:
初始化列表 : id(stuId), scoreRef(sc) 在创建 stu 对象的同时完成了两件事:使用 1001 初始化常量成员 id,并让引用成员 scoreRef 绑定到外部变量 score 上。
正因为 scoreRef 是外部变量 score 的别名,后面我们在 main() 函数中把 score 改成 98 之后,再次调用 stu.showInfo() 输出的成绩也跟着变成了 98。
如果不使用初始化列表,而是像下面这样把 const 成员和引用成员放到构造函数的花括号内部去赋值,编译时就会直接报错。
Student(int stuId, int& sc) {
id = stuId;
scoreRef = sc;
}在上面这种错误写法中,进入左花括号 { 之前,id 和 scoreRef 因为没有指定初始值就已经报错了。进入函数体后执行 id = stuId;,还会因为尝试修改常量而再次报错。
2. 没有默认构造函数的成员对象
在实际开发中,一个类的成员变量除了可以是 int、std::string,还可以是另一个类的对象。比如汽车类 Car 内部包含一个发动机类 Engine 的对象,这种把一个类的对象作为另一个类成员变量的方式称为 “对象组合”,里面的对象称为 “成员对象”。
上一节我们讲过,一旦类中手写了带参构造函数,编译器就不再自动生成无参默认构造函数。如果成员对象 Engine 只定义了带参构造函数,外层类 Car 创建时就必须在初始化列表中写出 成员对象名(实参),把初始数据传给它的构造函数。请看下面例子。
示例 3:初始化成员对象
#include <iostream>
#include <string>
class Engine {
private:
std::string model;
public:
// 只有带参构造函数
Engine(std::string m) : model(m) {}
void start() {
std::cout << model << " 发动机已启动\n";
}
};
class Car {
private:
std::string brand;
Engine engine; // 成员对象
public:
// 初始化成员对象 engine
Car(std::string b, std::string m) : brand(b), engine(m) {}
void drive() {
std::cout << brand << " 准备出发,";
engine.start();
}
};
int main() {
Car myCar("Audi", "V8");
myCar.drive();
return 0;
}运行结果如下。
Audi 准备出发,V8 发动机已启动分析:
由于 Engine 类只定义了带参构造函数,没有无参默认构造函数。如果 Car 的构造函数不写 : engine(m),编译器在创建 Car 对象时不知道应该传什么参数去构造内部的 engine,就会直接编译报错。
写上 : brand(b), engine(m) 之后就清楚了。当我们执行 Car myCar("Audi", "V8"); 时,"Audi" 用来初始化汽车品牌 brand,"V8" 则会传给 Engine 的构造函数来初始化成员对象 engine。
成员变量的初始化顺序
在使用初始化列表时,还有一条小伙伴们容易忽略的规则:成员变量真正的初始化顺序,只由它们在类中 “声明的先后顺序” 决定,而与初始化列表中谁写在前面、谁写在后面无关。
为什么 C++ 要这样规定呢?因为一个类可以重载多个构造函数,不同构造函数的初始化列表顺序可能写得不一样。为了保证对象销毁时析构函数始终能按照统一的相反顺序清理成员,编译器会固定按照成员变量在类中的声明顺序来初始化。下面我们通过一个例子来验证一下。
示例 4:成员变量的初始化顺序
#include <iostream>
#include <string>
class Part {
public:
Part(std::string name) {
std::cout << "正在初始化:" << name << '\n';
}
};
class Demo {
private:
Part first; // 第 1 个声明
Part second; // 第 2 个声明
public:
// 故意把 second 写在前面
Demo() : second("second"), first("first") {}
};
int main() {
Demo demo;
return 0;
}运行结果如下。
正在初始化:first
正在初始化:second分析:
从运行结果可以看出,虽然 Demo() 在初始化列表里故意把 second 写在了前面,程序却依然先输出了 first、后输出了 second。这是因为在 Demo 类内部,first 声明在 second 前面,编译器始终按照类内声明的顺序来初始化。
这条规则大家一定要留意。如果我们 “用一个成员去初始化另一个成员”,顺序写反就会引发隐蔽的逻辑错误,请看下面的对比。
class Box {
private:
int a; // 第 1 个声明
int b; // 第 2 个声明
public:
// 正确方式
Box(int val) : a(val), b(a) {}
// 错误方式
Box(int val) : b(val), a(b) {}
};在正确方式中,a 先被初始化为 val,随后再用 a 初始化 b。而在错误方式中,因为 a 声明在 b 前面,编译器依然会先执行 a(b)。此时 b 还没有初始化,结果导致 a 读出了随机的垃圾值。
注意 在实际开发中,我们编写初始化列表时,建议始终按照成员变量在类中的声明顺序来排列,并且不要用后面声明的成员去初始化前面声明的成员。
