ARTICLE DETAIL

资讯详情

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

C++构造函数初始化列表:底层原理、必用场景与性能优化

C++构造函数初始化列表:底层原理、必用场景与性能优化 1. 先从一次代码评审说起前阵子做代码评审看到同事新写的类里有个成员是const int构造函数里直接m_value 100;这样赋值编译死活过不去。他一脸困惑构造函数里不就能初始化成员吗为啥 const 的不行这就是典型的不理解初始化列表和构造函数体的区别。明明构造函数名() {}的大括号里可以写代码而且大多数教程也都说构造函数用于初始化成员变量为什么 const 成员偏偏不买账原因其实不复杂进入构造函数体时所有成员变量已经完成构造了你在大括号里写的其实是赋值不是初始化。const 对象不允许赋值所以编译直接报错。这个场景我见过太多次。初始化列表不是 C 的一个小技巧而是对象初始化的核心通道。想真正掌握构造函数必须先弄明白初始化列表到底在干什么。这篇内容就围绕这两块展开哪里必须用初始化列表哪里不能乱用构造函数重载、拷贝构造函数又是怎么和初始化列表互相配合的。适合刚学完 C 基础语法、准备深入面向对象的读者也适合写过一阵子但总在编译报错里打转的开发者。2. 初始化列表的底层逻辑与构造函数体的分工2.1 成员初始化的完整时间线一个对象从出生到构造函数体执行中间其实经过了一个成员就位阶段。简单说成员变量在进入函数体之前就已经被构造好了。如果没在初始化列表里指定值基本类型成员处于未初始化的随机值状态类类型成员则会调用各自的默认构造函数。这个时间线可以用下面的例子验证#include iostream class Demo { public: Demo() { std::cout Demo 默认构造 std::endl; } Demo(int x) { std::cout Demo 带参构造: x std::endl; } }; class Wrapper { public: Wrapper() { std::cout Wrapper 构造函数体开始 std::endl; } private: Demo demo_; }; int main() { Wrapper w; return 0; }运行结果会先打印Demo 默认构造再打印Wrapper 构造函数体开始。这说明 demo_ 的构造发生在 Wrapper 的函数体之前。你以为在 Wrapper 构造函数体里写demo_ Demo(10);是初始化其实那是一次先默认构造再赋值的浪费操作。真正的初始化只能靠初始化列表。class Wrapper { public: // 正确做法在初始化列表里指定 Demo 的带参构造 Wrapper() : demo_(10) { std::cout Wrapper 构造函数体开始 std::endl; } private: Demo demo_; };对比两次运行的输出就能发现用初始化列表时只调用了一次Demo(int x)而用赋值写法时多了一次默认构造的开销。对于轻量类的开销不明显但如果是 std::string、std::vector 这种有堆内存管理的类型多一次构造和赋值就意味着多一次内存申请和释放性能差距立刻就能感受到。2.2 编译器对初始化列表的无提示优化还有一类容易被忽略的情况构造函数体里对成员赋值编译器在某些场景下确实会优化掉多余构造但这种优化受限于成员类型的可优化性。比如class Wrapper { public: Wrapper() { demo_ Demo(10); } private: Demo demo_; };这段代码在 Release 模式下对于简单的 Demo 类编译器可能会把Demo(10)直接构造到 demo_ 的内存位置上从而省掉中间步骤。但这是编译器的优化行为不是语言保证。依赖编译器会优化来写代码本质上是在赌编译器的行为这非常不可靠。尤其是当 Demo 有其他副作用、或者有自定义的赋值运算符时优化的空间会被大幅压缩实际运行可能老老实实执行默认构造 赋值两步。正确的心态是使用初始化列表不是为了讨好编译器而是为了写出语义上就正确的初始化代码。优化与否交给编译器正确性掌握在自己手里。3. 必须使用初始化列表的场景与不能使用的场景3.1 四个回避不了的硬性场景有些场景不用初始化列表代码根本编译不过。我在工作里总结出四个出现频率最高的const 成员变量。class Config { public: Config(int v) : value_(v) {} // 必须用初始化列表 private: const int value_; };const 成员一旦离开初始化列表在构造函数体内就已经是已初始化状态之后不能再被赋值。所以要么在声明处给默认值const int value_ 1;要么在初始化列表初始化没有第三条路。引用成员变量。class Holder { public: Holder(int ref) : ref_(ref) {} // 引用必须绑定到已存在的对象 private: int ref_; };引用和 const 成员同理引用一旦绑定就不能换绑。构造函数体里写ref_ x;不是绑定引用而是给引用的目标赋值语义完全不同。没有默认构造函数的类类型成员。之前 Demo 类的例子已经演示了如果 Demo 只有Demo(int)而没有默认构造函数那么在 Wrapper 的构造函数体里demo_ 根本没有合法的构造路径编译必失败。只能通过初始化列表把参数传进去。基类没有默认构造函数。派生类的构造函数如果没有在初始化列表里显式调用基类的带参构造函数编译器就会尝试调用基类的默认构造函数。基类一旦没有默认构造函数报错就出现了。class Base { public: Base(int v) {} }; class Derived : public Base { public: Derived() : Base(10) {} // 必须显式调用基类构造 };这个场景在写派生类时尤其容易踩坑。基类只要定义了自己的带参构造函数编译器就不会自动生成默认构造函数。派生类里不写: Base(...)编译错误几乎是必然的。3.2 初始化列表的隐含陷阱成员声明顺序初始化列表的另一个隐蔽特性成员变量的初始化顺序不是由初始化列表的书写顺序决定而是由成员在类中的声明顺序决定。这一点是新手最常踩的坑之一。class Order { public: Order() : b_(1), a_(2) {} // 书写顺序是 b 在 a 前面 void print() { std::cout a a_ , b b_ std::endl; } private: int a_; // 声明顺序a 在 b 前面 int b_; };这段代码看起来初始化列表先写了 b_ 再写 a_以为 b_ 先被设置成 1。实际上由于类里 a_ 声明在 b_ 前面编译器会先初始化 a_再初始化 b_。最终输出是a 2, b 1。即使初始化列表的书写顺序完全相反结果也一样。这个陷阱在成员之间有依赖关系的时候体现最严重class Bad { public: Bad() : b_(a_ 1), a_(0) {} // b_ 先被初始化但此时 a_ 还未初始化 private: int b_; int a_; };因为 a_ 声明在 b_ 之后所以编译器先初始化 b_此时 a_ 还是随机值b_ 被赋了一个垃圾值。等到初始化 a_ 0 时已经晚了。最稳妥的做法是让初始化列表的书写顺序和成员声明顺序完全一致避免编译器埋雷。编译器通常会对顺序不一致的初始化列表给出-Wreorder警告建议把警告级别开高一点。3.3 初始化列表的两种受限初始化列表也不是万能的。两个限制值得记住不能对数组元素逐一初始化。C 的初始化列表constructor initializer list不同于 std::initializer_list它不是用来初始化数组元素的。类里的数组成员只能通过默认构造、或在构造函数体内逐个赋值来初始化。这一点不同于聚合体的大括号初始化。class Arr { public: Arr() {} // 可以在函数体里逐个赋值 arr_[0] 1; ... private: int arr_[10]; };不能决定初始化顺序的细节。前面已经说了初始化顺序由声明顺序决定开发者能做的只是对齐声明顺序而不是幻想用书写顺序控制。4. 拷贝构造函数与构造函数重载的细节协同4.1 拷贝构造函数的触发时机与自实现拷贝构造函数属于构造函数家族里比较特殊的一员。它的特征是参数类型是本类的 const 引用class Account { public: Account() : balance_(0) {} Account(const Account other) : balance_(other.balance_) {} private: double balance_; };触发时机主要是三种按值传参、按值返回、以及用一个已有对象初始化另一个新对象。void func(Account a); // 按值传参触发拷贝构造 Account makeAccount() { Account a; return a; // 按值返回触发拷贝构造或移动构造取决标准版本 } Account b(a); // 显式拷贝初始化最容易被忽略的触发场景是Account c a;。不管写不写只要是在声明时用一个对象去初始化另一个对象都是拷贝构造不是赋值。赋值发生在两个已有对象之间Account c; c a; // 这里是赋值运算符深拷贝在类持有指针或资源时必须自己实现。默认拷贝构造函数做的是逐成员浅拷贝对于 int、double 这种基本类型没有影响但对于int*成员浅拷贝会让两个对象指向同一块内存析构时会 double free。一个典型的 String 类class MyString { public: MyString(const char* s) { len_ strlen(s) 1; data_ new char[len_]; memcpy(data_, s, len_); } MyString(const MyString other) : len_(other.len_) { data_ new char[len_]; memcpy(data_, other.data_, len_); } ~MyString() { delete[] data_; } private: char* data_; int len_; };这里的拷贝构造函数必须用初始化列表完成 len_ 的初始化然后在函数体内 new 一块新内存做深拷贝。如果省略拷贝构造函数默认浅拷贝下两个对象的 data_ 指向同一块堆内存先后析构就直接 double free程序不崩溃才怪。4.2 构造函数重载的匹配规则与 explicit 关键字构造函数重载的关注点是调用时的匹配。一个类可以有多个构造函数参数列表不同即可。但需要注意的是C 允许某些情况下隐式调用构造函数比如class MyString { public: MyString(const char* s) {} }; void func(MyString s); func(hello); // 编译器自动构造了一个临时 MyString 对象触发隐式转换这看起来方便实际上很容易造成误用。比如std::string s hello;会隐式调用std::string(const char*)这没什么问题。但自定义类型中的隐式转换有时会让程序员在传参时不小心触发一个本不想触发的构造。显式声明explicit可以把隐式调用关掉只允许显式构造class MyString { public: explicit MyString(const char* s) {} }; func(hello); // 编译错误不能隐式转换 func(MyString(hello)); // 正确C 标准对explicit的建议是单参数构造函数默认考虑加explicit除非你有非常明确的需求允许隐式转换。比如 std::vector 的 size 参数构造函数就不适合加因为vectorint v(10);本身语义已经足够清晰加explicit反而让很多写法变得不自然。关于重载还有一个常见误会构造函数可以互相调用吗C11 之后支持委托构造函数class Point { public: Point() : Point(0, 0) {} Point(int x, int y) : x_(x), y_(y) {} private: int x_; int y_; };这里Point()委托给Point(int, int)完成初始化。需要注意的是委托构造和成员初始化列表是互斥的不能同时出现。写成Point() : x_(0), Point(0,0)是语法错误。委托构造的实用性在于可以消除多个构造函数之间的重复初始化逻辑。4.3 初始化列表与重载相互影响的编译验证想象一个复杂点的场景类有多个成员有些必须在初始化列表初始化有些可以延迟赋值。这时候构造函数重载就需要和初始化列表互相配合class HttpConfig { public: HttpConfig() : timeout_ms_(3000), retry_count_(3), url_(https://default) {} HttpConfig(const std::string url) : timeout_ms_(3000), retry_count_(3), url_(url) {} HttpConfig(int timeout, int retry, const std::string url) : timeout_ms_(timeout), retry_count_(retry), url_(url) {} private: const int timeout_ms_; int retry_count_; std::string url_; };timeout_ms_ 是 const 成员必须用初始化列表。url_ 是 std::string 成员如果不在初始化列表里传参就会先默认构造再赋值浪费一次内存操作。三个重载构造函数通过不同的参数组合初始化同样的成员逻辑统一。这类代码在实际项目里常见于配置类、参数类、builder 模式的对象。如果重载太多导致构造函数列表膨胀可以考虑用 named parameter 模式一个配置结构体 一个构造函数但那是另一个层面的设计问题了。5. 构造过程中的异常传播与运行时问题排查5.1 构造函数执行中的绑定约束异常意味着什么有读者提到过cefsharp.wpf.chromiumwebbrowser 的构造函数执行符合指定的绑定约束的调用时引发了异常这类问题。这类报错多见于 C# 场景但这个问题的实质是通用的构造函数中执行的操作超出了对象本身的初始化约束或者依赖的组件尚未就绪导致构造过程抛出异常。在 C 语境里类似的情况也很常见。构造函数里如果申请资源失败、打开文件失败、或者调用了某个依赖组件但组件不在可用状态异常会从构造函数里抛出。这时候 C 的内存管理规则是已构造完成的成员会被自动析构基类部分会被自动析构但对象本身的内存不会被自动释放。也就是说构造到一半抛异常时delete需要由调用者处理如果是new出来的对象new 表达式会保证释放已经分配的内存。这个行为由编译器保证不需要担心内存泄漏但需要知道的排查方向。最常见的排查步骤是确认构造函数里每一条产生副作用的顺序。比如初始化列表里调用某个函数但这个函数依赖一个尚未初始化的静态变量就会出现状态未就绪的异常。定位方法很朴素在构造函数的每个步骤后加日志或断点逐步缩小异常抛出的位置。C 里std::cerr输出或临时加的 printf 都比断点更快定位问题。5.2 构造函数体内抛异常时的特殊注意事项另一个容易忽略的场景是构造函数体内做了多步资源获取。比如先 new 一个对象 A再 new 一个对象 BB 失败了抛异常。此时 A 的指针存在局部变量中而构造函数不会执行析构——因为对象还没完全构造好析构函数不会被调用。这样 A 就泄漏了。对付这种问题有两个思路思路一用 RAII 成员替代裸指针成员。class Safe { public: Safe() : a_(new A()), b_(new B()) {} private: std::unique_ptrA a_; std::unique_ptrB b_; };如果new B()失败抛异常a_ 作为成员已经被初始化unique_ptr 会正确释放 A。而且内存分配失败时 new 本身会抛std::bad_alloc对象构造过程被中断但 a_ 的析构依然会被执行。思路二构造函数体里只做不抛异常的操作资源获取放到工厂函数里。class Resource { public: static std::unique_ptrResource create() { auto p std::make_uniqueResource(); p-init(); // init 里做可能失败的操作 return p; } private: Resource() default; void init() { /* 可能抛异常 */ } };这种方式把可能失败的初始化从构造函数挪到普通成员函数构造函数本身保持不抛异常减少异常安全方面的压力。5.3 排查初始化相关问题的通用工具栏我在实际工作里积累了一套排查构造函数 初始化列表问题的流程从编译器警告开始到运行时行为验证很实用第一步开满编译警告。GCC/Clang 建议至少打开-Wall -Wextra -WpedanticMSVC 打开/W4。初始化列表顺序不匹配、成员未初始化这类问题编译器给了警告一般就有雷。第二步审查成员声明顺序与初始化列表顺序。对照头文件里成员的声明顺序逐一核验初始化列表的书写顺序。不一致时优先改初始化列表的书写顺序。第三步检查有无默认构造函数丢失。基类或成员类只要定义了带参构造函数而又没有默认构造函数派生类或宿主类就必须在初始化列表显式调用。第四步运行时用 sanitizer 验证。构造过程涉及指针、数组边界时用 AddressSanitizer 或者 Valgrind 跑一遍。构造函数里的越界写、未初始化读这类问题编译期基本查不出来但是 sanitizer 一下就能定位到具体行号。我在调试一个图像缓冲类时遇到过类似的坑成员int width_和int height_初始化列表写成了height_(h), width_(w)因为声明顺序是 width 在前面导致 height_ 先被初始化赋值范围效验失败的时候拿到的 width_ 还是随机值。排查过程花了一个多小时最后就是用-Wreorder警告一眼锁定的。6. 初始化顺序、性能对比与长期可维护性的经验总结6.1 初始化列表在代码评审中的实际权重代码评审时我特别关注构造函数。一个构造函数里如果既用了初始化列表又有人在函数体里对成员赋值基本可以确定作者没理解初始化 vs 赋值的差异。最理想的状态是构造函数函数体里只写必须的额外逻辑如权限检查、日志记录、子对象注册所有成员初始化都放在初始化列表里。这个原则对类设计的影响是深远的。比如一个 logger 类class Logger { public: Logger(const std::string filename) : file_(filename), level_(LogLevel::INFO) { if (!file_.good()) { throw std::runtime_error(无法打开日志文件); } } private: std::ofstream file_; LogLevel level_; };初始化列表完成了 file_ 和 level_ 的初始化构造函数体只做前置条件检查。这样的代码逻辑清晰reviewer 一眼就能看出每个成员的初始化来源能更快发现潜在问题。一个实用的代码风格规范建议初始化列表的每一行只写一个成员并且顺序与声明顺序完全一致。这能避免大量的顺序相关 bug也让 review 的 diff 更小。6.2 从性能角度的两个实测结论关于初始化列表的性能优势有两个实测结论可以直接参考结论一基础类型成员初始化列表与函数体赋值几乎没有可测差异。int、double、指针这类成员在 Release 模式下两种方式生成的汇编代码基本一致。性能差异不在基础类型上所以不需要因为性能理由强迫自己给所有 int 成员套初始化列表。结论二类类型成员std::string、std::vector、自定义类等在 Debug 模式下差异明显Release 下视编译器优化程度而定。一个std::vectorint成员初始化列表直接构造能省掉一次默认构造 一次赋值包括可能的重新分配内存。如果类创建频繁这种差异会被放大。实测创建 10 万个包含std::string成员对象的场景初始化列表版本的耗时大约是赋值版本的 60%~70%差异相当可观。C Core Guidelines 建议Prefer initialization to assignment。这条建议的本质是写作时的正确性优先性能优势反而是附加奖励。6.3 向 C11 之后的标准演进就地初始化与初始化列表的协同C11 允许在类内声明处直接给默认值这种就地初始化和初始化列表是兼容并存的class ModernConfig { public: ModernConfig() {} // 使用声明处默认值 ModernConfig(int timeout) : timeout_ms_(timeout) {} // 覆盖默认值 private: int timeout_ms_ 3000; // 就地初始化 std::string url_ https://api.example.com; const bool enable_log_ true; };就地初始化让默认值靠近声明处减少了初始化列表里不必要的项目。但需要明确一个优先级规则初始化列表的值优先于声明的默认值。上面的示例里第一个构造函数不写初始化列表成员用声明处的默认值第二个构造函数初始化了 timeout_ms_它就使用传入值其余成员依然走默认值。值得注意的是 const 成员也可以用就地初始化C11 及之后都合法。所以如果成员有稳定的默认值就用就地初始化如果构造函数需要传参覆盖默认值就只在那个参数对应的构造函数里写初始化列表初始化列表只需要写那些和默认值不同的成员避免不必要的冗余。这可以让构造函数列表显著变短降低阅读成本。我在维护一个超过 20 个成员的旧代码时把一半成员翻到就地初始化后构造函数体从 40 行缩到 10 行整体可读性提升了一个档次。7. 收尾时的几个实操心得讲到最后分享几个我在实战里沉淀下来的体会。第一排查初始化相关 bug 时先怀疑顺序问题再怀疑赋值问题。大多数为什么我的成员值是随机数的案例最后都能追溯到成员声明顺序和初始化列表书写顺序不一致。花半小时看代码不如花五分钟查-Wreorder产生的警告这条路径快得多。第二重载构造函数有瘦身的空间别手软。如果发现类有六七个构造函数大量初始化列表内容重复直接用委托构造函数来收敛。现代 C 编译器对委托构造的支持已经很成熟不需要担心额外的运行时开销。第三构造函数里的每个调用都值得停下来问一句会不会抛异常。我处理过多次因为构造函数里打开了文件、分配了内存、或者调用了外部库函数导致对象半构造状态下抛异常的线上事故。如果你不能确定异常安全性优先考虑把风险操作移出构造函数移到工厂函数或 init() 方法里这种做法虽然不是最优雅的 C但它在生产环境里能救命。初始化列表和构造函数本质上就一件事保证对象从出生那一刻起就处于完整、有效的状态。理解了这条主线很多细节就不是死记硬背了。这个主题后续还可以继续扩展移动构造函数与拷贝构造函数的取舍、自定义分配器下构造流程的变化、异常安全保证与构造函数的关系。不过那些属于另一个话题了先把初始化列表和构造函数的地基打牢看什么复杂场景都不会怕。
返回列表