写C代码这么多年有个问题我几乎每次聊构造函数都会被人问起成员变量到底是放在初始化列表里写好还是直接在大括号里赋值好很多新手甚至干了两三年的开发者都说不清这两者的本质区别。借今天这篇我打算把构造初始化列表这件事一次讲透包括它解决了什么问题、藏了哪些坑、为什么有些人说“初始化列表比赋值更高效”以及在实际项目里到底该怎么用。先说个结论放在开头对象从底层内存到真正成为一个可用的对象中间要经历一个固定的“出生”过程。构造函数的大括号只是这个过程的最后一步真正决定成员变量初值走向的是初始化列表。理解了这一点很多模棱两可的用法自然就通了。1. 先把“出生”的流程捋清楚1.1 对象的一生从哪一刻开始很多人会把“构造函数被调用”等同于“对象被创建”。其实这个理解不精确。一个对象的生命周期大致分三件事分配内存、在内存上构造成员、执行构造函数体。分配内存这一步不用你操心编译器会搞定真正值得关注的是后面两步。成员变量的初始化发生在构造函数体执行之前。也就是说当你写下ClassName::ClassName()的大括号准备在函数体里给成员赋值时所有成员其实已经“出生”过一次了。如果你走的是初始化列表那成员在列表这一步就被赋予了初值如果你没走成员就会走默认初始化——内置类型比如int、double会得到一个不确定值注意不是0类类型成员则会调用它的默认构造函数。这就引出一个很多人没意识到的点在函数体内写this-a 10本质上是先让成员a被初始化成一个垃圾值然后再把10赋值给它。而如果写在初始化列表里成员a是被直接初始化为10没有中间那个垃圾值状态。差别就在这一步。1.2 用生活里的例子理解“初始化”和“赋值”假如你搬新家有两种入住方式。第一种进门的时候开发商已经把家具按设计图纸摆好了你只需要做微调这是初始化第二种进门的时候房间是空的家具需要你一件件搬进去自己组装这是赋值。初始化列表相当于第一种方式——对象在“交房验收”那一刻所有成员就按你给的参数一次性就位。函数体赋值相当于第二种方式——成员先以默认状态甚至是不确定状态存在你再一个一个改。多数时候第二种也能凑合用但存在几个绕不开的场景比如有些成员是“只读”的const有些是“没有钥匙”的没有默认构造函数这时候第二种方式根本走不通。所以框架层面的认知很重要构造函数体内做的是“修改”初始化列表做的才是“出生”。2. 为什么有些成员必须放进初始化列表2.1 const 成员和引用成员没有“改”的机会const成员变量在对象整个生命周期里只能被初始化一次不能被赋值。这个“一次”的时机只存在于初始化列表里。如果你在构造函数体内写this-constVal 10编译器会直接报错原因很直接const变量不允许赋值操作。引用成员T也一样引用在出生时必须绑定到一个对象一旦绑定就不能换绑。你没法在函数体里给一个引用成员做默认初始化之后再“重新绑定”因为在函数体执行之前引用就已经被初始化了——如果没绑到东西程序很可能已经崩了。举个最简单的代码class Demo { public: Demo(int x) : ref_(x), fixed_(42) {} private: int ref_; const int fixed_; };这种写法是唯一能正确初始化ref_和fixed_的方式。如果你想在构造函数体内给它们赋值编译都过不了。2.2 没有默认构造函数的类类型成员这类成员是“必须进初始化列表”的最典型场景。假设你有个成员类型Logger它只有一个带参数的构造函数没有默认构造函数class Logger { public: explicit Logger(const std::string tag) : tag_(tag) {} private: std::string tag_; };那任何包含Logger成员的对象在初始化阶段都必须给这个Logger提供构造参数。你唯一能指定这些参数的地方就是初始化列表。如果只在函数体内赋值编译会报错因为编译器在函数体执行之前尝试调用Logger的默认构造函数找不到就挂了。这个问题的本质是编译器在对象进入构造函数体之前必须把每个成员都“初始化”出来。对类类型成员来说这就是“要么默认构造要么在初始化列表里构造”。没有第三种情况。明白这个你就明白了为什么很多类设计会强制要求初始化列表——不是风格问题是技术上的硬约束。2.3 非静态成员变量的完整优先级规则在C11之后类内初始值也叫默认成员初始化器给成员初始化增加了另一条路如果在初始化列表里显式给出参数用初始化列表如果没写再看有没有类内初始值用类内初始值如果都没有内置类型走不确定值初始化类类型走默认构造。也就是说默认成员初始化器不跟初始化列表冲突。初始化列表的优先级最高而且它是“出生时刻”指定的初值类内初始值更像是“兜底方案”。这里有个容易混淆的细节类内初始值同样发生在出生阶段不是赋值。它解决的是“多个构造函数都想给成员设同样的初值不想重复写”的代码重复问题后面我会专门展开。3. 初始化顺序最容易踩的隐藏陷阱3.1 顺序由声明决定不是由列表决定这是初始化列表里最反直觉、也最容易出bug的点成员变量的初始化顺序由它们在类定义中的声明顺序决定跟初始化列表里写的顺序没有关系。很多新手会以为列表里谁写在前面谁就先初始化。大错特错。编译器会无视你在列表里的书写顺序严格按照成员声明顺序来一个个初始化。你以为a先于b初始化只要把a写在列表前面就行——这通常碰巧成立直到哪天你把声明顺序改了就踩雷。来看这个经典例子class BadExample { public: BadExample(int v) : b(v), a(b) {} // 看起来a用b很合理 int a; int b; };按声明顺序a在b之前定义所以实际初始化顺序是先a后b。可你在列表里偏偏用b去初始化a。这时的b还没出生拿到的是什么是未初始化的垃圾值。于是a被一个随机数填充了等b被初始化成v一切都已经晚了。如果你把声明顺序改成int b; int a;再跑一遍结果又对了。这种问题非常刁钻出bug的时候一脸懵因为代码看起来格外合理——变量写在前面用的值写在后面。3.2 编译器的warning是你最好的朋友现代的编译器和静态分析工具普遍能识别这种“成员初始化顺序与声明顺序不一致”的情况。以gcc和clang为例开启-Wall或-Wextra后编译器会给出类似“will be initialized after”的警告。我的建议很简单永远打开这类警告并且把它当成错误对待。对于顺序敏感的代码宁可把初始化列表的顺序完全按照声明顺序写也不要在列表里为了“美观”或“逻辑”乱排。更稳妥的做法是让初始化列表和成员声明的顺序尽量一致这样无论从哪个角度看都不会出问题。这里还有个我踩过坑的真实场景某次重构老代码我调整了一个类的成员声明顺序目的是让内存布局更紧凑。结果有一个初始化列表是按原声明顺序写的重构后好几个成员的值全乱了。排查了整整一个下午最后发现就是初始化顺序跟着声明顺序变了。从那以后我提交代码前只要看到初始化列表和声明顺序不一致就会下意识先改列表让它跟上声明顺序。3.3 自包含设计可以绕开顺序问题如果成员之间的初始化存在依赖关系最安全的解法其实不是依赖初始化列表的顺序而是让被依赖的数据变成独立的函数或静态工具。比如不要在A的初始化里依赖B的初值而是把那个初值通过参数显式传进构造函数。这样时序就完全透明了。实在要依赖就采用“先声明被依赖者后声明依赖者”的顺序。比如B需要被A使用那就先声明B再声明A同时初始化列表按这个顺序写。这样一来即使哪天疏忽编译器警告也能及时提醒你。4. 继承、委托与初始化列表的深层用法4.1 派生类如何初始化基类继承关系下的初始化列表规则是首先初始化基类然后初始化派生类自身的成员。如果你在派生类的构造函数里没写基类的初始化那么编译器就尝试调用基类的默认构造函数如果基类没有默认构造函数编译就过不了。正确写法是把基类构造参数放进派生类的初始化列表里并且基类部分必须写在最前面。例如class Base { public: Base(int id) : id_(id) {} private: int id_; }; class Derived : public Base { public: Derived(int id, std::string name) : Base(id), name_(std::move(name)) {} private: std::string name_; };这里有个细节值得注意基类成员的初始化发生在派生类成员之前这是标准规定的。所以即使派生类成员和基类部分没有依赖关系你也不应该想当然地在基类初始化之前做任何派生类成员相关的操作——那在语法上都不可行。4.2 委托构造函数让代码更干净C11引入了委托构造函数允许一个构造函数把初始化工作交给同类的另一个构造函数。这在处理“多个构造函数有公共初始化逻辑”时非常有用class HttpRequest { public: HttpRequest() : HttpRequest(GET) {} // 委托 HttpRequest(const std::string method) : method_(method), timeout_(3000) {} private: std::string method_; int timeout_; };委托构造的目标构造函数同样走初始化列表同样遵循成员声明顺序。需要注意委托和初始化列表是“二选一”的关系你可以在初始化列表里调用另一个构造函数也可以显式初始化成员但两者不能同时出现在同一个构造函数的初始化列表里。从实践角度讲委托构造函数配合合理的成员设计能省掉很多重复代码也让初始化路径变得单一、可预测。如果你发现一个类里有多个构造函数每个都要做不同的初始化参数组合委托构造往往是重构的好选择。5. 性能与异常安全初始化列表的双重优势5.1 少一次默认构造和赋值就是实打实的快性能这个问题放到现在编译器优化能力面前有些人会觉得无关紧要。但对于非平凡类型比如管理资源的类、容器类型初始化列表和函数体赋值之间的差距仍然存在。究其原因是函数体赋值版本的执行路径是“默认构造成员 调用拷贝赋值运算符”初始化列表版本的路径是“直接用参数构造成员”。拿一个普通成员来举例class Config { public: Config(const std::string name) { name_ name; } // 先默认构造 name_再赋值 private: std::string name_; }; class ConfigBetter { public: ConfigBetter(const std::string name) : name_(name) {} // 直接拷贝/移动构造 private: std::string name_; };第一个版本里name_先被默认构造空字符串再通过赋值操作符写入name。第二个版本直接以name为参数构造name_。虽然字符串很短时差距几乎可以忽略但如果成员是容器、锁、或者某些重型RAII类多一次默认构造加赋值的成本就会被放大。对于那些根本没定义默认构造函数的类型函数体赋值版本连编译都过不了更不用谈性能了。关于效率还有一个冷门但实在的场景在构造函数体内给成员赋值时“先默认构造再赋值”产生的临时状态可能让某些资源管理类如持有互斥锁或文件句柄的类型产生无意义的开销。初始化列表方式则直接从参数构造一步到位。从工程习惯上讲能用初始化列表就不用赋值不亏。5.2 初始化失败时对象的“出生”更安全异常安全是很多人忽略的维度。构造函数如果在函数体内部抛异常成员都已经初始化过了析构函数不会被调用——因为对象本身没有构造完成。这意味着被成功构造的成员会被逐一销毁但部分资源可能已经泄漏这取决于成员类型是否RAII化。相比之下初始化列表里抛出的异常发生在成员初始化期间。语言会保证当前正在构造的成员如果构造失败已经构造完成的兄弟成员会被逐一析构不会留下悬空的资源前提是成员类型也能正常析构。所以把“能一步完成的事”放在初始化列表里不只是性能偏好也是让异常路径更方便推理。最直观的对比场景是这样的如果你必须在构造函数体内new一个东西、然后初始化另一个成员中途一旦抛异常你自己还得写 try-catch 去清理已经new出来的资源。而如果你把这些东西都放进初始化列表RAII成员本身的析构逻辑就接管了清理工作。写代码的人需要处理的异常分支少得多。6. 类内初始值与static成员的边界情况6.1 默认成员初始化器什么时候用更好C11起可以在类内给非静态成员一个默认值class Timer { public: Timer() {} Timer(int period) {} // 进一步修改 period_ private: int period_ 1000; // 类内初始值 };类内初始值在成员初始化阶段生效不经过构造函数体。它和初始化列表的优先级关系前面说过列表优先。它最大的价值在于你不需要在每个构造函数里重复写同样的初值。比如默认超时时间1000毫秒如果多个构造函数各自初始化写错一次就出现行为不一致。类内初始值能把这层歧义消灭掉。不过也要注意类内初始值不适用于需要依赖构造函数参数的场景。如果初值因参数不同而变化应该放进初始化列表。二者在代码中其实可以同时存在类内给一个“安全性默认值”列表里给一个“按需覆盖值”。6.2 static成员的初始化方式不太一样静态成员属于类而不属于某个具体对象。它不是通过构造函数初始化的而是在程序启动阶段、main函数执行前就完成初始化。静态成员分几种情况static const int这类整型常量可以直接在类内给初值C17之后inline static允许在类内定义并初始化任何类型的静态成员普通的非const静态成员变量传统写法需要在类外单独定义和初始化。举个例子class App { public: static const int VersionMajor 1; // class内可行 inline static std::string Env dev; // C17class内可行 private: static std::string cache_; // 必须在类外定义 }; // 在某个.cpp文件中 std::string App::cache_ /tmp/app_cache;静态成员和初始化列表之间基本没有关系——初始化列表针对的是每个对象自己的成员而静态成员是整类共享的。记住这个区分面试和实战中就不会被绕晕。7. 从实战角度聊聊我踩过的几个坑书本上的规则说再多真正动手还是会遇到各种“边缘情境”。分享几个我在实际项目里见识过的问题给大家提前打个预防针。第一个坑是“在构造函数体里调用虚函数”。很多人写代码时没意识到构造函数执行期间虚调度是无效的。也就是说基类构造函数还没执行完时候调用虚函数调的是基类版本而不是派生类版本。这和初始化列表其实有着间接关系——如果你在初始化列表里初始化了一个带有虚函数的类成员并且在构造过程中触发了虚调用行为会和你预期的“多态”完全不同。最安全的做法是构造函数中不要让虚函数的调用成为初始化的依赖。第二个坑是“异常发生在初始化列表里的资源泄漏”。严格来说如果成员类型是自己用裸指针管理的那么初始化列表抛异常时裸指针成员指向的资源不会自动释放。这个坑本质上是“没有遵守RAII”。我见过有些人的类里有裸指针成员为了图省事在初始化列表里用一个new的返回值去初始化一旦后续成员构造失败前面new出来的内存就泄漏了。这种代码在复杂类里排查起来特别痛苦。我的建议是成员类型尽量用RAII容器unique_ptr、vector、string兜底不要让裸指针成为成员变量。第三个坑是“非平凡类型初始化列表的求值顺序”。C17之前初始化列表中各个成员初始化参数表达式的求值顺序是未指定的。也就是说列表里a(f(x)), b(g(x))这样的写法不能保证f先于g调用。虽然C17之后求值顺序有了明确改进但如果你在多个初始化参数里依赖同一个状态的变更次序还是会踩坑。解决办法仍然是不让初始化参数产生副作用依赖各参数之间尽量解耦。8. 结合个人的实践习惯聊聊怎么养成正确写法说实话我早些年也喜欢在构造函数体里直接赋值觉得代码更直白。后来被两个坑教育过之后慢慢形成了一套固定的写法习惯分享给大家参考。第一非静态成员变量凡是能在初始化列表里初始化的一律在列表里写。除非成员类型本身就是默认构造赋值很廉价、而且根本没有参数依赖的场景否则不走函数体赋值。第二初始化列表的顺序严格跟随成员声明顺序。注意不光是“先来后到”的美学问题而是防止编译器警告和意外行为。我会在类定义里也保持和列表一样的编排顺序让读代码的人不用来回比对。第三尽量让每个成员都在初始化列表里“出场”。如果一个成员不在列表里就意味着它走默认初始化或类内初始值。明确写出来阅读的人能直接看到每个成员的初值来源什么都没写大家就得猜。比起代码简洁性代码的确定性更重要。第四能委托构造就委托能类内初始值就类内初始值。在一个类有多个构造函数、且部分成员初值恒定的前提下这种组合可以明显减少重复代码也让新增构造函数变得便宜。有人可能会担心初始化列表太长了、不好读。这个担心有道理但解决办法不是退回函数体赋值而是重新审视类的成员设计——成员太多就做拆分把相关的几个字段组合成小结构体或类。从设计上把成员数量压下来比强行缩短列表更本质。最后再分享一个小细节初始化列表里我经常用std::move来把右值参数转移给成员比如name_(std::move(name))。很多人看到std::move会本能觉得是“性能优化”其实在初始化列表的语境里它更是语义表达——告诉读者“这个参数之后就不再需要了直接接管资源”。这种写法的意外收获是代码的意图会比裸拷贝清晰得多。C里零零碎碎的规则特别多初始化列表属于那种“你懂了就觉得理所当然你不懂就天天遇bug”的知识点。希望这篇能把对象“出生”的整个过程完整摆在大家面前以后写构造函数的时候心里对自己正在做的事有个清晰的图景。