ARTICLE DETAIL

资讯详情

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

COM组件对象模型:接口契约、引用计数与最小实现实战

COM组件对象模型:接口契约、引用计数与最小实现实战 接手一个跑了十年以上的桌面项目翻到某个模块的实现代码往往会看到一个眼熟的东西一个导出了DllGetClassObject的 DLL旁边跟着一堆形如{0002DF01-0000-0000-C000-000000000046}的字符串调用方从来不直接new对象而是先CoCreateInstance一把。这套写法的底层就是 COM组件对象模型。它是 Windows 平台上组件复用的地基从系统的 Shell 扩展、DirectX、WMI到 Office 自动化、各类音视频插件、工业上位机软件几乎都能看到它的影子。很多人第一次接触 COM 是被工作推着走的老板说要调用第三方提供的组件工程师打开文档一看全是接口定义和 GUID一头雾水地硬啃。这篇内容就是给这批人准备的也为那些写过不少 COM 代码但一直没系统梳理过的人提供一次完整的复盘。我打算从为什么会有 COM讲起把它最核心的几个契约拆开揉碎再手写一个最小可用的组件跑通最后把踩过的坑摊开来说。整个系列会分几篇这是打地基的第一篇重点解决是什么和为什么这么设计。1. 先搞清楚 COM 到底在解决什么问题很多人学 COM 卡住不是因为概念难而是因为一上来就陷进QueryInterface、AddRef、Release三个函数的细节里不知道它们为什么会存在。先退一步看看没有 COM 的时代C 程序员是怎么复用别人代码的就能明白这套设计不是凭空冒出来的。1.1 从源码级复用到二进制级复用的跨越C 的类复用是源码级的。你把头文件#include进来把.lib静态库或者.dll的导入库链接上编译器按你的编译选项重新生成代码。问题就出在这类的内存布局、名字修饰规则、虚表结构、异常处理模型、STL 的 ABI全都跟编译器版本、运行时库版本、编译开关强绑定。你用一个 VS2015 编译的库配 VS2022 的项目很可能链接期就报一堆LNK2019运气好链接过了运行期一个虚函数调用跳错位置直接崩。这种二进制不兼容是 C 生态里最经典的痛。COM 的思路是把复用下沉一层。它不要求双方共享头文件、不要求编译器一致、不要求同一门语言甚至不要求同一个进程。它只规定一件事调用方和被调用方通过一张函数指针表来通信这张表的布局、索引、调用约定都被严格约定死了。只要双方都遵守这个约定具体的实现语言、编译器、内存分配方式可以完全不同。这就是二进制级复用也是 COM 最本质的价值主张。这里要区分一个容易混淆的点COM 的复用单位不是类而是接口。一个组件内部可以有一堆类但对外只暴露接口。调用方拿到的是接口指针看不见对象本身的任何细节。这种信息隐藏比 C 的private更彻底因为对方连你的对象有多大、里面有什么成员都不知道。1.2 接口即契约为什么说接口设计比实现更重要COM 圈子里有句老话接口一旦发布就不能改。这不是规矩是物理约束。接口的二进制形式就是一张函数指针数组数组的下标就是函数的身份。你在中间插一个函数后面所有函数的偏移全部错位二进制兼容瞬间崩塌所有已经编译好的调用方全部失效。所以 COM 接口的扩展方式只能是新增接口绝对不能修改已有接口。这个约束带来一个反直觉的后果设计接口的时候你必须比设计普通 API 更谨慎因为改不动。我见过不少团队外围功能三周就迭代一版接口层却要从容规划半年就是这个原因。实践中比较稳妥的做法是把接口切得足够细一个接口只承担一组内聚的能力比如读取配置和写入配置分两个接口查询状态和修改状态分两个接口。粒度细了将来扩展时新增接口的成本就低老接口不用动。还有一点值得强调接口方法应该尽量设计成无副作用或者副作用明确的。COM 的调用可能发生在任何线程、任何 apartment 里调用方对时序的假设往往比你想象的更弱。一个隐式修改全局状态的方法在多线程环境下就是定时炸弹。1.3 内存布局接口指针到底指向什么理解 COM 绕不开这个问题。假设你有一个接口IFoo它声明了三个方法A、B、C那么IFoo*这个指针实际指向的是一块内存这块内存的第一个机器字是一个指向函数指针数组的指针数组里依次是A、B、C的地址顺序必须和声明顺序严格一致。// 编译器眼中的 IFoo 对象布局简化示意 struct IFooVtbl { HRESULT (*QueryInterface)(IFoo*, const IID, void**); ULONG (*AddRef)(IFoo*); ULONG (*Release)(IFoo*); HRESULT (*A)(IFoo*, /* 参数 */); HRESULT (*B)(IFoo*, /* 参数 */); HRESULT (*C)(IFoo*, /* 参数 */); }; struct IFoo { IFooVtbl* lpVtbl; };注意前三个位置永远被QueryInterface、AddRef、Release占着这就是IUnknown。所有 COM 接口都必须从IUnknown派生也就是前三个槽位必须一致。有了这个约定调用方拿到一个不知道具体类型的接口指针时可以安全地调用QueryInterface去问你支持不支持某某接口。这是整个 COM 里唯一一个不依赖任何预先知识的万能操作。用 Go 或者 Rust 的人可能会觉得这像是 interface 或者 trait object思路确实接近区别在于 COM 把这套东西固定到了 ABI 层面跨语言、跨编译器都能对上。这背后其实就是 C 语言调用约定的功劳——所有 COM 方法都是stdcall32 位上64 位只有一种调用约定不再区分参数从右往左压栈栈由被调用方清理。选择stdcall而不是cdecl的原因也很实际跨进程、跨语言调用时被调用方清理栈更安全不会因为调用方和被调用方对参数大小的理解不一致导致栈失衡。2. 四大基石接口、引用计数、GUID、HRESULTCOM 的规矩不少但真正撑着整套体系运转的核心只有四样东西。把这四样吃透剩下的都是细节。2.1 IUnknown三个函数撑起整个体系IUnknown的方法只有三个但它承担的职责相当重每一个都有严格的语义约定违反任意一条都会导致难以定位的问题。QueryInterface(riid, ppv)的行为规则在官方文档里写得很清楚我按自己的理解复述一遍。规则一自反性用自己接口的 IID 去查询必须返回S_OK并且返回同一个指针值。规则二对称性如果从 A 接口能查到 B 接口那么从 B 接口也必须能查回 A 接口返回的指针必须指向同一个对象实例。规则三传递性A 能查到 BB 能查到 C那 A 就必须能直接查到 C。规则四静态性一个对象的接口集合在创建后就不能改变不能这次查得到下次查不到。这四条规则看着琐碎价值在于调用方可以根据任意一个接口指针推导出对象的完整能力集合不用担心这条路走得通那条路走不通。工程上违反对称性是最常见的通常是因为实现者给不同的接口用了不同的实现类或者QueryInterface里到处是if-else复制粘贴改一处忘一处。我个人的建议是把QueryInterface表驱动化维护一张{IID, 偏移量}的静态数组统一遍历匹配指针通过reinterpret_cast从对象基址加偏移算出来。这种写法不容易出错也便于审查。AddRef和Release是一对管理对象的生命周期。AddRef返回加一后的计数Release返回减一后的计数。约定是Release返回 0 时对象必须立刻销毁自己。注意立刻这两个字不能在返回 0 之后还留着对象等下次清理也不能从 0 又变回非 0那样调用方会误判。2.2 引用计数最容易出错的环节COM 的生命周期管理完全靠引用计数没有别的机制。这意味着内存泄漏和野指针这两种最烦人的问题在 COM 里会以计数没配平的形式出现。我的经验是泄漏和崩溃的成因九成可以归到下面几类。构造阶段加引用忘了配对减引用。典型场景是QueryInterface成功之后调用方用完没Release。COM 有一条硬规矩所有输出接口指针的函数成功时都已经为你加过一次引用调用方必须负责减掉。这条规矩贯穿QueryInterface、CoCreateInstance、所有返回接口指针的方法没有例外。循环引用。A 持有 B 的引用B 也持有 A 的引用两边计数永远是 1谁也不会释放。这是引用计数方案的固有缺陷COM 也没能解决只能靠设计规避父子关系里让父持有子的强引用、子持有父的弱引用弱引用用裸指针加约定维护不参与计数。异常路径漏减。这段代码在正常流程里加引用减引用配得很齐某个分支提前return了就漏掉了。C 里可以用智能指针包装CComPtr、_com_ptr_t、Microsoft::WRL::ComPtr都行我个人现在更倾向 WRL 的ComPtr轻量而且跟现代 C 的移动语义配合得比较自然。要注意智能指针不是万能药把一个裸指针交给智能指针时如果是Attach语义就不加引用如果是构造或者operator就加引用这两种语义搞反了照样出问题。注意调试引用计数问题别急着打断点猜。给对象加一个静态计数器统计创建和销毁数量跑完一轮业务后看差值比逐行读代码高效得多。2.3 GUID一个 128 位的身份标识GUID 是全局唯一标识符128 位通常写成{XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX}的形式。在 COM 里它有好几个化身理解它们的区别很重要。CLSID标识一个组件类也就是要创建哪个对象。IID标识一个接口也就是要哪个能力。LIBID标识一个类型库。ProgID是给人看的人类可读别名比如Excel.Application本质上还是映射到某个 CLSID。AppID用来给跨进程组件做配置分组。生成 GUID 的方式很多别手写。命令行用uuidgenPowerShell 里一行[guid]::NewGuid()就够了Visual Studio 里也有工具菜单。这里有个实际工作中必须守住的原则同一个接口所有使用方必须用同一个 GUID。我见过接口定义在头文件里复制到另一个项目时手抖改了 GUID 的情况编译链接全过运行时QueryInterface一直返回E_NOINTERFACE排查半天。所以正规做法是接口定义放.idl文件由midl编译生成头文件和_i.c文件谁都别手工维护 GUID。2.4 HRESULT统一的返回码体系COM 方法不用异常传递错误统一返回HRESULT。它是一个 32 位整数最高位表示成功还是失败0是成功1是失败。剩下位分成设施码Facility、错误码等信息段。日常打交道最多的几个S_OK成功、S_FALSE成功但结果特殊注意它算成功、E_FAIL通用失败、E_NOINTERFACE不支持该接口、E_OUTOFMEMORY、E_INVALIDARG、E_POINTER传了空指针、E_UNEXPECTED。判断成功与否必须用SUCCEEDED(hr)和FAILED(hr)宏不能拿hr S_OK去比。S_FALSE是经典陷阱很多方法用S_FALSE表示没有更多数据了之类的语义你如果按 S_OK判断就会误判为失败。反过来也有人在返回S_FALSE的地方当成错误处理逻辑直接跑偏。HRESULT还有个隐藏的坑它不像 errno 那样会被后续调用覆盖但如果方法内部调用了一系列子方法最后只返回了最后一个的HRESULT前面真正出错的现场就丢了。我在排查跨进程组件的时候习惯在每一层把HRESULT打印出来配合FormatMessage或者_com_error转成可读文本能省掉大量猜测时间。另外HRESULT保留了一些值不允许自定义组件使用比如S_OK、E_FAIL这些由系统定义的通用值自定义错误码应该带上自己的 Facility 字段避免和其他组件的错误码撞车。3. 三种进程模型同一套接口三种运行方式COM 有个很漂亮的地方调用方写代码的时候通常不需要知道组件跑在哪里。进程内、同机跨进程、跨网络调用代码长得几乎一样。这种透明性靠的是代理Proxy、桩Stub和编组Marshaling机制。3.1 进程内组件最快的形态进程内组件就是 DLL通过InprocServer32注册。调用CoCreateInstance时COM 库把 DLL 加载到调用方进程调用DllGetClassObject拿到类厂再由类厂创建对象。这种形态没有跨进程开销接口指针直接就是进程内的虚表指针调用效率和普通虚函数调用差不多是性能最好的一种。代价是稳定性。DLL 和宿主共享同一个地址空间组件崩了宿主跟着崩组件内存泄漏宿主一起涨。另外 DLL 和宿主的 CRT、全局状态、线程局部存储可能互相干扰尤其当双方用不同版本运行时库的时候。我做过一个项目第三方组件在 DLL 的DllMain里做了太多初始化工作宿主退出时代码还没跑完就被卸载偶发崩溃排查了很久。所以进程内组件的最佳实践是DllMain里只做最必要的事其他初始化放到显式的初始化接口里。3.2 本地服务器跨进程的安全隔离本地服务器是 EXE通过LocalServer32注册。CoCreateInstance时 COM 库会启动这个 EXE双方建立 RPC 通道接口调用被打包成消息传过去。开销明显增大一次调用的延迟可能从纳秒级涨到微秒甚至毫秒级但换来的是隔离组件崩溃宿主的进程完好无损双方内存互不干扰权限也可以分开配置。跨进程调用需要编组。接口里的参数类型如果是简单类型MIDL 生成的代理桩代码能自动处理如果有指针、结构体、自定义类型就需要在 IDL 里明确声明让 MIDL 生成对应的序列化代码。这里有个常见的认知误区有人以为把一个进程内组件改成进程外组件只是改个注册表键实际上如果 IDL 里参数类型标注不完整改完之后调用会直接失败。我建议所有要发布的接口从第一天起就按未来会跨进程的标准写 IDL把[in]、[out]、[size_is]、[string]这些属性写全成本很低省掉的是未来的重构。3.3 注册表COM 的目录服务COM 的对象发现机制本质上是一张注册表索引。以 64 位系统上的 32 位组件为例关键位置在HKEY_CLASSES_ROOT\CLSID\{你的CLSID}下面InprocServer32子键的默认值写 DLL 完整路径ThreadingModel值写线程模型本地服务器则是LocalServer32。同时HKCR\CLSID\{CLSID}\ProgID提供可读别名HKCR\{ProgID}\CLSID做反向映射。注册表这块最容易出问题是位数问题。64 位 Windows 上32 位组件注册到Wow6432Node下面regsvr32也有 32 位和 64 位两个版本分别在SysWOW64和System32里是的这两个目录名字有点反直觉。你用 64 位的regsvr32注册了一个 32 位的 DLL注册表里位置就错了程序按正确路径去找不到报0x80040154。这个坑我在不同的项目里至少踩过三次每次都要愣一下才想起来。提示注册组件不要依赖注册表编辑器手工填路径。用regsvr32或安装程序调用DllRegisterServer路径变化时重新注册一次比手改省心得多。4. 手写一个最小可用的 COM 组件理论说够了动手跑一遍比什么都清楚。下面这套代码我特意写得尽量少去掉所有不必要的包装目的是让每个环节都能看清。4.1 用 IDL 定义接口先写接口定义文件这是唯一权威来源。// Calculator.idl import oaidl.idl; import ocidl.idl; [ object, uuid(8F0B4A1C-3D2E-4B77-9C51-6A2E8D0F1B33), dual, pointer_default(unique) ] interface ICalculator : IDispatch { HRESULT Add([in] long a, [in] long b, [out, retval] long* result); HRESULT Divide([in] long a, [in] long b, [out, retval] long* result); }; [ uuid(2C7D5E90-1A4B-4F82-B3D6-9E0A7C4F5D21), version(1.0) ] library CalculatorLib { importlib(stdole2.tlb); interface ICalculator; };几个细节值得说。dual表示双接口既能通过虚表直接调用也能通过IDispatch后期绑定调用兼容性好但需要继承IDispatch多占三个槽位。如果你确定只用早绑定去掉dual和IDispatch直接继承IUnknown会更清爽。[out, retval]标记返回值这样在支持自动化的语言里能写成result calc.Add(1, 2)这种自然形式。pointer_default(unique)是让所有未标注的指针默认为可空指针减少编组负担。用 MIDL 编译一下midl /nologo /env win32 Calculator.idl产出Calculator.h、Calculator_i.c、Calculator_p.c、dlldata.c代理桩代码全在里面了。4.2 实现类的骨架#include windows.h #include atlbase.h #include Calculator.h class CCalculator : public ICalculator { public: CCalculator() : m_ref(1) {} virtual ~CCalculator() {} // IUnknown STDMETHODIMP QueryInterface(REFIID riid, void** ppv) override { if (!ppv) return E_POINTER; *ppv nullptr; if (riid IID_IUnknown || riid IID_IDispatch) *ppv static_castIDispatch*(this); else if (riid IID_ICalculator) *ppv static_castICalculator*(this); if (*ppv) { AddRef(); return S_OK; } return E_NOINTERFACE; } STDMETHODIMP_(ULONG) AddRef() override { return InterlockedIncrement(m_ref); } STDMETHODIMP_(ULONG) Release() override { LONG n InterlockedDecrement(m_ref); if (n 0) delete this; return n; } // ICalculator STDMETHODIMP Add(long a, long b, long* result) override { if (!result) return E_POINTER; *result a b; return S_OK; } STDMETHODIMP Divide(long a, long b, long* result) override { if (!result) return E_POINTER; if (b 0) return E_INVALIDARG; *result a / b; return S_OK; } private: LONG m_ref; };注意QueryInterface里我没有用if-else到处AddRef而是先算出指针再统一加引用。这样对称性规则天然满足后续加接口只需要往条件里加一行。AddRef和Release必须用InterlockedIncrement和InterlockedDecrement因为 COM 对象可能被多个线程持有引用普通自增在多核上不是原子的。4.3 类厂与 DLL 导出函数class CClassFactory : public IClassFactory { public: CClassFactory() : m_ref(1) {} STDMETHODIMP QueryInterface(REFIID riid, void** ppv) override { if (!ppv) return E_POINTER; if (riid IID_IUnknown || riid IID_IClassFactory) { *ppv static_castIClassFactory*(this); AddRef(); return S_OK; } *ppv nullptr; return E_NOINTERFACE; } STDMETHODIMP_(ULONG) AddRef() override { return InterlockedIncrement(m_ref); } STDMETHODIMP_(ULONG) Release() override { LONG n InterlockedDecrement(m_ref); if (n 0) delete this; return n; } STDMETHODIMP CreateInstance(IUnknown* outer, REFIID riid, void** ppv) override { if (outer ! nullptr) return CLASS_E_NOAGGREGATION; CCalculator* obj new CCalculator(); if (!obj) return E_OUTOFMEMORY; HRESULT hr obj-QueryInterface(riid, ppv); obj-Release(); return hr; } STDMETHODIMP LockServer(BOOL) override { return S_OK; } private: LONG m_ref; }; STDAPI DllGetClassObject(REFCLSID clsid, REFIID riid, void** ppv) { if (clsid ! CLSID_Calculator) return CLASS_E_CLASSNOTAVAILABLE; CClassFactory* factory new CClassFactory(); if (!factory) return E_OUTOFMEMORY; HRESULT hr factory-QueryInterface(riid, ppv); factory-Release(); return hr; }CreateInstance里那个obj-Release()很容易漏。逻辑是QueryInterface会为目标接口加一次引用新建对象初始引用计数是 1把这次引用还给调用方之后本地的这份引用要减掉。不减就一直泄漏。CLASS_E_NOAGGREGATION表示不支持聚合因为我这里简化处理聚合的实现稍微复杂一点后面再说。4.4 注册与调用先定义一个.def文件导出必需函数LIBRARY Calculator EXPORTS DllGetClassObject PRIVATE DllCanUnloadNow PRIVATE DllRegisterServer PRIVATE DllUnregisterServer PRIVATE注册表写入可以用DllRegisterServer里调RegCreateKeyEx手动写也可以直接用一个.rgs脚本配合 ATL。跑起来的时候在命令行执行regsvr32 /s Calculator.dll调用方代码#include windows.h #include objbase.h #include Calculator.h int wmain() { HRESULT hr CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); if (FAILED(hr)) return 1; ICalculator* calc nullptr; hr CoCreateInstance(CLSID_Calculator, nullptr, CLSCTX_INPROC_SERVER, IID_ICalculator, reinterpret_castvoid**(calc)); if (SUCCEEDED(hr)) { long sum 0; hr calc-Add(3, 7, sum); wprintf(L3 7 %ld\n, sum); calc-Release(); } CoUninitialize(); return 0; }编译链接的时候需要把Calculator_i.c一起编进去它包含CLSID_Calculator和IID_ICalculator的实际定义。忘了加就会出现一堆找不到符号的链接错误这是新手很常见的卡点。5. 常见问题排查实录写了这么多年代码COM 相关的问题来来回回就那么几类。下面按我实际遇到的频率排个序配上排查思路和速查表。5.1 创建失败0x80040154 是第一大报错CoCreateInstance返回0x80040154对应REGDB_E_CLASSNOTAVAILABLE。这里的注册表里找不到通常有几种成因。第一是位数不匹配前面说过。第二是路径不对注册表里写的 DLL 路径已经被移动或删除了COM 找不到文件。第三种比较隐蔽注册的是进程内组件但调用时指定了CLSCTX_LOCAL_SERVER或者反过来的场景上下文对不上。第四种是权限问题组件注册在HKCU下另一个用户跑程序就读不到。排查的固定动作先打开regedit按 CLSID 搜一下看看在不在、在哪个位数分支下、路径对不对。然后确认 DLL 的位数跟进程位数一致64 位进程加载不了 32 位 DLL。再不行就用Process Monitor过滤注册表查询路径能直接看到 COM 在找哪个键、找没找到。这个工具我基本是排查 COM 注册问题的第一反应。5.2 线程模型与 Apartment不报错的诡异故障COM 的线程模型分Apartment套间和Free自由实现组件时在ThreadingModel值里写。不写就是Single只允许在创建它的那个线程里被调用。写成Apartment时对象被绑定到 STA所有对它的调用必须由创建线程处理其他线程调用会被系统排队并转发过去。Both是既能进 STA 也能进 MTAFree是不做同步保护由实现者自己管线程安全。这块的问题特点是不崩溃、不报错但行为不对。典型场景是你从工作线程去调一个创建在主线程的组件调用看起来成功了但内部状态没更新或者界面卡住。原因往往是调用被自动编组到主线程而主线程正忙别的消息循环没转起来调用就一直排队。有个经典的死锁场景值得单独提主线程做 STA 下的同步调用被调用的组件反过来又调主线程的对象。这种相互等待在 MTA 里不会出现。规避方式是把所有跨线程的接口调用改成异步或者干脆统一用 MTA 加自己写同步。注意CoInitializeEx的第二个参数决定当前线程进哪个 apartment。同一个线程只能调用一次重复调用返回S_FALSE或者RPC_E_CHANGED_MODE后者是灾难信号说明你在线程里混用了两种模式必须想办法统一。5.3 一个特别容易搞混的命名撞车搜索 COM 相关资料时你会发现大量内容讲的是串口。串口设备在 Windows 里被命名为COM1、COM2这样的设备名热词里COM 口驱动can not open COM portDB9 COM 口 RS232 和 RS485 定义说的都是这个意思。而这篇讲的是Component Object Model两个东西只是重名技术上毫无关系。这个撞车在实际工作中确实会造成困扰。我见过有人搜COM 编程指南搜到一堆串口通讯的教程看了半天发现跟自己的问题不沾边。也有人在代码 review 里看到CoCreateInstance一头雾水地问这是不是操作串口的。判断方式很简单看关键词出现GUID、IUnknown、CoCreateInstance、.idl、regsvr32就是组件对象模型出现波特率、串口号、RS232、握手协议就是串口。这个区分搞清楚了找资料能省不少时间。5.4 常见问题速查表现象可能原因排查动作CoCreateInstance返回0x80040154未注册、位数不符、路径失效查注册表 CLSID、确认进程位数、走 Process MonitorQueryInterface返回E_NOINTERFACEIID 不一致、未实现该接口、GUID 手改过对比 IDL 与头文件、确认_i.c参与编译程序退出时崩溃在Release引用计数减多了、二次释放加静态计数、检查异常分支内存持续增长漏Release、循环引用统计创建销毁差值、梳理强引用链跨线程调用行为异常Apartment 不匹配、消息循环未运行确认ThreadingModel、检查CoInitializeEx参数接口能调但结果为空[out]参数未正确编组检查 IDL 属性、跨进程时确认代理桩已生成卸载组件后仍占用文件引用未释放、LockServer计数不当检查DllCanUnloadNow、确认组件已释放6. 从 COM 到现代组件技术的演进线索了解 COM 的设计取舍之后再看后来那些技术很多疑问会自动消解。.NET 的ComVisible和 COM 互操作本质上就是给托管对象套一层 COM 可见的壳让老组件能继续被调用。Runtime Callable Wrapper和COM Callable Wrapper这两个包装器在做的事情是把两套不同的对象模型、两套不同的生命周期、两套不同的类型系统翻译过来。理解 COM 的引用计数规则能让Marshal.ReleaseComObject这类 API 的使用变得理所应当而不是照抄别人的写法。WinRT 是 COM 的直系后代它保留了接口、GUID、HRESULT内部形式换掉了类型系统和注册机制改用元数据文件和激活工厂。WRL 库就是为写 WinRT 组件准备的。你在 WinRT 里看到的IUnknown变体IInspectable加的那几个方法是对反射和属性系统的支持。再往后跨语言组件化靠的是 IDL 加代码生成gRPC、Protobuf、Thrift 走的是这条路只是把传输从进程内虚表调用换成了网络消息。COM 当年面对的不同语言不同编译器如何协作这个问题今天的微服务架构换了个尺度重新问了一遍答案的骨架依然相似定义契约、生成胶水、运行时按契约通信。我个人在实际使用中的体会是学 COM 最大的收益不是会写多少 COM 代码而是理解接口设计这件事背后的工程权衡。什么该固化、什么该留扩展、什么必须一开始就想清楚这些问题在任何一个需要长期演进的系统里都会遇到。工具和框架会换这层判断力不太会过时。
返回列表