C++ 中的初始化列表和列表初始化
很多初学者容易混淆初始化列表(Member Initializer List)和列表初始化(List Initialization),因为它们的名字很像,但它们实际上解决的是完全不同的问题:
- 初始化列表:解决的是对象生命周期与内存模型的问题(“什么时候赋初值”)
- 列表初始化:解决的是类型系统的统一性与安全性的问题(“用什么语法赋初值”)
初始化列表(Member Initializer List)
它的形式出现在构造函数参数列表之后,函数体大括号之前,以 : 开头。
class A {
int x;
public:
A(int val) : x(val) {} // 初始化列表
};
初始化 vs. 赋值
在 C++ 的对象模型中,“初始化”(Initialization)和 “赋值”(Assignment)是两个截然不同的物理过程。
- 内存分配(Allocation):在栈上或堆上划出一块内存
- 初始化(Initialization):在这块内存上构建对象,使其成为一个合法的实例
- 赋值(Assignment):对象已经存在了,擦出旧值,填入新值。
构造函数的执行时间线:
- 进入构造函数之前:编译器必须确保所有成员变量都已经 “出生”(初始化完成)
- 进入构造函数体(
{...}):这已经是 “出生后” 的世界了,这里面写的代码都是 “赋值” 操作。
为什么要用初始化列表?
如果你不用初始化列表,而是写在函数体内:
class Person {
std::string name;
public:
// 写法 1:在函数体内赋值
Person(const std::string& n) {
name = n;
}
};
底层发生了什么?
- 隐式初始化:在进入
{之前,编译器发现name还没有初始化,于是强行用std::string的默认构造函数。name变成了一个空字符串"" -
赋值操作:进入
{后,执行name = n;。调用std::string的赋值运算符,把刚才那个空字符串的内容丢弃,拷贝n的内容
这就类似于,你先建了一个空房子,然后立即把它拆了重建成你想要的样子,这就是双倍的开销。
正确的写法(初始化列表):
// 写法 2:使用初始化列表
Person(const std::string& n) : name(n) {}
底层发生了什么?
- 直接构造:直接调用
std::string的拷贝构造函数,用n来 “生出”name - 函数体为空,无操作。收益:省去了一次默认构造和一次赋值操作。
必须使用的场景(物理限制)
有些东西必须 “出生时” 就确定,生出来后再改就晚了。这些情况必须使用初始化列表:
-
const成员:常量一旦出生就不能修改(不能赋值) - 引用成员(
&):引用一旦出生必须绑定到一个对象,不能重新绑定(不能赋值) - 没有默认构造函数的类成员:如果成员是一个类,且它没有默认构造函数(必须带参),编译器无法在进入函数体前 “隐式初始化” 它,必须显式指定
初始化的顺序(内存布局决定)
初始化列表中的顺序并不决定初始化的真实顺序!成员变量的初始化顺序严格由它们在类定义(Class Definition)中声明的顺序决定。
class A {
int x;
int y;
public:
// 错误示范!看起来像先算 y,其实 x 会先初始化,次数 y 是垃圾值
A(int val) : y(val), x(y) {}
};
C++ 对象的内存布局在编译器就确定了(x 在前,y 在后)。析构时必须按相反顺序销毁。为了保证构造和析构的对称性(LIFO),构造顺序必须固定,不能随你怎么写列表儿改变。
列表初始化(List Initialization)
它的形式是使用花括号 {}。
int a{0};
std::vector<int> v{1, 2, 3};
统一初始化语义
在 C++11 之前,初始化的语法简直是精神分裂:
int a = 5;
int b(5);
int arr[] = {1, 2};
struct Point p = {1, 2};
C++11 引入 {} 旨在统一初始化(Uniform Inialization):万物皆可 {} 初始化。
底层机制:std::initializer_list
当你用{1, 2, 3, 4} 初始化一个容器(如 std::vector)时,发生了什么?
- 编译器魔法:编译器会在静态存储区悄悄创建一个数组
- 包装:编译器构建一个
std::initializer_list<T>对象。这其实是一个轻量级的 “视图” 或 “胖指针”,它只包含两个指针:一个指向数组开头,一个指向结尾(或长度) - 传递:这个轻量级对象被传给
std::vector的构造函数 - 消费:
vector遍历这个 list,把数据拷贝到自己的内存里
这就是为什么你可以给 std::vector 赛任意数量的初始值。
安全特性:防止窄化转换(Narrowing Conversion)
这是一个类型安全设计。初始化意味着 “建立一个合法的初始状态”。如果数据在初始化过程中丢失了精度,那么这个初始状态本身就是 “不诚实” 的。
int a = 3.14; // C++98: 允许 a 变成 3(截断)
int b{3.14}; // C++11: 编译器报错!禁止数据丢失
{} 语法强制要求:如果源类型的值无法无损地放入目标类型,编译器必须报错。
解决 “最令人头秃的解析”(Most Vexing Parse)
C++ 的语法歧义:
// 你的意图:创建一个对象 a,调用默认构造函数
A a();
编译器的理解:声明了一个函数 a(),它不接受参数,返回类型是 A。
解决办法:使用 {}:
A a{}; // 明确无误,这就是创建对象
因为函数声明不可能用 {} 结尾。
两者的 “撞车”
当一个类既有普通构造函数,又有接受 std::initializer_list 的构造函数时,{} 会极度优先匹配后者。
class Magic {
public:
Magic(int a, int b) {
// 构造函数 1:普通构造
std::cout << "Normal Constructor" << std::endl;
}
Magic(std::initializer_list<int> list) {
// 构造函数 2:列表构造
std::cout << "List Constructor" << std::endl;
}
};
Magic m1(10, 20); // 调用构造函数 1
Magic m2{10, 20}; // 调用构造函数 2(因为 {10, 20} 被视为列表)
如何选择 {} 与 ()?
{} 的写法
直接列表初始化(Direct List Initialization)
- 特征:不带等号
= - 心智模型:我非常确定我要构造这个对象,请直接用这些参数造出来,不许做任何隐式类型转换。
// 1. 普通变量
int x{0}; // x 初始化为 0
double d{3.14}; // d 初始化为 3.14
// 2. 类对象
std::vector<int> v{1, 2, 3}; // 容器包含 1, 2, 3
Person p{"Alice", 20}; // 调用 Person(string, int)
// 3. 堆内存 new
int* ptr = new int{5}; // 申请内存并初始化为 5
// 4. 临时对象(匿名对象)
func(Person{"Bob", 30});
// 5. 基类与成员初始化
Derive(int x, int y) : Base{x}, m_val{y} {}
拷贝列表初始化(Copy List Initialization)
- 特征:带有等号
= - 心智模型:先把右边的花括号转换成目标类型,然后再拷贝(或移动)给左边
// 1. 变量定义
int x = {0};
std::vector<int> v = {1, 2, 3};
// 2. 函数传参(隐式构造)
void foo(std::vector<int> v);
foo({1, 2, 3}); // 编译器隐式地把 {1, 2, 3} 转换成了 vector
// 3. 函数返回值
std::vector<int> bar() {
return {1, 2, 3}; // 隐式构造并返回
}
直接列表初始化和拷贝列表初始化的选择
在绝大多数情况下,由于现代编译器的优化,拷贝列表初始化会被直接优化成为直接列表初始化,这被称为复制省略(Copy Elision)。
但是存在一种例外情况,那就是 explicit 关键字声明的构造函数。
explicit 关键字的影响
如果一个类的构造函数被声明为 explicit(显式),那么第 2 种写法(带等号的会被禁止)。
class Widget {
public:
explicit Widget(int n) {}
};
Widget w1{10}; // OK:直接初始化,显式调用
Widget w2 = {10}; // Error:拷贝列表初始化要求 “隐式转换”,但被 explicit 阻止了
所以我们可以得出结论:Type x{...} 比 Type x = { ... } 更加通用,因为它不受 explicit 的限制,更推荐使用不写等号的写法。
真的应该所有情况都用 {} 吗?
虽然 C++ Core Guidenlines 推荐默认使用 {},但你必须警惕两个巨大的陷阱。这两个陷阱的根源在于编译器对 std::initializer_list 的极度偏爱。
陷阱 1:std::vector 的构造
std::vector<int> v1(10, 5); // 用 ()
std::vector<int> v2{10, 5}; // 用 {}
解析:
-
v1(圆括号):调用vectore(size_type n, const T& val),创建了 10 个元素,每个元素都是 5 -
v2(花括号):编译器看到{},优先查找是否接受std::initializer_list的构造函数,找到了,于是创建了 2 个元素,分别是 10 和 5
当构造函数发生重载时,如果存在接受 std::intitalizer_list 的版本,编译器会贪婪地优先匹配它,哪怕需要进行一些类型提升。只有完全匹配不上了,才会退回去找普通的构造函数。
建议:
- 原则上:默认用
{} - 例外:当在调用容器的大小/数量构造函数时,必须用
(),否则会被误解为 “元素内容”
陷阱 2:auto 类型推导的歧义
这一点在 C++11/14 和 C++17 中表现不同,非常令人困惑。
auto x = {1};
// x 的类型是什么?是 int 吗?
// NO! x 的类型是 std::initializer_list<int>
如果你本意是想要一个 int,结果得到了一个轻量级列表对象,后面的代码可能会出问题。
修正写法(C++17 起):
auto x{1}; // C++17 规定推导为 int
auto y = {1}; // 推导为 std::initializer_list<int>
建议:配合 auto 使用 {} 时要格外小心,最好明确类型,或者确保你真的想要 std::initializer_list。
{} 的特性:默认值初始化(Value Initialization)
这是 {} 最让我觉得 “舒服” 的地方。
如果你想把一个对象清零,或者初始化为 “空状态”:
// 旧写法
int i; // 危险!如果是局部变量,i 的值是随机的(垃圾值)
int* p; // 危险!野指针
// 新写法(空花括号)
int i{}; // 安全!保证初始化为 0
int* p{}; // 安全!保证初始化为 nullptr
double d{}; // 安全!保证初始化为 0.0
空的 {} 触发值初始化(Value Initialization)。对于内置类型(int、float、指针),它会执行零初始化(Zero Initialization)。
Comments