← ALL ARTICLES

C++构建与对象模型

全库目录221
本文目录14

资产插件与宿主分别编译,公共头文件约定可见声明,链接器连接定义。本文沿构建产物进入对象内部,说明构建故障、布局、多态和异常保证。稳定的跨模块接口见 插件接口与对象借用。

C++构建与模块边界

构建过程先确定声明和定义怎样进入产物;动态加载以后,调用双方再按共同的ABI解释入口与参数。

C++ 构建链

插件的asset.cpp和宿主的editor.cpp分别编译,二者通过公共头约定CreateAsset。每个文件先经过下面的构建链:

text
Source
 -> Preprocess
 -> Compile
 -> Object File
    ├─ Link -> Executable / Dynamic Library
    └─ Archive -> Static Library -> 后续消费方的Link

预处理器展开 #include、宏和条件编译。编译器对每个翻译单元生成目标文件。链接器解析跨文件符号、库依赖和最终地址。静态库由归档工具汇集目标文件,符号解析留到消费它的最终链接。

若editor.cpp看不到参数类型,编译阶段就失败。声明可见而链接器找不到CreateAsset定义,会产生Unresolved External;两个目标文件都提供同一强符号则可能产生Multiple Definition。生成程序后找不到插件或导出入口,属于运行加载问题。入口存在但双方解释参数不同,则已经进入ABI不匹配,错误可能直到调用时才暴露。

排错先判断阶段,不要看到“编译失败”就反复改代码。

头文件与翻译单元

资产接口头同时被宿主和插件包含,通常保存声明、必要类型以及需要在调用侧实例化的内联与模板。实现放在.cpp,修改解码算法便不必让所有调用方重新编译;公共头改变则使包含它的翻译单元重新构建。

减少不必要的头文件包含:

  • 能前置声明时不引入完整定义;
  • 私有实现可使用 PImpl 隔离依赖;
  • 稳定公共接口与高频变化实现分开;
  • 隐式实例化通常需要模板定义可见;对支持的具体类型显式实例化后,可以把相应定义放在 .cpp,并用 extern template 抑制调用侧的重复实例化。

包含保护/#pragma once 防止同一翻译单元重复包含,但不解决跨翻译单元的单一定义规则。

例如a.cpp与b.cpp都包含定义了普通全局变量的公共头,各自编译都能成功,链接时却可能产生重复定义。包含保护只在单个翻译单元内起作用,不能合并这两份定义。让头只声明extern int counter,并在一个.cpp提供定义,才使两个调用方引用同一实体;C++17的内联变量则遵循另一套允许多处定义的契约。

静态与动态库

静态库在链接时把所需目标文件合入最终产物。部署简单,但多个程序可能各自包含一份,更新需要重新链接。

这里的资产处理器采用动态库,宿主按受控搜索路径加载插件及依赖,再解析导出入口。双方必须匹配调用约定和参数布局;创建对象后,还要保证它的析构函数所在模块一直加载到对象销毁。独立替换因此要求先排空旧模块的调用、对象与回调,插件关闭与卸载沿这条顺序关闭插件。

跨 DLL 分配、另一侧释放若使用不同堆/CRT,可能崩溃。边界常提供成对 Create/Destroy,由分配方负责释放。

ABI 与 C接口

若直接让CreateAsset返回std::string或C++对象,宿主就同时依赖插件的类布局、标准库实现和异常约定。这里用C ABI导出入口:整数规定宽度,字节缓冲配长度,对象以不透明句柄返回。接口头提供版本和结构大小,失败返回错误码;消息写入调用方缓冲,或由插件提供对应释放函数。这样宿主消费的是明确的接口字段,而不是猜测插件私有对象的布局。

不要跨边界直接传 std::string、std::vector 或编译器私有对象,除非所有模块严格锁定同一工具链。

类型布局与资源语义

接口字段的字节布局决定调用双方读到什么,对象的复制、移动和析构则决定资源由谁保留。两者共同约束插件内部实现怎样接到公共入口。

C++ 类型与布局

插件内部仍可用STL容器、迭代器和算法保存资产。模板在编译期生成具体类型代码,内部选择不必暴露为公共接口。继承与虚函数支持运行时多态,但会引入对象布局、间接调用和 ABI 约束;数据导向热路径更常用显式数据与批处理。

结构体的填充字节由成员对齐要求决定,成员顺序会改变 sizeof。联合体让多个成员共享同一段存储,必须记录当前有效成员;现代 C++ 更适合用 std::variant 表达带标签联合。任何跨文件、网络、GPU 或插件边界的数据结构都应固定宽度、布局和版本,不能直接依赖编译器默认内存表示。

C++ 对象模型与虚函数

