ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

C++构造函数初始化列表:从冒号语法到底层原理与常见坑

C++构造函数初始化列表:从冒号语法到底层原理与常见坑 很多C初学者学到构造函数的时候都会遇到那个让人一头雾水的问题构造函数后面怎么还跟着一个冒号比如下面这行代码Point(int x, int y) : _x(x), _y(y) {}很多人第一反应是“这是不是写错了”第二反应是“冒号后面这串东西到底在干什么”。我在带新人的时候几乎每隔一段时间就会遇到有人截图问我这个语法。其实这个冒号后面的内容就是C里非常经典又非常重要的初始化列表。今天我想用一篇完全从零开始的解析把它掰开揉碎了讲清楚保证你以后再看到构造函数后面的冒号脑子里浮现的只有四个字原来如此。这篇文章适合所有正在学C的初学者也适合那些会用初始化列表但从来没搞懂“为什么必须这么写”的开发者。我会从最基础的语法讲起再到const成员、引用成员、初始化顺序这些硬核细节最后附上我实际踩过的一些坑和调试经验。全程不堆术语尽量用人话讲明白。1. 初始化列表是什么冒号后面的语法其实很简单1.1 从一段最朴素的代码认识初始化列表先来看一个最常见的例子。假设我要写一个表示二维坐标点的类class Point { public: Point(int x, int y) : _x(x), _y(y) { // 构造函数函数体 } void print() const { std::cout ( _x , _y ) std::endl; } private: int _x; int _y; };注意看构造函数那一行Point(int x, int y) : _x(x), _y(y) {}。这里面的: _x(x), _y(y)就是初始化列表。它的意思非常直白把参数x的值用来初始化成员变量_x把参数y的值用来初始化成员变量_y。语法结构拆开来看其实就三步冒号标志着初始化列表的开始。逗号用来分隔多个成员的初始化格式是“成员名(参数)”。括号里面填写你准备传给这个成员的值可以是参数、表达式甚至函数调用。如果你不用初始化列表这个构造函数也可以写成这样Point(int x, int y) { _x x; _y y; }这两种写法在结果上对于int这种内置类型来说几乎没有任何区别。但请注意我这里说的是“几乎”。等到成员变成const、引用或者某个没有默认构造函数的类对象时这两种写法的差异就会瞬间放大甚至直接决定代码能不能编译通过。1.2 初始化列表和赋值的本质区别要真正理解初始化列表必须把“初始化”initialization和“赋值”assignment这两件事区分开。你可以把类对象想象成一套精装修交付的房子初始化是你在拿到钥匙的那一刻房子里已经摆好了家具、装好了电器赋值是房子已经按默认样板间交付了你住进去之后再自己一件件换家具。C里对象的成员变量生命周期开始于构造函数函数体执行之前。也就是说当你还没进入构造函数的花括号{}时所有成员变量就已经被创建出来了。如果你在函数体里写_x x那其实是在“成员已经默认构造完成之后再给它赋一个新值”这是一次多余的、低效的操作。而初始化列表做的事是在成员变量被创建的那一刻直接指定初始值一步到位没有中间商赚差价。对于内置类型比如int、double、char这个差别几乎感知不到因为默认构造一个int的成本趋近于零。但一旦成员变量变成std::string、std::vector这类重量级对象默认构造再赋值就是“先建一栋空房子再拆了重装”白白浪费了一段构造和析构的开销。这也是很多C老手坚持“能用初始化列表就绝不在函数体里赋值”的原因。2. 为什么有些成员必须交给初始化列表2.1 const成员定义即封存错过就再也没机会C里有一个铁律const变量必须在定义的时候就初始化之后不能再被修改。这条规则放在类成员变量上就会引发一个非常常见的编译错误。看下面的代码class Test { public: Test(int x) { _x x; // 编译错误 } private: const int _x; };你猜编译器会不会让你过答案是不会。因为_x是const成员它在构造函数函数体执行之前就已经被默认初始化了而一旦被默认初始化它就被“封存”了你后面想给它赋值编译器直接拒绝。这就好比你买了一张写着“默认值”的身份证之后想改名系统说不行因为初始信息已经刻死了。正确的写法必须把_x放到初始化列表里class Test { public: Test(int x) : _x(x) {} // 正确 private: const int _x; };因为初始化列表发生在“成员对象创建”的时刻而不是“对象已经创建完毕”的时刻所以const成员在这里获得初始值是合法的。记住一句话const成员的初始化窗口期只有初始化列表这一次错过了就没有了。我自己早期写代码时喜欢先在构造函数函数体里给成员赋值等需求变更把某个成员改成const然后编译报错才意识到要回过去改成初始化列表。后来干脆养成习惯所有成员默认都塞进初始化列表省心。2.2 引用成员C里没有“默认空引用”这回事引用是C另一个容易被初始化列表卡住的类型。和const成员类似引用也有一个硬性规定引用必须在定义的时候被绑定到一个对象上不存在“默认空引用”这种状态。你可以把它理解成一根线这根线从出生的那一刻起就必须连着一只风筝不能先让它悬空站一会儿之后再挂风筝。看下面这个错误示范class Test { public: Test(int ref) { _ref ref; // 编译错误 } private: int _ref; };_ref声明为int它没有默认绑定的目标所以一旦进入构造函数函数体它就已经处在“未初始化”的非法状态了再对它赋值根本不合法。正确做法同样是在初始化列表里绑定class Test { public: Test(int ref) : _ref(ref) {} // 正确必须在这里绑定引用 private: int _ref; };这里有个细节值得注意传入构造函数的参数ref本身必须是一个有效的int左值否则_ref就会绑定到一个临时对象上留下悬空引用的隐患。比如你写Test t(5)那5是一个临时值绑定是绑上了但临时值生命周期一结束引用就失效了。这种问题比较隐蔽编译期不一定报错运行期却可能突然崩掉。所以引用成员在项目里通常要谨慎使用最好配合明确的生命周期管理。2.3 成员对象没有默认构造函数不传参就没法构造这一条可能是实战中最容易遇到的场景。假设我们有一个Inner类它定义了带参构造函数但没有默认构造函数class Inner { public: Inner(int value) : _value(value) {} int getValue() const { return _value; } private: int _value; };因为 C 类一旦自己声明了任何构造函数编译器就不会再自动生成默认构造函数所以Inner现在是“没有默认构造”的状态。接着我们再写一个Outer类它包含一个Inner类型的成员class Outer { public: Outer() { _inner Inner(42); // 编译错误 } private: Inner _inner; };这段代码编译不过原因很清晰在进入Outer构造函数函数体之前_inner就需要被构造但Inner没有默认构造函数编译器不知道该怎么构造它。此时你连_inner Inner(42)这一步都走不到因为成员在函数体之前就已经“构造失败”了。正确写法是把_inner也放进初始化列表class Outer { public: Outer() : _inner(42) {} // 正确直接构造并初始化 private: Inner _inner; };这个场景也侧面解释了为什么很多设计规范会建议如果一个类没有默认构造函数那它作为另一个类的成员时外层类的构造函数必须要用初始化列表显式传参。否则你就是在强迫外层类必须也只能依赖默认构造很容易把自己卡死。2.4 效率账一次构造和两次构造的差别有多大除了上面三种“不用列表就编译不过”的硬性场景初始化列表还有一个软性优势性能。说它“软”是因为在某些类型身上效果不明显在另一些类型身上却能拉开非常可观的差距。最典型的例子是std::string。假设一个类有两个string成员class Person { public: // 写法一初始化列表 Person(const std::string name, const std::string addr) : _name(name), _addr(addr) {} // 写法二函数体赋值 // Person(const std::string name, const std::string addr) { // _name name; // _addr addr; // } private: std::string _name; std::string _addr; };写法二在底层发生了什么_name和_addr先是各自调用默认构造函数生成两个空字符串然后函数体里的再触发拷贝赋值运算符把name和addr的内容拷过去。这里面空字符串的构造和后续的资源分配都属于“白干”的活。写法一则是一步到位直接用传入的参数构造字符串少了一次默认构造和一次赋值。对于std::string这种内部涉及堆内存分配的类这一来一回可能就是两次内存分配。如果构造函数的调用很频繁比如在一个循环里创建一万个Person对象那写法二多出的额外开销就会非常明显。所以哪怕从纯性能角度我也建议你养成“成员一律初始化列表优先”的习惯。3. 初始化顺序列表写了什么不重要声明顺序才算数3.1 一个反直觉的例子先声明的成员先初始化初始化列表最容易被初学者忽略的坑是初始化顺序。C规定成员变量的初始化顺序不是由初始化列表里的书写顺序决定的而是由它们在类中声明的顺序决定的。这个规则非常容易踩雷我直接给个例子class Test { public: Test(int val) : _b(val), _a(_b) {} void print() const { std::cout _a _a , _b _b std::endl; } private: int _a; int _b; };你以为初始化列表写了_b(val), _a(_b)那执行顺序就是先_b后_a所以_a应该能拿到_b的值。但现实很残酷由于_a在类中声明在_b之前实际的初始化顺序是“先_a后_b”。也就是说当_a初始化时_b还是未初始化的垃圾值_a(_b)这个表达式用到的_b的内容完全是未定义的。实际运行效果不同编译器可能给出不同的结果。如果你把_b初始化成100_a可能会随机得到一个乱七八糟的数因为那一刻_b还没有被赋值为100。这属于典型的“初始化顺序依赖”在跨平台项目里特别容易引发诡异的bug因为你在这台机器上跑得好好的换个编译器或者换个优化级别结果就不一样了。3.2 编译器告警与实用建议好消息是主流编译器其实会提醒你这个风险。GCC和Clang在开启-Wreorder告警后如果发现初始化列表的书写顺序和成员声明顺序不一致会给出类似下面的警告warning: field _b will be initialized after field _a [-Wreorder]VS系列编译器也会输出类似提示。但日常开发中很多人根本不看警告或者团队没有开启-Wreorder的告警于是这类问题就悄悄溜进了代码库。我的建议有两个。第一初始化列表书写的成员顺序始终和类里声明的顺序保持一致。即便编译器允许你写乱序也千万别图一时方便倒着写。第二项目里尽量开启-Wall -Wextra这类常见告警集把这类隐患在编译期就消灭掉。不要等到上线后跑出一堆离奇数据才想起回头查初始化顺序。4. 进阶实操初始化列表不只有“给成员赋值”这一种用法4.1 派生类构造函数基类也要在列表里初始化初始化列表的另一个高频使用场景是派生类的构造函数。当你继承一个基类时派生类构造函数必须在初始化列表里调用基类的构造函数尤其在基类没有默认构造函数的情况下这甚至是唯一的选择。看这个例子class Base { public: Base(int id) : _id(id) {} private: int _id; }; class Derived : public Base { public: Derived(int id, const std::string name) : Base(id), _name(name) {} // 先初始化基类再初始化派生类成员 private: std::string _name; };注意初始化列表里出现了Base(id)这就是对基类构造函数的调用。它的执行顺序有严格规定先初始化基类部分然后按声明顺序初始化派生类自身的成员变量最后才进入派生类构造函数函数体。这个顺序不能乱也不建议乱。即便基类有默认构造函数你在派生类里不写Base(...)编译器也会隐式调用基类的默认构造函数但如果基类没有默认构造你在初始化列表里又漏了那编译器就直接报错。这个场景在多层继承里尤其容易出问题。我见过一些项目里三层五层地继承最底层派生类的初始化列表里写着好几个基类的初始化一旦某个中间层基类的构造函数签名改了编译错误会沿着继承链条一路爆炸。排查的时候最好的办法就是自上而下检查每一层的构造函数签名和初始化列表。4.2 C11委托构造函数一个构造函数调用另一个C11 引入了一个很有意思的特性委托构造函数。它允许一个构造函数在初始化列表里直接调用同一个类的另一个构造函数让多个构造函数共享初始化逻辑。写法如下class Test { public: Test() : Test(0, default) {} // 委托给下面的构造函数 Test(int x, const std::string s) : _x(x), _s(s) {} private: int _x; std::string _s; };Test()这个无参构造函数在初始化列表里调用Test(0, default)这样_x和_s的初始化就统一交给带参构造函数去处理。好处很明显你可以避免在多个构造函数里重复写同样的初始化列表也能保证所有构造函数走同一套初始化逻辑降低维护成本。但要注意委托构造不能形成环。比如Test()委托Test(int)而Test(int)又委托Test()这种循环委托是编译错误。实际项目里通常只做一层或两层委托超过两层就该怀疑设计是不是有问题了。另外一个细节是委托构造的初始化列表里不能再同时写其他成员变量的初始化。比如Test() : Test(0, default), _x(1) {}就是非法的。因为你在委托给另一个构造函数时等于把“初始化本类成员”这件事也委托出去了不能再自己单独插手。4.3 哪些成员不能放在初始化列表里初始化初始化列表看起来很万能但它并不是所有成员的“万能钥匙”。最典型的例外是静态成员变量static成员。静态成员属于类本身而不属于某个具体的对象所以它的初始化不能在对象构造时进行。C要求静态成员必须在类外定义和初始化比如class Test { public: static int count; // 类内声明 }; int Test::count 0; // 类外定义并初始化如果你试图把count写进某个构造函数的初始化列表里比如Test() : count(1) {}编译器会直接告诉你不能用初始化列表初始化静态成员。因为初始化列表针对的是“这个正在被构造的对象”的成员而静态成员是“所有对象共享”的初始化时机完全不同。另外普通数组成员在初始化列表里也不能直接这样写class Test { public: Test() : _arr{1, 2, 3} {} // 某些编译器可能支持但标准上有讲究 private: int _arr[3]; };这个写法跟编译器版本和市场标准有关C11之后对聚合初始化的支持有所变化但如果你写的是_arr {1, 2, 3}那是肯定不行的。更稳妥的做法是在构造函数函数体里对数组元素逐个赋值或者改用std::array、std::vector这类现代容器它们的初始化就方便多了。4.4 初始化列表里的“初始化”最好别夹带私货最后一个实战建议初始化列表里写的内容应该尽量保持纯粹。什么意思就是列表里只放成员变量的初始值不要在括号里写复杂到绕圈的表达式更不要在初始化列表里调用虚函数或依赖未初始化成员的成员函数。比如有人喜欢写这种Test(int x) : _x(x), _y(_x * 2 compute()) {}如果compute()是成员函数它内部万一用到某个还没初始化的成员那结果就是未定义行为。因为初始化顺序是声明顺序_y执行初始化时后声明的成员可能还没准备好。这类问题在编译期不一定报错运行期却会给出神出鬼没的结果特别难查。我自己遇到过一次后来代码里明确了规矩初始化列表里的表达式只允许使用构造参数、常量以及已经确认初始化完成的成员。5. 常见问题速查与调试经验5.1 常见编译错误和解决对照表我把平时最常见的几种跟初始化列表相关的编译错误整理成了一张速查表方便你遇到问题时对照查找错误信息原因解决方案const member _x must be initializedconst成员没有在初始化列表中获得初值把_x放进初始化列表reference member _ref must be initialized引用成员没有在初始化列表中被绑定在初始化列表里绑定合法的左值引用no default constructor exists for class Inner成员对象没有默认构造函数且没有在初始化列表里传参构造在初始化列表里显式调用成员对象的带参构造函数static data member cannot be initialized here试图用初始化列表初始化静态成员把静态成员放到类外定义和初始化multiple initializations of same member同一个成员在初始化列表和函数体里被重复赋值二选一推荐保留初始化列表删除函数体内的赋值这里有个小技巧值得说明编译器报错时错误信息里提到的“must be initialized”一般都不是说“你少写了一个赋值”而是在暗示“你需要在初始化列表里补上初值”。我刚学C时经常遇到const成员报错后第一反应是去函数体里加一行赋值结果越改越错。后来才明白编译器的潜台词是你对这个成员做初始化的时候已经到了但初始化列表里没写函数体里再补救也不顶用。5.2 初始化列表里能不能调用成员函数这个问题我经常被问到答案是能但不建议。技术上成员函数在对象构造期间是形态完整的你确实可以在初始化列表里调用某个成员函数来获取返回值。可问题在于调用成员函数时成员变量的构造状态和初始化顺序是不可控的。如果这个函数内部读取了某个尚未初始化的成员或者在虚函数机制生效之前你就调用了虚函数那结果完全不可预期。举一个实际例子假设类里有_a和_b两个成员你写Test() : _a(helper()), _b(10) {}而helper()内部会读取_b。这时_b还没初始化于是_a拿到的是一个垃圾值。编译器不会报错因为从语法层面看“调用一个成员函数”是合法的但从逻辑层面看你依赖了一个尚未就绪的状态。我的建议是初始化列表里尽量只放简单的表达式要么来自构造参数要么来自外部纯函数。如果确实要在初始化时做点计算先把计算结果算好再传给初始化列表。比如改成Test(int b) : _b(b), _a(calculate(b)) {}其中calculate是自由函数或静态函数不依赖对象内部状态这样就安全多了。5.3 真实项目里的几条实用习惯聊了这么多理论最后分享几条我在实际项目里踩过坑之后总结出来的习惯。这些不是教科书上写的但实用性很高。第一只要不是静态成员、不是数组所有成员我都默认放进初始化列表。哪怕成员是普通的int我也坚持写。理由很简单万一将来某一天这个成员被改成了const或者类型换成了一个没有默认构造的类我不用回头改构造函数因为初始化列表早就写好了。第二初始化列表的顺序跟类里成员声明的顺序严格一致并且在代码审查时重点检查这一点。这能有效规避-Wreorder告警也能避免我上面提到的“先声明的成员被后声明的成员影响”那类bug。第三构造函数尽量保持简洁初始化列表之外的工作能少则少。如果一个构造函数的函数体里包含了复杂的业务逻辑、文件操作、资源申请我会怀疑它是不是设计得有问题。构造函数的核心职责就是把对象置于一个合法、可用的状态复杂逻辑应该放到专门的init()或工厂函数里。第四面试准备时初始化顺序、const成员初始化、引用成员初始化、基类初始化这几个点几乎是必考内容。我面试别人时也喜欢问“初始化列表里的顺序和类中声明顺序不一致会发生什么”。很多人能背出答案但只有真正写出过那个bug的人才会流露出那种“我懂你”的眼神。所以建议你在本地跑一跑我上面第三部分的例子亲眼看看_a的乱值比记十遍规则都管用。初始化列表这个语法看着只是构造函数后面一个小小的冒号实际上牵涉到成员生命周期、const/引用语义、继承关系、性能开销还有初始化顺序的重重坑。搞懂它你不仅能在编译错误面前少掉一半头发写出来的类也会更健壮、更专业。希望这篇文章能帮你在C这条路上少踩几个坑。遇到什么跟构造函数相关的奇怪bug欢迎按着文中的思路排查一遍很可能就是初始化顺序在捣乱。
返回列表