前言:
本文将继续深入探讨类与对象的进阶特性,在前文介绍的构造函数、拷贝构造函数、析构函数和操作符重载基础上,重点讲解初始化列表
一、构造函数初始化列表
在 C++ 中,构造函数初始化列表是一种在构造函数体执行之前,对类成员变量进行初始化的机制。
二、语法格式
初始化列表位于构造函数的参数列表之后,函数体的大括号之前,以冒号:开头,成员之间用逗号,分隔。
语法形式:
构造函数 (函数参数1,函数参数2) : 成员1(参数1), 成员2(参数2) { ... }
代码示例:
class MyClass { public: // 语法:构造函数(参数) : 成员1(值), 成员2(值) { ... } MyClass(int x, double y) : a(x), b(y) { // 此时 a 和 b 已经被初始化了 } private: int a; double b; };注意事项:每个成员变量在初始化列表中只能出现⼀次,语法理解上初始化列表可以认为是每个成员变量定义初始化的地⽅。
三、核心区别:初始化与赋值
掌握初始化列表的核心在于明确"初始化"与"赋值"的区别。
在使用初始化列表之前,构造函数中对成员变量的操作实际上是赋值,而初始化列表才是真正的初始化过程。
场景 A:构造函数体内赋值
class MyClass { public: //你以为你没写初始化列表 MyClass(string s) { _name = s; } // 编译器实际上看到的是: MyClass(string s): _name() { _name = s; } private: string _name; };时间轴发生的事情:
初始化阶段(隐式): 编译器发现你没显示在列表里写_name(s),编译器也会生成隐式列表_name(),然后它悄悄调用 string 的默认构造函数。
此时: _name 已经诞生了,它是一个空字符串 ""。
进入函数体 { : 开始执行用户代码。
赋值阶段: 执行 _name = s; 调用 string 的赋值运算符。
此时: 把刚才那个空字符串的内容清掉,换成 s 的内容。
总结: 先生出一个“空壳”,然后再往里“填充”。
场景 B:初始化列表
class MyClass { public: MyClass(string s) :_name(s) {} private: string _name; };时间轴发生的事情:
初始化阶段(显式): 编译器看到列表里有 _name(s),直接调用 string 的拷贝构造函数。
此时: _name 在诞生的那一刻,就直接拥有了 s 的值。
进入函数体 { : 执行用户代码 (该段代码为空)。
总结: 出生即完美,一步到位。
验证上述逻辑:
#include <iostream> using namespace std; // 1. 定义一个用于测试的类 class MyString { public: // A. 默认构造函数 MyString() { cout << " [底层] 默认构造 (创建空对象)" << endl; } // B. 拷贝构造函数 // 当你用一个已有的对象去创建一个新对象时,调用这个 MyString(const MyString& other) { cout << " [底层] 拷贝构造函数被调用 (拷贝了一个新对象) " << endl; } // C. 赋值运算符 // 当你把一个对象的值改写给另一个已存在的对象时,调用这个 MyString& operator=(const MyString& other) { cout << " [底层] 赋值运算符被调用 (覆盖旧值)" << endl; return *this; } }; // 2. 使用初始化列表的类 class InitListTester { MyString m_str; public: // 这里的 : m_str(s) 就是在让 m_str 出生 // 我们传入 const MyString& s 避免传参时产生额外的拷贝干扰 InitListTester(const MyString& s) : m_str(s) { cout << "--- 进入 InitListTester 构造函数体 ---" << endl; } }; // 3. 使用函数体内赋值的类 class AssignmentTester { MyString m_str; public: AssignmentTester(const MyString& s) { cout << "--- 进入 AssignmentTester 构造函数体 ---" << endl; m_str = s; // 这里是赋值 } }; int main() { // 先准备一个源对象 cout << "=== 准备工作:创建一个源对象 source ===" << endl; MyString source; cout << "\n=== 验证 1:初始化列表 ===" << endl; // 预测:这里应该直接调用拷贝构造,不会有默认构造,也不会有赋值 InitListTester t1(source); cout << "\n=== 验证 2:函数体内赋值 ===" << endl; // 预测:先默认构造,进函数体后,再赋值 AssignmentTester t2(source); return 0; }打印结果如下:
第一部分:初始化列表
=== 验证 1:初始化列表 (Init List) ===
[底层] 拷贝构造函数被调用 (拷贝了一个新对象)
--- 进入 InitListTester 构造函数体 ---注意:它没有打印“默认构造”,也没有打印“赋值运算符”。
这证明了:m_str(s) 这一行代码,直接利用 s 为蓝本,通过拷贝构造函数生出了 m_str。
第二部分:函数体内赋值
=== 验证 2:函数体内赋值 ===
[底层] 默认构造 (创建空对象)
--- 进入 AssignmentTester 构造函数体 ---
[底层] 赋值运算符被调用 (覆盖旧值)注意:先调用了“默认构造”(因为成员必须先存在) -> 然后进入函数体 -> 最后才调用“赋值运算符”。
这证明了:编译器发现你没在列表里写 m_str(s),编译器也会生成隐式列表m_str(),它会悄悄调用 MyString 的默认构造函数,然后再执行赋值运算符操作。
小结:无论是否显式声明初始化列表,每个构造函数都包含初始化过程。
四、必须用初始化列表的成员
首先建立一个核心概念,C++ 对象的生命周期时间轴。
时间轴:
A、内存分配
B、初始化列表阶段,这是成员变量“出生”的时刻
C.、构造函数体阶段,这是对成员变量进行“修改/赋值”的时刻
在 C++ 中,初始化 和 赋值 是两个完全不同的步骤
1.成员变量通过初始化列表进行默认初始化,这个过程(初始化列表进行默认初始化)发生在对象内存分配之后、构造函数体执行之前。
2.当程序执行到构造函数体 { ... } 时,实际上是在进行赋值操作,如果成员变量进入了构造函数体,说明它已经完成了默认初始化过程。
4.1 引用成员
因为引用不能为空,所以对于引用成员而言必须在定义时绑定一个对象,且一旦绑定不可更改(即不能重新指向别的对象),
如果不在初始化列表中绑定,进入函数体时引用就是“未绑定”状态,这是违法的。
错误演示:在构造函数体内赋值
class Referencer { private: int& m_ref; // 引用成员 public: Referencer(int& target) { // 错误! // 此时 m_ref 已经“出生”了,但没有绑定对象。 // 下面这行代码实际上是“赋值”,而不是“初始化”。 m_ref = target; } }; // 报错信息通常为:error C2530: “Referencer::m_ref”: 必须初始化引用正确演示:使用初始化列表
class Referencer { public: // 正确!在 m_ref “出生”的那一刻,直接将其绑定到 target Referencer(int& target) : m_ref(target) { // 函数体可以是空的 } private: int& m_ref; };4.2 const 成员变量
const 意味着“只读”,它的值必须在创建时确定,之后不能被修改。
如果在构造函数体内赋值,实际上是在试图修改一个已经初始化过的常量,这是违法的。
错误演示:在构造函数体内赋值
class ConstHolder { private: const int m_val; // 常量成员 public: ConstHolder(int x) { // 错误! // 此时 m_val 已经“出生”了(通常会被初始化为随机垃圾值),且属性为“不可修改”。 // 下面这行试图修改一个只读变量。 m_val = x; } }; // 报错信息通常为:error C2789: “ConstHolder::m_val”: 必须初始化常量限定类型的对象正确演示:使用初始化列表
class ConstHolder { private: const int m_val; public: // 正确!在 m_val “出生”的同时赋予初值 ConstHolder(int x) : m_val(x) { //函数体为空 } };4.3 没有默认构造函数的类类型变量
这一点最容易让人产生困惑,当类 B 包含类 A 的对象成员时,在创建 B 的实例时,编译器会优先自动创建 A 的成员对象。
如果 B 的初始化列表中没有指定如何初始化 A,编译器会默认调用 A 的无参构造函数,此时若 A 未定义无参构造函数,就会导致编译错误。
错误演示:在初始化列表中没有指定如何初始化 Engine,且Engine未定义无参构造函数,编译器无法调用导致编译错误。
class Engine { public: // 只有带参构造,没有 Engine() 默认构造 Engine(int power) :_power(power) { } private: int _power; }; class Car { private: Engine m_engine; // Car 包含 Engine public: Car(int p) :m_engine(p) {} };正确演示:显式调用构造函数
#include <iostream> using namespace std; class Engine { public: // 只有带参构造,没有 Engine() 默认构造 Engine(int power) :_power(power) { } private: int _power; }; class Car { private: Engine m_engine; // Car 包含 Engine public: //在初始化列表,显示指定初始化方式 Car(int p) :m_engine(p) {} };实战演示:
#include <iostream> using namespace std; class Engine { public: // 只有带参构造,没有 Engine() 默认构造 Engine(int power) :_power(power) { } private: int _power; }; class SuperCar { private: int& m_refSpeed; // 1. 引用 const int m_maxSpeed; // 2. const Engine m_engine; // 3. 无默认构造的类成员 public: // 初始化列表必须同时处理这三个刺头 SuperCar(int& speedMetric, int maxS, int power) : m_refSpeed(speedMetric) // 绑定引用 ,m_maxSpeed(maxS) // 初始化 const ,m_engine(power) // 初始化类成员 {} }; int main() { int currentSpeed = 0; // 实例化 SuperCar myCar(currentSpeed, 300, 500); return 0; }五、类内成员初始化
C++11 允许在声明成员变量时直接指定默认值,这些默认值主要用于未被显式列入初始化列表的成员变量。
它的核心作用就是为成员变量提供一个“保底值”(备胎)。
使用条件:如果构造函数在冒号后面显式提到了这个变量,那么类内写的那个缺省值就会被直接忽略,只有当构造函数没提这个变量时,编译器才会去用那个缺省值。
代码实测:
#include <iostream> using namespace std; class Settings { public: // 构造函数 1:什么都不写 // 结果:_v1 和 _v2 都会使用上面的缺省值 Settings() { // 此时 _v1 = 50, _v2 = 80 } // 构造函数 2:只初始化 _v1 // 结果:_v1 使用参数 a,_v2 继续使用缺省值 80 Settings(int a) : _v1(a) { // 此时 _v1 = a, _v2 = 80 } // 构造函数 3:全部覆盖 // 结果:两个缺省值都被忽略 Settings(int a, int b) : _v1(a), _v2(b) { // 此时 _v1 = a, _v2 = b } void print() { cout << "_v1: " << _v1 <<" " << "_v2: " << _v2 << endl; } private: //成员变量进行声明 //在这里给缺省值 int _v1 = 50; int _v2 = 80; }; int main() { Settings s1; // 输出: _v1: 50 _v2: 80 Settings s2(10); // 输出: _v1: 10 _v2: 80 Settings s3(10, 20);// 输出: _v1: 10 _v2: 20 s1.print(); s2.print(); s3.print(); return 0; }温馨提示:如果函数参数上带有缺省值,效果与上述一致,如果构造函数在冒号后面显式提到了这个变量,那么类内写的那个缺省值就会被直接忽略,用显示的初始化方式进行初始化,只有当构造函数没提这个变量时,编译器才会去用那个缺省值。
代码示例:尽管函数参数带有缺省值,但是初始化列表没有显示初始化方式,编译器只会去用成员变量声明处的缺省值。
#include <iostream> using namespace std; class Settings { public: // 函数参数带有缺省值,初始化列表没有显示初始化方式 // 结果:_v1 和 _v2 都会使用成员变量的缺省值 Settings(int a=10,int b=20) { // 此时 _v1 = 50, _v2 = 80 } void print() { cout << "_v1: " << _v1 << " " << "_v2: " << _v2 << endl; } private: //成员变量进行声明 //在这里给缺省值 int _v1 = 50; int _v2 = 80; }; int main() { Settings s1; // 输出: _v1: 50 _v2: 80 s1.print(); return 0; }引入该特性的优势:在 C++11 之前,若存在多个构造函数且某个成员变量(如 _init)需要在所有构造函数中初始化为 0,我们不得不在每个构造函数中重复编写 : _init(0)。
C++98 的痛苦写法:
class OldStyle { int x; int y; public: // 必须重复写 : x(0), y(0) OldStyle() : x(0), y(0) {} //x=0 y=0 OldStyle(int a) : x(a), y(0) {} //x=a,y=0 OldStyle(int a, int b) : x(a), y(b) {} //x=a,y=b };C++11 的优雅写法:
class NewStyle { int x = 0; // 写一次,到处通用 int y = 0; public: NewStyle() {} // x=0 y=0 NewStyle(int a) : x(a) {} // x=a y=0 NewStyle(int a, int b) : x(a), y(b) {} //x=a y=b };六、初始化顺序
类成员变量的初始化顺序仅取决于它们在类中的声明顺序,与初始化列表中的排列顺序无关,建议将初始化列表的顺序与成员变量的声明顺序保持一致。
代码示例:我们想把x存给m_b,然后把m_b的值赋给m_a。
#include <iostream> class Trap { public: // 初始化列表:故意把 m_b 写在前面 // 你的意图:先 m_b = x,然后 m_a = m_b Trap(int x) : m_b(x), m_a(m_b) { cout << "m_a = " << m_a <<endl; cout << "m_b = " << m_b <<endl; } private: // 声明顺序:m_a 先声明,m_b 后声明 int m_a; int m_b; }; int main() { Trap t(10); return 0; }打印结果如下:
编译器实际生成的执行步骤如下:
①第一步:初始化 m_a
编译器看一眼声明,发现 m_a 排第一。
它去看初始化列表,找到了 : m_a(m_b)。
灾难发生: 此时 m_b 还没有被初始化!它里面是内存里的随机垃圾值。
结果:m_a 被初始化为垃圾值。
②第二步:初始化 m_b
编译器发现 m_b 排第二。
它去看初始化列表,找到了 : m_b(x)。
结果:m_b 被正确初始化为 10。
③第三步:进入构造函数体
打印 m_a (垃圾值) 和 m_b (10)。
总结:这就充分的证明了类成员变量的初始化顺序仅取决于它们在类中的声明顺序,与初始化列表中的排列顺序无关。
修改后的安全代码:
class Safe { public: // 安全写法:两个都直接用参数 x 初始化,互不依赖 Safe(int x) : m_a(x), m_b(x) {} private: int m_a; int m_b; };为什么要这样设计?
这是为了保证析构顺序的确定性,在 C++ 中,对象的析构顺序必须严格是构造顺序的逆序。
如果不按声明顺序构造,而是按列表顺序构造:
程序员 A 写了 Class(x) : a(x), b(x) {}
程序员 B 写了 Class(x) : b(x), a(x) {}
同一个类,竟然会有两种不同的构造顺序,那析构函数该按什么顺序销毁成员呢,这会导致混乱。
因此,C++ 规定:声明顺序是唯一的真理,这样析构函数就可以无脑地按照“声明顺序的逆序”来清理资源。
七、初始化列表总结
关于初始化列表的要点总结:
①所有构造函数都包含初始化列表,无论是否显式声明;
②每个成员变量都会通过初始化列表进行初始化,不论是否在列表中显式指定。
核心观念:初始化列表不是“选修课”,而是成员变量出生的“必经之路”。
简单理解:无论你写不写冒号:,无论你写不写初始化列表,每一个成员变量在进入构造函数体{}之前,都必须在初始化列表这个阶段完成初始化。
我们可以把这个过程想象成一个“三级过筛”的决策流程图。
假设编译器正在初始化成员变量m_var,流程如下:
第一关:查看初始化列表
问: 构造函数的冒号后面有没有写
: m_var(x)?是: 停止检查,直接使用 x 初始化(这是最高优先级)。
否: 进入第二关。
第二关:查看类内声明缺省值
问: 在
class定义里有没有写Type m_var = y;?是: 停止检查,使用缺省值 y 初始化。
否: 进入第三关。
第三关:兜底处理(最危险的阶段)
问:
m_var是什么类型?情况 A:自定义类型(类对象)
调用它的默认构造函数
Type()。风险提示:如果该类型没有默认构造函数,编译报错。
情况 B:内置类型(int, double, 指针等)
不处理。它里面的值是内存里残留的随机垃圾值。
风险提示:这是 C++ 中无数莫名其妙 Bug 的根源
代码示例:
#include <iostream> using namespace std; class Inner { public: Inner() { cout << "Inner: 默认构造" << endl; } Inner(int x) { cout << "Inner: 带参构造 " << x <<endl; } }; class MyClass { public: // ------ 构造函数 ------ MyClass(int val) : m_explicit(val) { // 此时,所有成员都已经处理完毕! cout << "MyClass 构造函数体开始执行..."<<endl; cout << "m_explicit: " << m_explicit << " (使用了列表值)"<<endl; cout << "m_default: " << m_default << " (使用了缺省值)"<<endl; cout << "m_garbage: " << m_garbage << " (未定义,可能是乱码)"<<endl; } private: // ------ 成员声明区域 ------ // 1. 有缺省值,但在列表里被覆盖 int m_explicit = 100; // 2. 有缺省值,没在列表里,将使用缺省值 int m_default = 200; // 3. 自定义类型,没缺省值,没在列表 -> 调默认构造 Inner m_obj; // 4. 内置类型,没缺省值,没在列表 -> 【危险】随机值 int m_garbage; }; int main() { MyClass c(999); return 0; }打印结果如下所示:
既然看到这里了,不妨关注+点赞+收藏,感谢大家,若有问题请指正。