Effective C++ 条款31:将文件间的编译依存关系降至最低
Effective C 条款31将文件间的编译依存关系降至最低在大型 C 项目中你是否经历过修改一个头文件引发全工程重新编译的痛苦本条款将教你如何打破这种编译依赖的枷锁让你的构建速度飞起来一、问题引入编译依赖的噩梦想象这样一个场景你正在维护一个拥有数百万行代码的大型 C 项目某天你修改了某个类的私有成员变量结果整个项目需要重新编译——哪怕其他模块只包含了这个类的头文件根本没有直接使用那个私有成员。为什么会这样因为 C 的编译模型是文本替换式的当编译器处理#include时它会将整个头文件的内容插入到当前文件中。这意味着任何对头文件的修改都会触发所有包含该头文件的源文件重新编译。// Person.hpp —— 一个看似普通的类定义#includestring// 引入了 string 的定义#includevector// 引入了 vector 的定义#includeAddress.hpp// 引入了 Address 的完整定义#includeDate.hpp// 引入了 Date 的完整定义classPerson{public:Person(conststd::stringname,constDatebirthday,constAddressaddr);std::stringgetName()const;DategetBirthday()const;AddressgetAddress()const;private:std::string name_;// 实现细节暴露给所有人Date birthday_;// 修改这里 全工程重编Address address_;// 哪怕只是改个变量名};问题分析问题影响成员变量类型暴露使用 Person 的代码必须包含 Date、Address 的头文件私有成员可见修改私有实现会触发所有依赖者的重编译头文件层层包含形成复杂的依赖网络编译时间指数级增长二、核心原则相依于声明式不要相依于定义式本条款的核心思想可以概括为一句话如果能够使用 object references 或 object pointers 完成任务就不要使用 objects如果能够尽量以 class 声明式替换 class 定义式。2.1 使用指针/引用替代对象C 有一个重要规则声明一个 class 指针或引用时不需要该 class 的完整定义只需要一个前向声明forward declaration即可。// 好的做法只需要前向声明classDate;// 前向声明——不需要 #include Date.hppclassAddress;// 前向声明——不需要 #include Address.hppclassPerson{public:Person(conststd::stringname,constDatebirthday,constAddressaddr);std::stringgetName()const;// 返回引用或指针避免包含定义式constDategetBirthday()const;constAddressgetAddress()const;private:// 使用指针可以大幅降低编译依赖std::shared_ptrDatebirthday_;// 或 std::unique_ptrDatestd::shared_ptrAddressaddress_;// 智能指针更安全};对比表格方式是否需要完整定义编译依赖程度适用场景Date date_;对象成员是高小型、稳定的类Date* date_;原始指针否低需要手动管理内存std::unique_ptrDate否C11低独占所有权std::shared_ptrDate否低共享所有权2.2 为声明式和定义式提供不同的头文件这是本条款的另一个重要建议。我们可以将接口声明和实现细节彻底分离// Person_fwd.hpp —— 只有声明没有定义// 这个文件极轻量可以放心地被大量文件包含#ifndefPERSON_FWD_HPP#definePERSON_FWD_HPPclassPerson;// 仅此而已#endif// Person.hpp —— 完整的接口定义#ifndefPERSON_HPP#definePERSON_HPP#includestring#includememory// 只需要前向声明不需要包含完整头文件classDate;classAddress;classPerson{public:Person(conststd::stringname,constDatebirthday,constAddressaddr);~Person();// 必须声明因为析构需要 delete 不完整类型std::stringgetName()const;constDategetBirthday()const;constAddressgetAddress()const;private:classImpl;// 前向声明实现类std::unique_ptrImplpImpl;// PIMPL 惯用法核心};#endif三、PIMPL 惯用法编译防火墙PIMPLPointer to IMPLementation指向实现的指针是实现编译隔离的最强武器。它将类的公有接口与私有实现完全分离。3.1 PIMPL 完整示例// Person.hpp —— 接口文件极轻量#ifndefPERSON_HPP#definePERSON_HPP#includestring#includememoryclassDate;classAddress;classPerson{public:Person(conststd::stringname,constDatebirthday,constAddressaddr);~Person();Person(Person)noexcept;// 移动构造Personoperator(Person)noexcept;// 移动赋值// 禁止拷贝或按需实现Person(constPerson)delete;Personoperator(constPerson)delete;std::stringgetName()const;intgetAge()const;std::stringgetAddressString()const;// 可以在不暴露实现的情况下修改行为voidupdateAddress(constAddressnewAddr);private:classImpl;std::unique_ptrImplpImpl;};#endif// Person.cpp —— 实现文件包含所有细节#includePerson.hpp#includeDate.hpp#includeAddress.hpp#includechronoclassPerson::Impl{public:Impl(conststd::stringname,constDatebirthday,constAddressaddr):name_(name),birthday_(birthday),address_(addr){}std::string name_;Date birthday_;Address address_;std::vectorstd::stringphoneNumbers_;// 随时可以增加字段std::string email_;};Person::Person(conststd::stringname,constDatebirthday,constAddressaddr):pImpl(std::make_uniqueImpl(name,birthday,addr)){}Person::~Person()default;// 必须在 .cpp 中定义因为 Impl 在这里才完整Person::Person(Person)noexceptdefault;PersonPerson::operator(Person)noexceptdefault;std::stringPerson::getName()const{returnpImpl-name_;}intPerson::getAge()const{// 使用 Date 的具体方法计算年龄autonowstd::chrono::system_clock::now();// ... 具体实现return25;// 简化示例}std::stringPerson::getAddressString()const{returnpImpl-address_.toString();}voidPerson::updateAddress(constAddressnewAddr){pImpl-address_newAddr;}3.2 PIMPL 的优势优势说明编译隔离修改私有实现不触发客户端重编译接口稳定公有接口一旦发布可以长期保持不变二进制兼容可以在不改变接口的情况下修改实现隐藏细节私有成员、第三方库依赖完全不可见加速编译大幅减少头文件包含链四、实际应用场景场景1跨平台抽象层// PlatformFile.hpp —— 跨平台文件操作接口classPlatformFile{public:PlatformFile(conststd::stringpath);~PlatformFile();boolopen(intmode);size_tread(void*buffer,size_t size);size_twrite(constvoid*buffer,size_t size);voidclose();private:classImpl;std::unique_ptrImplpImpl;// Windows 用 HANDLELinux 用 fd};客户端代码完全不需要知道底层是 Windows API 还是 POSIX API甚至可以在运行时切换实现。场景2减少第三方库暴露// Logger.hpp —— 日志系统接口classLogger{public:Logger();~Logger();enumLevel{Debug,Info,Warning,Error};voidlog(Level level,conststd::stringmessage);private:classImpl;std::unique_ptrImplpImpl;// 内部可能使用 spdlog、log4cpp 等};如果直接在头文件中#include spdlog/spdlog.h那么所有使用 Logger 的代码都会间接依赖 spdlog。使用 PIMPL 后spdlog 的依赖被完全隔离在.cpp文件中。场景3大型游戏引擎中的组件系统// RenderComponent.hppclassRenderComponent{public:RenderComponent();~RenderComponent();voidsetMesh(conststd::stringmeshPath);voidsetMaterial(conststd::stringmaterialPath);voidrender(constCameracamera);private:classImpl;std::unique_ptrImplpImpl;// Impl 内部包含// - Mesh* mesh// - Material* material// - Shader* shader// - 各种渲染状态缓存};五、注意事项与最佳实践5.1 使用std::unique_ptr时的陷阱// 错误在头文件中默认析构会导致编译失败classWidget{public:Widget();~Widget()default;// 错误此时 Impl 还不完整private:classImpl;std::unique_ptrImplpImpl;};原因std::unique_ptr的析构函数需要知道如何delete指向的对象如果在头文件中内联定义析构函数此时Impl还是不完整类型。正确做法// Widget.hppclassWidget{public:Widget();~Widget();// 只声明不定义private:classImpl;std::unique_ptrImplpImpl;};// Widget.cppWidget::~Widget()default;// 在这里定义Impl 已经完整5.2 性能考量方面影响建议内存分配多一次堆分配对大多数场景可接受访问开销多一层间接跳转现代 CPU 缓存友好影响微小内联优化无法内联私有方法将热点代码放在公有接口中对于性能极度敏感的类如数学库中的Vector3不建议使用 PIMPL。但对于业务逻辑类、管理类PIMPL 的收益远大于开销。5.3 与 Interface Class 的对比除了 PIMPL另一种降低编译依赖的方式是使用纯接口类Interface Class// IDevice.hpp —— 纯接口没有任何实现classIDevice{public:virtual~IDevice()default;virtualboolconnect()0;virtualvoiddisconnect()0;virtualintread(void*buffer,intsize)0;virtualintwrite(constvoid*buffer,intsize)0;};// 工厂函数返回具体实现std::unique_ptrIDevicecreateSerialDevice(conststd::stringport);std::unique_ptrIDevicecreateUsbDevice(intvendorId,intproductId);特性PIMPLInterface Class虚函数开销无有动态替换实现困难容易接口与实现绑定编译期运行期适用场景单一实现追求性能多实现需要运行时多态六、总结技巧核心思想适用场景前向声明用声明替代定义只需要指针/引用的场景指针/智能指针成员延迟对象构造类成员需要其他类型分离头文件提供轻量级前向声明头库对外接口PIMPL将实现完全隐藏需要长期维护的公共 APIInterface Class纯虚接口 工厂需要运行时多态请记住支持编译依存性最小化的一般构想是相依于声明式不要相依于定义式。基于此构想的两个手段是 Handle classesPIMPL和 Interface classes。程序库头文件应该以完全且仅有声明式的形式存在。掌握这些技巧你的项目编译时间将从喝杯咖啡缩短到喝口水团队协作效率也会大幅提升参考《Effective C》第三版Scott Meyers 著相关条款条款30透彻了解 inlining 的里里外外、条款32确定 public 继承塑模出 is-a 关系