C语言中初始化列表和列表初始化如何改成?

摘要:深入理解 C++ 中初始化列表与列表初始化的区别:前者解决对象生命周期与内存模型问题,后者统一初始化语法并提供类型安全保障。
目录初始化列表(Member Initializer List)初始化 vs. 赋值为什么要用初始化列表?必须使用的场景(物理限制)初始化的顺序(内存布局决定)列表初始化(List Initialization)统一初始化语义底层机制:std::initializer_list安全特性:防止窄化转换(Narrowing Conversion)解决 “最令人头秃的解析”(Most Vexing Parse)两者的 “撞车”如何选择 {} 与 ()?{} 的写法直接列表初始化(Direct List Initialization)拷贝列表初始化(Copy List Initialization)直接列表初始化和拷贝列表初始化的选择explicit 关键字的影响真的应该所有情况都用 {} 吗?陷阱 1:std::vector 的构造陷阱 2:auto 类型推导的歧义{} 的特性:默认值初始化(Value Initialization) 本文首发于我的个人博客:Better Mistakes 版权声明:本文为原创文章,转载请附上原文出处链接及本声明。 由于技术迭代较快,文章内容可能随时更新(含勘误及补充)。为了确保您看到的是最新版本,并获得更好的代码阅读体验,请访问: 🍭 原文链接:https://bfmhno3.github.io/note/initialization-in-cpp/ 很多初学者容易混淆初始化列表(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)。 📢 写在最后 如果你觉得这篇文章对你有帮助,欢迎到我的个人博客 Better Mistakes 逛逛。 在那里我归档了更多高质量的技术文章,也欢迎通过 RSS 订阅我的最新动态!