资产插件内部可以用基类管理不同解码器。普通非虚成员函数不存放在每个对象内。对象主要保存非静态数据成员和编译器需要的隐藏信息。含虚函数的对象通常带一个虚表指针,指向该动态类型的虚函数表。通过基类指针或引用调用虚函数时,程序根据动态类型查表完成运行时分派。

标准规定的是多态行为,不规定虚表的具体内存布局。常见实现可以帮助理解成本,但不能作为跨编译器 ABI 契约。

解码器的派生实现用override让编译器核对重写签名,稳定终点可用final限制扩展;接口中的纯虚函数=0要求具体解码器提供实现。宿主若通过普通基类指针delete解码器,基类必须提供虚析构,派生资源才能按正确类型释放。

构造和析构期间,虚调用只到当前构造层级。基类构造函数因此不能调用虚函数来初始化尚未构造的派生缓冲。解码器也以指针或引用传递;把派生对象按值赋给基类会切掉派生部分,后续再调用已经无法恢复原解码器状态。

虚调用的主要代价是一次间接调用,并可能限制内联。真正风险通常是对象分散、指针追逐和所有权不清。稳定扩展点适合接口多态,海量同构对象的热循环更适合连续数据与显式类型分组。

复制、移动与异常安全

解码器获得文件和输出缓冲后,失败路径也必须释放它们。资源类型应优先遵守 Rule of Zero:让标准容器和智能指针管理资源,类型本身不手写析构、复制和移动。直接持有资源的 C++ 类型,需要一起设计析构、复制构造、复制赋值、移动构造和移动赋值。这是五法则(Rule of Five)。优先用标准资源包装类型承担所有权,减少手写这些操作的需要。

移动操作按类型契约转移或重建资源。标准库类型的移后对象通常处于有效但未指定的状态,具体可执行哪些操作仍取决于操作前提;自定义资源类型也要明确移后状态与允许的操作。std::move 只是把表达式转换为右值引用,不会自动搬运;是否移动取决于目标类型是否实现移动操作。

容器和工具代码常讨论三种异常保证:基本保证要求失败后没有资源泄漏且对象不变量成立,值可以变化;强保证要求因异常失败时状态不变;不抛异常保证要求异常不会离开函数。RAII、先构造临时结果再交换,以及不抛异常的移动能帮助实现更强保证。

例如赋值要同时更新缓冲与长度,先释放旧缓冲再分配新缓冲,分配失败就无法保持旧值。先用RAII临时对象分配并复制全部数据,成功后以不抛异常的交换提交,失败则临时对象释放自身,原对象保持不变。强保证依赖提交动作不失败;若交换还要分配,这条推理便不成立。

布局与边界字节

固定宽度不自动固定布局。例如uint8_t后接uint32_t,在第二字段按4字节对齐的常见ABI下,中间会插入3字节填充。调用双方对结构尺寸、字段偏移、对齐和调用约定使用同一契约,并在各自构建中检查sizeof与offsetof。extern "C"约束语言链接,不会取消填充、统一大小端或把无效指针变成安全输入。文件和网络因此逐字段编码;同进程插件才在已锁定的ABI下共享结构内存。

Win32 与 COM 边界

本节以插件M中的解码器A为例:A在界面线程T创建窗口并注册消息来源。Win32 GUI程序从消息队列取出事件,经TranslateMessage和DispatchMessage送到窗口过程WndProc。WPARAM、LPARAM的含义由消息类型决定,平台层据此解码为稳定事件,宿主跳板再按模块代次决定能否交给M。来源注册、已排队事件和正在执行的窗口过程分别持有状态,关闭时才能分别注销、取消和排空。

COM用接口与二进制契约连接组件。对象通过QueryInterface查询接口,用AddRef/Release管理引用计数,接口以GUID标识。资产解码器持有的COM接口也属于关闭资源;智能指针只管理引用释放,还需遵守接口的Apartment、线程模型与封送要求。STA中的直接调用限定于所属线程;同一MTA允许多个线程按对象的同步契约直接调用。

COM引用也按交接点计数。成功的QueryInterface给调用者一份需要Release的接口引用;借用指针只在原所有者保证寿命期间使用,要留到异步任务就取得独立引用。Release降到零后对象可立即销毁,不能继续通过该指针查询状态。线程先初始化所需Apartment,释放自己持有的接口后再结束对应COM初始化;跨Apartment按接口契约封送,复制一个裸指针不会把线程访问权限一并转移。

实现与证据

本文说明构建、对象语义和Win32/COM接口规则;关闭协议的完整推演见插件关闭与卸载和线程受限资源的关闭。尚未编译动态库、加载宿主或执行实际COM异步撤销;编译、运行与性能结果分别验收。

相关主题

参考资料