十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

C++委托构造函数:减少重复初始化,提升代码质量

C++委托构造函数:减少重复初始化,提升代码质量 写构造函数大概是C里最磨人的事情之一。类一复杂、重载一多好几处构造函数体里就会躺着几乎一模一样的初始化代码打开文件、设置默认参数、校验状态、给资源分配空间……改了一个构造函数忘了另一个bug就悄悄来了。我早期常用的逃生通道是抽一个private的init()函数但遇到const成员和引用成员时这条路就彻底断了。C11引入的委托构造函数才算是把这个坑从根上填平了。这篇文章我从实际项目的角度把委托构造函数的用法、限制、坑和设计习惯一次讲清楚。不光是语法层面还会重点解释几个“为什么”——为什么必须在初始化列表里委托为什么委托链不能成环为什么有人宁可用默认实参也不用委托。适合已经能写基本C类、但想摆脱重复初始化代码的读者如果你正准备重构旧代码这篇的实战示例也可以直接抄。1. 为什么要引入委托构造函数重复代码与init函数之痛1.1 构造函数重复初始化代码的真实场景先看一段非常常见的代码。假设你写一个日志类有三个构造函数一个用默认文件名一个指定文件名一个同时指定文件名和日志级别。class Logger { public: Logger() { Open(app.log); SetLevel(INFO); SetTimeFormat(%Y-%m-%d %H:%M:%S); } explicit Logger(const std::string file) { Open(file); SetLevel(INFO); SetTimeFormat(%Y-%m-%d %H:%M:%S); } Logger(const std::string file, int level) { Open(file); SetLevel(level); SetTimeFormat(%Y-%m-%d %H:%M:%S); } private: void Open(const std::string file); void SetLevel(int level); void SetTimeFormat(const char* fmt); };这段代码的问题一眼就能看出来三处初始化代码逻辑基本一样只是参数不同。如果以后要加一个“是否按天滚动日志”的开关你得改三个构造函数漏改一个就是线上事故。这还只是三行真实项目里一个类有五六个构造函数每个里塞十几行初始化逻辑的大有人在。这类问题的本质是构造函数之间的公共初始化逻辑缺少复用机制。你可能会想到用默认参数合并构造函数、抽一个公共函数但这些问题并没有看上去那么好解决。1.2 用init成员函数救场为什么只能救一半一个最朴素的想法是把公共逻辑抽成一个private的init函数每个构造函数都调用它。class Logger { public: Logger() { Init(app.log, INFO); } explicit Logger(const std::string file) { Init(file, INFO); } Logger(const std::string file, int level) { Init(file, level); } private: void Init(const std::string file, int level) { Open(file); SetLevel(level); SetTimeFormat(%Y-%m-%d %H:%M:%S); } };这个方案的代码重复确实减少了但它治标不治本。首先const成员和引用成员没法在init函数里初始化。这两类成员必须出现在构造函数的成员初始化列表里进入函数体时已经初始化完毕你不可能等到init函数里再赋初值。举个典型例子class Connection { public: Connection() { Init(); } explicit Connection(const std::string addr) : addr_(addr) { Init(); } private: const std::string addr_; // 只有第二个构造函数能初始化它 int timeout_{0}; void Init() { timeout_ 30; // 在这里不能写 addr_ ..., 因为addr_是const成员 } };默认构造函数里addr_根本没有初始化入口这种代码能不能编译过去全看const成员有没有默认成员初始化器。一旦遇到引用成员、较真的const场景init函数方案就崩了。其次init函数无法覆盖“基类初始化”这一层。如果派生类需要根据不同的参数去调用基类的不同构造函数你不可能在一个成员函数里完成基类构造。这正是委托构造函数能解决的另一个场景——虽然它常在“减少重复”里被介绍但它真正强大的地方在于把“如何初始化当前对象的基类和成员”这一整套逻辑完整转交给另一个构造函数。还有一点容易忽略init函数是普通成员函数它可以在对象生命周期里的任意时刻被再次调用。如果某个成员已经持有资源第二次调用init可能造成重复释放、资源泄漏。虽然可以用状态标志位防一手但这样代码已经变得足够复杂了。1.3 C11给的答案让构造函数自己去“委托”委托构造函数的语法并不复杂在构造函数的初始化列表里直接调用同类另一个构造函数后续的构造函数体在目标构造函数执行完之后继续执行。class Logger { public: Logger() : Logger(app.log, INFO) {} explicit Logger(const std::string file) : Logger(file, INFO) {} Logger(const std::string file, int level) { Open(file); SetLevel(level); SetTimeFormat(%Y-%m-%d %H:%M:%S); } };我经常跟同事这么解释委托构造函数不是你写了两个构造函数而是你定义了一个“总构造函数”其他构造函数把自己的初始化工作交给它去干。上面例子中Logger(const std::string, int)是那个“总构造函数”其余两个构造函数通过初始化列表把活儿派给它。调用Logger()时实际执行顺序是先进入Logger(app.log, INFO)再进入Logger(const std::string, int)最后回到Logger()的空函数体。这个特性从C11开始就是标准的一部分GCC、Clang、MSVC都支持得很好不需要任何第三方库。2. 委托构造函数的语法与入门三分钟跑通2.1 最小可运行示例下面这个示例是委托构造函数最基础的形态建议直接抄到编译器里跑一遍观察构造顺序#include iostream class Config { public: Config() : Config(1024, 8) { std::cout Config() body std::endl; } explicit Config(int buffer_size) : Config(buffer_size, 8) { std::cout Config(int) body std::endl; } Config(int buffer_size, int thread_count) : buffer_size_(buffer_size), thread_count_(thread_count) { std::cout Config(int, int) body std::endl; Validate(); } private: int buffer_size_; int thread_count_; void Validate() const { if (buffer_size_ 0 || thread_count_ 0) { throw std::invalid_argument(invalid config); } } }; int main() { Config c1; std::cout ---- std::endl; Config c2(2048); std::cout ---- std::endl; Config c3(4096, 16); }输出结果是Config(int, int) body Config(int) body Config() body ---- Config(int, int) body Config(int) body ---- Config(int, int) body注意最后一行的输出调用Config(4096, 16)时只有总构造函数体执行了因为它本身就是被委托的目标。而前两个调用会先执行目标构造函数再执行发起委托的构造函数体。整个链上成员初始化列表只执行一次——在总构造函数里。其他委托构造函数不会“重新初始化”成员它们只负责补充执行自己的函数体。2.2 为什么必须在初始化列表里调用而不是函数体里刚接触委托构造函数的人最容易产生一个疑问为什么不能在构造函数体里直接写Config(1024, 8);这个问题的答案很有意思——如果你在函数体里这样写编译器不会把它理解为“初始化当前对象”而是理解为“创建一个临时对象”。Config() { Config(1024, 8); // 错这里创建了一个新的临时Config然后立刻销毁 }这和委托构造完全是两码事。函数体里的Config(1024, 8)会构造一个全新的、与当前对象无关的临时对象它不会初始化当前对象的任何成员甚至可能导致你误认为“已经调用了总构造函数”实际上成员仍是未初始化的。临时对象销毁时还可能有析构副作用非常隐蔽。只有初始化列表里的同类构造函数调用才被标准明确为“委托”。语法上可以这样理解初始化列表的职责是“完成基类和成员的初始化”既然当前构造函数决定把这份工作转交出去那就必须完整转交而不是在函数体里“事后补做”。2.3 与“默认实参”方案的取舍对比委托构造函数出现之前很多人会用“默认实参”合并多个构造函数class Config { public: explicit Config(int buffer_size 1024, int thread_count 8) : buffer_size_(buffer_size), thread_count_(thread_count) {} };这样写确实能减少构造函数数量而且对于“所有参数都有合理默认值”的场景它比委托构造函数更简洁。那是不是可以不用学委托构造函数了不一定。默认实参有几个限制。第一它把“用户能否用某个形式构造对象”的选择权变模糊了一旦构造函数有多个参数你能写出很多种组合但这些组合的语义可能并不都是你想要的。第二如果不同构造函数需要不同的访问权限——比如一个需要校验、一个跳过校验——默认实参做不到。第三C的构造歧义在某些重载下会更复杂尤其是多个构造函数都有默认实参时裸调Config c;该匹配谁编译器很容易傻眼。委托构造函数适合的典型场景是公开构造函数有多种形态但最终都收敛到一个私有或保护的目标构造函数上。比如class HttpClient { public: HttpClient() : HttpClient(localhost, 80) {} explicit HttpClient(const std::string host) : HttpClient(host, 80) {} HttpClient(const std::string host, int port) : host_(host), port_(port) { // 真正的初始化逻辑 } };这种“不同公开接口统一内部逻辑”的形态默认实参表达不出来而委托构造函数表达的非常清晰。两者不是替代关系而是取舍关系参数天然收敛、组合数量可控优先用默认实参类对外暴露多种构造语义或者内部有复杂的收敛路径优先用委托构造函数。3. 容易踩的坑限制、初始化顺序与异常传播3.1 委托后不能有其它成员初始化器这是新手最容易踩的编译错误。委托构造函数一旦使用了委托初始化列表里就只允许写目标构造函数不允许再写其它成员的初始化项。class Demo { public: Demo() : Demo(0), value_(42) {} // 编译错误 explicit Demo(int value) : value_(value) {} private: int value_; };上面这行代码会报错错误信息类似“delegating constructors cannot have other mem-initializers”委托构造函数不能有其它成员初始化器。这个限制的原因很直接初始化列表的职责已经转交给目标构造函数了如果你再写一个value_(42)那么value_到底由谁初始化标准选择了一刀切——委托了就不能再指定任何成员初始化。解决方法也很简单如果确实有需要覆盖的成员初始化值把它作为参数传给目标构造函数或者在目标构造函数里统一处理。不要试图“既要委托、又要自己偷偷初始化一个成员”这条路在标准上就是死的。3.2 委托链不能成环否则编译失败委托链“A委托BB委托A”在逻辑上就是一个无法结束的递归编译器会直接拒绝。看这个例子class Bad { public: Bad() : Bad(5) {} explicit Bad(int v) : Bad() {} // 编译错误委托环 };GCC会报类似于“call to delegating constructor of Bad is ambiguous”或者直接提示存在循环。Clang的错误信息则更明确会说委托构造形成环。这个限制不仅是标准规定更是逻辑必然。所有构造函数都有同一个身份标签初始化当前对象。如果A等B、B等A那初始化就失去了起点。实际项目中我见过的成环委托都是重构时不小心引入的所以一旦编译报错先检查是不是在改构造函数参数时把调用关系串成了环。3.3 const、引用成员到底在哪一个构造函数里初始化回到前面提到的const成员问题。委托构造链上只有真正的“目标构造函数”会执行成员初始化列表也就是说const成员和引用成员的初始化点只能落在目标构造函数里。class Server { public: Server() : Server(127.0.0.1, 8080) {} explicit Server(const std::string ip) : Server(ip, 8080) {} Server(const std::string ip, int port) : ip_(ip), port_(port) {} // 这里才是const成员的初始化点 private: const std::string ip_; int port_; };这种设计其实是一种优点所有成员初始化集中在一条链的终点。阅读代码时你想知道成员怎么初始化的只需要看目标构造函数即可不用在所有构造函数之间来回翻。这比“每个构造函数都写一段成员初始化列表”更利于维护。这里有个容易混淆的意识委托构造函数自己虽然不初始化成员但它的函数体里已经可以完整地访问成员。因为当目标构造函数执行完后所有成员都已经初始化完毕当前委托构造函数体再执行时成员就是合法可用的。这一点和“对象构造完成后才能访问成员”的顺序是一致的。3.4 目标构造函数抛异常会发生什么构造函数抛异常是整个对象构造失败的过程。放在委托构造链上也是一样如果目标构造函数在初始化列表或函数体中抛出异常那么这个对象构造失败委托构造函数体不会执行也不会调用析构函数因为对象从未构造完成。但这里有个冷门细节可以用委托构造函数的函数try块捕获目标构造函数抛出的异常。由于委托发生在初始化列表阶段所以函数try块理论上可以捕获到从被委托构造函数传出的异常。例如class Demo { public: Demo() try : Demo(false) { // 正常构造路径 } catch (const std::exception e) { std::cerr caught: e.what() std::endl; throw; // 对于构造函数try块捕获后必须重新抛出或终止 } explicit Demo(bool fail) { if (fail) throw std::runtime_error(init failed); } };不过在实际工程里我很少用这个技巧。构造函数try块的catch块里吞掉异常几乎没有任何意义——因为对象已经构造失败不可能在catch里“补救”成成功状态。它的合理用途只有两个记录日志、或把异常转换成另一种类型再抛出。如果你不需要这两种操作就让它自然传播。还有一个容易被忽略的点如果目标构造函数里某个成员已经成功构造了但后续函数体抛异常已经构造的成员会自动析构资源不会泄漏。这是C对象生命周期的基本保证委托构造函数并不会破坏它。3.5 继承、模板与委托构造的联动委托构造函数是“同一个类内部”的机制它不会自动跨越继承层级。派生类无论用哪个构造函数都必须显式指定基类构造函数这点和普通构造规则一样。委托不会偷偷帮你调用基类构造函数。class Base { public: Base(int x) : x_(x) {} int x_; }; class Derived : public Base { public: Derived() : Derived(0) {} // ok委托 explicit Derived(int v) : Base(v) {} // 必须显式初始化基类 };另外C11还有一种“继承构造函数”语法using Base::Base;它让派生类继承基类的构造函数。注意这和委托构造函数是两回事别混在一起。继承构造函数解决的是“派生类不需要重写同名构造函数”的问题委托构造函数解决的是“同一个类内多个构造函数共享初始化逻辑”的问题。类模板里也可以用委托构造函数语法完全相同。唯一要注意的是模板构造函数参与重载决议时情况会更复杂目标构造函数如果是模板实例需要确认它能被正确推导。实际使用中我建议模板场景下委托链不要拉太长否则编译错误信息会让排查体验比较糟糕。4. 实战示例与最佳实践单一初始化点4.1 一个接近真实项目的例子下面这个例子是我在一套消息队列客户端里写过的模式结构简化了但骨架保留了。先看需求类有默认构造、指定队列名的构造、指定队列名回调的构造无论哪种构造方式最终都要检查参数、建立内部的缓冲区。#include string #include functional #include stdexcept class MessageQueue { public: MessageQueue() : MessageQueue(default, nullptr) {} explicit MessageQueue(const std::string queue_name) : MessageQueue(queue_name, nullptr) {} MessageQueue(const std::string queue_name, std::functionvoid(const std::string) on_message) : queue_name_(validate(queue_name)), on_message_(std::move(on_message)), buffer_size_(4096) { Open(); } private: static std::string validate(const std::string name) { if (name.empty()) { throw std::invalid_argument(queue name cannot be empty); } return name; } void Open() { // 实际的打开队列、分配缓冲逻辑 } std::string queue_name_; std::functionvoid(const std::string) on_message_; int buffer_size_; };这里的核心是只有MessageQueue(const std::string, std::function...)是真正干活的构造函数其余两个都是在转发参数。校验放在静态函数validate里目标构造函数初始化列表就可以放心使用校验后的值。这个模式在真实项目里非常常见相当于把“默认参数”和“参数校验”统一在了一个入口。有人可能会问为什么不直接用默认实参因为这里是“队列名为空时需要抛异常”而且nullptr、std::string、std::function三者在重载决议上组合起来用默认实参写会显得语义模糊。委托构造让每个公开构造函数的含义都非常清楚默认构造就是对“default”队列的便捷封装。4.2 委托构造与默认成员初始化器配合代码量再降一截C11除了带来委托构造函数也带来了默认成员初始化器。两者配合使用可以把构造函数体压缩到非常干净。class Timer { public: Timer() : Timer(1000, true) {} explicit Timer(int interval_ms) : Timer(interval_ms, true) {} Timer(int interval_ms, bool repeat) : interval_ms_(interval_ms), repeat_(repeat) { Start(); } private: int interval_ms_ 500; // 默认成员初始化器只在未被显式初始化时生效 bool repeat_ false; std::string name_ anonymous; void Start(); };注意上面这个类目标构造函数里只显式初始化了interval_ms_和repeat_name_没有出现在初始化列表中所以它使用默认成员初始化器anonymous。这就是默认成员初始化器和委托构造函数的分工配合委托构造函数负责“必须由参数决定的成员”默认成员初始化器负责“保持默认值即可的成员”。我自己的经验是成员越多越要优先考虑默认成员初始化器。因为默认成员初始化器是每个成员旁边就写好的你不需要去构造函数堆里翻而委托构造函数的价值在于多个构造入口共享一段初始化流程。两者合在一起能让类的定义顺序变得非常直观。但也要注意一个节奏问题如果一个类已经有五六种构造形态每个形态还要大量覆盖默认成员初始化器那就不是“委托结构清晰”而是类本身太胖。这时候更该考虑用工厂函数或者Builder模式而不是硬塞给构造函数。4.3 设计建议委托链短一点主构造函数只有一个结合几个项目经验总结几条委托构造函数的设计原则。第一目标构造函数最好只有一个。虽然标准允许委托链多级比如A委托B、B再委托C但链越长阅读成本越高。我一般最多允许两级一个无参构造函数委托到带参构造函数带参构造函数就是终点。如果还需要第三个层次说明类初始化逻辑可能被拆得过细了建议把一部分逻辑提取成成员函数。第二目标构造函数可以设为private或protected逼着用户走公开入口。这在需要强约束参数的类里很好用。公开构造函数只负责传默认值真正带校验的构造函数不对外暴露。class ImageLoader { public: explicit ImageLoader(const std::string path) : ImageLoader(path, 0, 0) {} private: ImageLoader(const std::string path, int width, int height) : path_(path), width_(width), height_(height) { if (width 0 || height 0) { throw std::invalid_argument(invalid size); } Load(); } };第三不要在委托链里的多个构造函数体里都做“大事”。如果A委托B然后A的函数体里还做一次读文件、B的函数体里又做一次读文件那构造逻辑就是重复的。正确做法是主构造函数完成所有核心逻辑委托构造函数体里基本不做事或者只做和具体入口相关的细节调整。这里最容易出现的反模式是代码演进之后委托构造函数体里被塞进了大量本应属于主构造函数的逻辑结果是调用一个构造函数会执行两遍“初始化动作”造成重复资源分配。第四委托构造函数体里可以继续给成员赋值。比如目标构造函数设了一个默认线程数委托构造函数体可以再根据外部条件覆盖。这是一个很灵活的特性但也容易把秩序搞乱。我建议这种“覆盖”最多出现在一组有明确语义的构造函数里不要写得太随意。否则一个成员值最后是多少得顺着委托链追好几层维护成本就上来了。5. 常见编译错误与排查速查表5.1 编译错误信息对照表我整理了一份委托构造函数相关的编译错误速查表基本都是实际项目里撞过的错误现象典型错误信息原因解决办法委托构造函数里出现了其它成员初始化delegating constructors cannot have other mem-initializers委托后不再允许单独初始化成员把成员初始化移到目标构造函数或通过参数传递委托链成环call to delegating constructor of X is ambiguous / cycleA委托B、B又委托A梳理委托关系明确唯一的目标构造函数函数体里调用同类构造但没有效果无编译错误但成员未初始化函数体里的X(...)创建的是临时对象不是委托把调用移到初始化列表目标构造函数是private外部无法访问calling a private constructor of class X目标是私有构造函数被外部间接调用时访问控制问题确认目标构造函数的访问级别是否匹配构造函数try块捕获异常后未重新抛出terminate called after throwing an instance of ...构造函数try块要求捕获后重新抛出或终止在catch块里写throw;或使用调试日志后重抛这张表里最容易误判的是“函数体里调用同类构造”。编译器不报错程序也照常运行但你会发现成员值不对。遇到这种症状第一件事就是检查那个看起来像委托的调用到底写在初始化列表还是函数体里。5.2 容易被误诊的两个问题第二个容易被误诊的问题是“重载二义性”。委托构造函数让类的构造函数数量不会减少但形态变多了尤其是在同时使用委托构造函数和默认实参时调用Config c;可能匹配多个构造函数。我排查过的案例里最常见的写法是class Config { public: Config() : Config(1024) {} explicit Config(int size 1024) {} };这个类里Config()可以直接匹配默认构造函数也可以匹配Config(int)的默认实参版本编译器会报二义性。要解决二义性建议要么让默认构造函数直接委托到唯一目标构造函数要么干脆不要两个构造函数同时具备“无参可调用”的能力。第三个容易被忽略的问题是委托构造函数和成员默认初始化器的覆盖顺序。如果目标构造函数没有初始化某个成员但委托构造函数体里又给这个成员赋了值很多人会误以为“在构造之前赋值导致成员没有初始化”。其实不是目标构造函数执行完后成员已经初始化完毕委托构造函数体里的赋值就是对已初始化成员的普通赋值。这个顺序本身没问题但代码读起来容易疑惑所以我更建议把需要覆盖的值也通过参数传给目标构造函数保持单一初始化点。5.3 编译器与标准版本注意事项委托构造函数是C11特性。理论上只要编译器支持C11就可以用。实际项目里要注意这么几件事老旧的MSVC版本比如VC2012之前的编译器对C11支持不完整委托构造函数可能无法使用或者行为有差异。如果你还在维护配VS2010甚至更老工具链的项目请老老实实用init函数方案不要硬上委托。GCC和Clang从很早就支持委托构造函数。新版工具链基本没有任何坑但如果你开启了-stdc98或-stdc03编译器会直接报错。使用CMake等构建系统时记得确认C标准设置。有些静态分析工具对委托构造函数的支持仍然不完善可能在“跳转定义”“高亮成员初始化”等场景出现奇怪的提示。这属于工具问题不是代码问题别因此怀疑设计。还有一点和标准版本相关的细节在C11初期委托构造函数的使用限制比较多比如与默认成员初始化器的一些交互在旧标准下不明确。后来C14、C17对相关规则做了澄清和放宽。如果你在C17及以上环境中开发基本可以放心地把委托构造函数和默认成员初始化器混用但如果你的编译环境还是C11模式遇到奇怪行为时优先查一下标准版本不要硬猜。6. 写在最后的个人经验委托构造函数在我写C的这十来年里属于“用之前觉得无所谓、用之后回不去”的特性。它最值钱的地方不是省几行代码而是把“初始化逻辑只写一份”的纪律性固化到了语言层面。以前靠自觉抽init函数现在编译器逼着你收敛到一个目标构造函数这比任何代码评审意见都好使。我最后再分享一个小技巧重构旧代码时先别急着全量改。找一个构造函数最多、重复最严重的类把它的初始化逻辑收敛到一个私有或保护的目标构造函数其他构造函数全部改成委托。跑一遍测试对比功能无变化再推广到其他类。这样一个类一个类地推进风险比一把梭小得多。委托构造函数本身不神奇但它能帮你把“初始化流程”这件事变得有序而有序就是复杂工程里最值钱的东西。
返回列表