什么是 COM - 为什么 Windows COM 的设计至今依然优美
· 更新日期: · 小村 豪 · COM, ActiveX, Windows 开发
引用本文(DOI: 10.5281/zenodo.21615406)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《什么是 COM - 为什么 Windows COM 的设计至今依然优美》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615406 https://comcomponent.com/zh-CN/blog/2026/01/25/001-why-com-is-beautiful/
- DOI(最新版本)
- 10.5281/zenodo.21615406
- DOI(此版本)
- 10.5281/zenodo.22281911
什么是 COM?
COM(Component Object Model)是 让 Windows 上的组件彼此交互的“二进制契约”。它是一种跨越语言与编译器差异、通过 接口这种严格契约 来通信的机制,其根本是“面向契约而非面向实现进行编程”的设计思想。
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 23 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
COM 的三个重要构成要素
本章列出的是构成 COM 这套机制的零件。后面的“COM 的四大优势”,则是把这些零件组合起来所得到的性质,因此并不是把同一件事讲了两遍。两者的对应关系会在优势那一章的末尾用表格给出。
1. 以接口为中心的设计
在 COM 中,“契约先于实现”。即使不了解对象的内部实现,只要知道其公开的接口就可以使用它。
2. 通过 GUID(CLSID / IID)进行识别
每个组件和接口都会被赋予 全球唯一的 ID(GUID),因此名称冲突从根本上不会发生。
3. IUnknown
所有 COM 接口都会继承的基本接口,提供以下三个功能。
| 方法 | 作用 |
|---|---|
QueryInterface |
询问对象是否具备另一个接口 |
AddRef |
增加引用计数 |
Release |
减少引用计数(归零时销毁自身) |
这三个要素组合在一起之后,调用方与实现之间剩下的就只有“由 GUID 标识、方法排列顺序固定的接口”。语言和编译器都不会出现在这份契约里。
flowchart LR
subgraph CALLER["调用方 ── 语言不限"]
A1["C++ 应用"]
A2["C# 应用"]
A3["VBA / Python 等"]
end
CONTRACT["契约(在二进制层面固定下来的部分)<br/>·用 IID(GUID)唯一确定“是哪一份契约”<br/>·从 IUnknown 的三个方法开始的排列顺序<br/>·每个方法的参数与返回值类型以及调用约定"]
subgraph IMPL["实现 ── 语言不限"]
B1["用 C++ 编写的组件"]
B2["用 C# 编写的组件"]
end
A1 --> CONTRACT
A2 --> CONTRACT
A3 --> CONTRACT
CONTRACT --> B1
CONTRACT --> B2
图1:COM 的二进制契约。调用方的语言和实现的语言都不会出现在契约里,因此替换任意一侧都不需要重新构建另一侧
用代码看懂“面向契约进行编程”
只靠文字不容易讲清楚,这里用最小的代码来说明。示例是一个只做加法的组件 ICalcService,演示在完全不了解其实现的情况下如何使用它。
先从 C++(原生 COM)开始。这里写出的 ICalcService 定义就是契约本身,而实现究竟是用 C++ 还是用 C# 编写的,在调用方的代码里任何地方都不会出现。
#include <objbase.h>
// 契约的定义。通常由 IDL 生成这种形式的头文件
// 由于继承自 IUnknown,因此必定具备 QueryInterface / AddRef / Release
struct __declspec(uuid("7A4B5B23-0A2F-4D2B-9D4D-8A2A92B8B001"))
ICalcService : public IUnknown
{
virtual HRESULT STDMETHODCALLTYPE Add(int a, int b, int* result) = 0;
};
// 后来追加的扩展版契约。GUID 是另外一个
struct __declspec(uuid("7A4B5B23-0A2F-4D2B-9D4D-8A2A92B8B002"))
ICalcServiceEx : public ICalcService
{
virtual HRESULT STDMETHODCALLTYPE Multiply(int a, int b, int* result) = 0;
};
// 实现组件的 CLSID。通常在由 IDL 生成的头文件中定义
static const CLSID CLSID_CalcService =
{ 0x1C9B6F4D, 0x1E9A, 0x4E61, { 0x9A, 0x4F, 0x6A, 0x0F, 0x1D, 0x2D, 0x9A, 0x11 } };
// 调用方
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
if (FAILED(hr)) { return hr; }
ICalcService* calc = nullptr;
hr = CoCreateInstance(CLSID_CalcService, nullptr, CLSCTX_ALL,
__uuidof(ICalcService), reinterpret_cast<void**>(&calc));
// 这里返回的指针已经完成 AddRef(引用计数为 1)
if (SUCCEEDED(hr))
{
int sum = 0;
hr = calc->Add(1, 2, &sum); // 不了解实现也能调用
// 在运行时询问“是否也具备扩展版契约”= 版本共存的实现方式
ICalcServiceEx* calcEx = nullptr;
if (SUCCEEDED(calc->QueryInterface(__uuidof(ICalcServiceEx),
reinterpret_cast<void**>(&calcEx))))
{
// 只有对方是新版本的组件时才会走到这里
calcEx->Release(); // 由接收方释放
}
// 如果不具备,也只是返回 E_NOINTERFACE,旧版本照样继续工作
calc->Release(); // 引用计数归零,对象被销毁
}
CoUninitialize();
这里有三个值得注意的地方。
- 几乎没有需要自己写
AddRef的场合。CoCreateInstance和QueryInterface在返回指针之前都已经替你调用过AddRef。也就是说,原则是“拿到指针的一方负责Release”,而需要显式调用AddRef的,只有在另一处也开始保存同一个指针的时候。 QueryInterface失败并不是异常情况。“不具备那份契约”(E_NOINTERFACE)是一个正常的回答,正是它让“在不破坏旧组件的前提下添加新功能”成为可能。- 返回值一律是
HRESULT。用返回值而不是异常来表示成败,是跨语言所必需的约定。抛出异常的方式各语言各不相同,而一个整数返回值谁都能解释。
flowchart TB
accTitle: 引用计数与 Release 的原则
accDescr: CoCreateInstance 和 QueryInterface 返回的是已经 AddRef 过的指针,因此由接收方在用完后调用 Release,引用计数归零后对象被销毁,本图展示这一原则。
api["CoCreateInstance 或 QueryInterface"] --> ret["返回已经 AddRef 过的指针"]
ret --> use["接收方使用它"]
use --> rel["接收方调用 Release"]
rel --> zero["引用计数归零后销毁"]
use -.->|"显式调用 AddRef"| add["在另一处也保存同一个指针"]
图2:原则是拿到指针的一方负责 Release,只有在增加保存位置时才需要显式调用 AddRef。
同一份契约从 C# 使用时是下面这样。只要 GUID 相同就视为同一份契约,所以对方是 C++ 实现也没有关系。
using System;
using System.Runtime.InteropServices;
// 写上与 C++ 侧相同的 GUID。这就是“同一份契约”的声明
[ComImport]
[Guid("7A4B5B23-0A2F-4D2B-9D4D-8A2A92B8B001")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
public interface ICalcService
{
int Add(int a, int b);
}
// 调用方
Type t = Type.GetTypeFromProgID("KomuraSoft.CalcService")
?? throw new InvalidOperationException("COM 服务器未注册。");
object server = Activator.CreateInstance(t)!;
try
{
var calc = (ICalcService)server; // 这个强制转换相当于 QueryInterface
int sum = calc.Add(1, 2);
Console.WriteLine(sum); // 3
}
finally
{
Marshal.ReleaseComObject(server); // 相当于 Release
}
C# 侧的 Add 之所以返回值是 int,是因为 .NET 的 COM 互操作会做一套固定的转换:把最后一个 [out, retval] 参数变成返回值,并把失败的 HRESULT 转换成异常。AddRef/Release 在调用方这边也看不见了,但它们并没有消失,而是由 .NET 提供的轻量包装器(RCW)代为调用。同一份契约,可以用各语言自然的写法来使用——这就是“二进制契约”的实际样貌。
flowchart TB
accTitle: C# 的写法与 COM 的对应关系
accDescr: 在 C# 中,向接口类型的强制转换相当于 QueryInterface,Marshal.ReleaseComObject 相当于 Release,失败的 HRESULT 会被转换成异常,而 AddRef 与 Release 由 RCW 代为调用,本图展示这一对应关系。
cs["C# 代码"] --> cast["向类型的强制转换"]
cs --> rco["ReleaseComObject"]
cs --> hrx["失败的 HRESULT"]
cast --> qi["相当于 QueryInterface"]
rco --> rel["相当于 Release"]
hrx --> ex["被转换成异常"]
cs -.-> rcw["由 RCW 代管引用计数"]
图3:即使是同一份契约,在 C# 侧也会换成该语言自然的写法,转换工作由 RCW 和互操作层承担。
COM 的四大优势
1. 二进制兼容性
构建一次的组件,可以 不受编程语言或运行时限制 地重用。用 C++ 做出来的 COM 组件被 C# 或 Python 调用,是很平常的事情。
2. 接口分离
由于完全隐藏实现、只公开契约,因此自由更改内部实现也不会影响调用方。
3. 版本共存
为了在保持向后兼容的同时添加功能,基本做法是不断 添加新接口。这样就能在不修改旧接口的前提下提供新功能。
把新旧调用方与新旧组件组合起来,四种组合中有三种可以直接工作,剩下的一种也只是得到一个“不具备”的回答而已。
flowchart LR
OLDC["旧的调用方<br/>只知道 ICalcService"]
NEWC["新的调用方<br/>会试着询问 ICalcServiceEx"]
OLDS["旧版本的组件<br/>只实现 ICalcService"]
NEWS["新版本的组件<br/>两者都实现"]
OLDC -->|"QueryInterface(IID_ICalcService)<br/>S_OK"| OLDS
OLDC -->|"QueryInterface(IID_ICalcService)<br/>S_OK ── 升级后也不会坏"| NEWS
NEWC -->|"QueryInterface(IID_ICalcServiceEx)<br/>S_OK ── 可以使用新功能"| NEWS
NEWC -.->|"QueryInterface(IID_ICalcServiceEx)<br/>E_NOINTERFACE ── 以旧功能继续运行"| OLDS
图4:不修改已有接口而只增加新的 IID,新旧的任意组合都能继续工作。虚线表示“不具备那份契约”这个正常的回答
4. 跨越进程边界的重用
COM 组件的存放位置有两种:作为 DLL 加载到与调用方 同一个进程 中的 In-proc(DLL 服务器),以及作为 独立进程 的 EXE 启动的 Out-of-proc(EXE 服务器,LocalServer)。两者在调用代码上的写法完全一样。
flowchart TB
accTitle: 组件的两种存放位置
accDescr: 写法完全相同的调用代码,既可以连到作为 DLL 加载进调用方同一进程的 In-proc,也可以连到作为独立进程 EXE 启动的 Out-of-proc,本图展示这一点。
caller["调用方的代码(写法完全相同)"] --> ip["In-proc(DLL 服务器)"]
caller --> op["Out-of-proc(EXE 服务器)"]
ip -.-> ipd["作为 DLL 加载到同一个进程中"]
op -.-> opd["作为独立进程的 EXE 启动"]
图5:存放位置分 In-proc 和 Out-of-proc 两种,但调用代码的写法不变。
| In-proc(DLL 服务器) | Out-of-proc(EXE 服务器) | |
|---|---|---|
| 运行的位置 | 与调用方同一个进程 | 独立进程 |
| 调用的实质 | 经由函数指针的直接调用 | 把参数重新打包(封送)后通过进程间通信传递 |
| 速度 | 快 | 因为有进程间通信而更慢 |
| 对方崩溃时 | 调用方也会被一起拖垮 | 调用方存活下来(调用以失败返回) |
| 位数(32/64 位) | 不一致就无法加载 | 不一致也没关系 |
使用 Out-of-proc COM(EXE 服务器),就可以 安全地调用其他进程中的功能。“32 位与 64 位不一致也没关系”这最后一行在实务中确有用武之地,从 32 位应用使用 64 位 DLL 的具体做法,在《从 32 位应用调用 64 位 DLL 的 COM 桥接实例》中有讨论。
不过,处于独立进程也意味着 对方随时可能消失。当服务器进程崩溃或退出时,调用方会收到下面这些失败返回。它们表示的不是“坏掉了”,而是“对方已经不在了”,是 Out-of-proc 特有的错误。
| 错误代码 | 含义 |
|---|---|
RPC_E_DISCONNECTED |
想要调用的对象已经与客户端断开(对方的对象已经不存在) |
RPC_S_SERVER_UNAVAILABLE |
无法到达被调用的服务器(进程) |
如果是 In-proc,“对方倒下自己也跟着倒下”,所以不必考虑;而在 Out-of-proc 中,这件事会作为 重启与重新连接的恢复处理 进入设计。准确地说,这是用换来的安全性所增加的工作量。
flowchart TB
accTitle: 对方进程消失时的流程
accDescr: 在 Out-of-proc 中,服务器进程因崩溃或退出而消失时调用会以失败返回,调用方能够存活下来,因此需要把重启与重新连接的恢复处理纳入设计,本图展示这一流程。
gone["服务器进程崩溃或退出"] --> fail["调用以失败返回"]
fail --> alive["调用方存活下来"]
alive --> rec["进入重启与重新连接的恢复处理"]
fail -.-> mean["不是坏掉了,而是对方已经不在了"]
图6:在 Out-of-proc 中,对方消失不会把自己一起拖垮,代价是恢复处理要进入设计。
三个要素与四大优势的对应关系
正如开头提到的,“三个要素”是零件,“四大优势”是它们带来的结果。把哪个零件在支撑哪个优势排列出来,原本看上去互相重叠的部分,其分工就清楚了。
| 优势 | 主要起作用的要素 |
|---|---|
| 1. 二进制兼容性 | 以接口为中心的设计 + IUnknown(调用的排列顺序在二进制层面固定下来) |
| 2. 接口分离 | 以接口为中心的设计(只公开契约) |
| 3. 版本共存 | GUID + QueryInterface(可以在运行时询问另一份契约) |
| 4. 跨越进程边界的重用 | 以接口为中心的设计(连实现所在的位置也能从契约中剥离) |
COM 至今仍在现役
尽管常被当作“过时的技术”,但 COM 是 至今仍在 Windows 核心中持续被使用的机制。
COM 出现的场景
- 资源管理器扩展(右键菜单、预览显示)
- Office 自动化(对 Excel、Word 的外部控制)
- 与 .NET 的互操作(COM Interop)
- 包含 ActiveX 的现有系统
- DirectX、Windows Shell API 等众多 Windows API
即使觉得“这和自己没关系”,只要从事 Windows 开发,COM 就一定会在某处出现。
总结
COM 设计的核心是 “独立于语言、进程与实现”。语言中立的接口设计、通过 GUID 实现的唯一识别与版本管理、通过 IUnknown 实现的引用计数,以及能够透明处理进程间通信的机制。
flowchart TB
accTitle: COM 设计的核心
accDescr: 语言中立的接口设计、通过 GUID 实现的唯一识别与版本管理、通过 IUnknown 实现的引用计数、透明的进程间通信这四项机制,共同支撑起独立于语言、进程与实现这一设计核心,本图展示这一关系。
i1["语言中立的设计"] --> core["独立于语言、进程与实现"]
i2["通过 GUID 实现的唯一识别"] --> core
i3["IUnknown 的引用计数"] --> core
i4["透明的进程间通信"] --> core
图7:四项机制汇聚起来,支撑起“独立于语言、进程与实现”这个 COM 的核心。
“优美”是一种主观的说法,但作出这种评价的依据是具体的。把 COM 试图解决的问题和它的解法并排列出来,就是下面这样。
| 当年(以及现在依然)存在的制约 | COM 给出的答案 |
|---|---|
| C++ 没有标准的二进制约定(ABI),仅仅编译器不同就无法重用。名称修饰和对象布局都对不上 | 只把“虚函数表的排列顺序”定为约定。正因为收窄到这一点,才做到了不挑语言也不挑编译器 |
| 一旦替换库,所有使用它的代码都必须重新构建 | 只要契约(接口)不变就无需重新构建,可以保持二进制形态直接替换 |
| 想添加功能,又不能破坏已有的调用方 | 不修改接口而是添加接口,再用 QueryInterface 在运行时询问“是否具备” |
| 名称会冲突(同名类的不同东西共存于一处) | 用 GUID 唯一标识,从根本上省去了调整名称的必要 |
| 一旦调用其他进程、其他机器上的功能,调用的写法就完全变了 | 中间夹一层 Proxy/Stub,让调用方的代码保持同样的形态 |
也就是说,COM 的设计并不是从“怎样才漂亮”出发的,而是把阻碍重用的具体障碍一个个消除之后才成为今天这个样子。制约与解法之间的对应能被如此直白地追溯,这样的设计并不多见。
而且,这套解法原封不动地留在了现代的面向组件开发中。
| COM | 现代的对应物 |
|---|---|
| 用 IDL 先确定契约,再由它生成两侧的代码 | 从 OpenAPI 或 Protocol Buffers 的 schema 生成客户端/服务端代码 |
| 不修改已有接口,而是添加新接口 | 在 Protocol Buffers 中不复用字段编号而是新增,为 API 划分版本 |
| 不挑实现语言(二进制契约) | 不挑实现语言(跨网络的消息契约) |
| Proxy/Stub 隐藏进程边界 | RPC 的客户端存根隐藏网络边界 |
不同的只有边界的种类(是同一台机器上的进程边界,还是网络)和契约的表现形式(是二进制,还是文本/schema)。理解了 COM 之后,在设计微服务之间的接口时,“为什么要先确定契约”“为什么不能删除已有字段”就会成为必然而非潮流,让人真正想通。
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
COM/OCX/ActiveX 开发中容易踩坑的注册与位数陷阱
本文从实务角度整理 COM、OCX、ActiveX 开发中容易踩坑的 32bit/64bit、Visual Studio 2022、regsvr32/Regasm、管理员权限、HKCR、STA/MTA 等问题。
COM / ActiveX / OCX 是什么 - 区别与关系一并梳理
从实务视角梳理什么是 COM、什么是 ActiveX、什么是 OCX,一直讲到三者的区别与关系、与 OLE 的关联、它们被用在哪里,以及现在应该怎么看待它们。
ActiveX / OCX 现在该如何处理 - 保留、封装、替换的判断表
本文整理发现 ActiveX / OCX 时应该选择保留、封装还是替换,涵盖 32bit / 64bit、注册、浏览器依赖、供应商维护等因素。
WinRT 就是 COM —— IInspectable、.winmd、语言投影,以及 WinUI 至今仍立在二进制契约之上的原因
WinRT 不是托管运行时,而是在 COM 之上加了元数据(.winmd)与语言投影的 ABI。本文从 IUnknown 与 IInspectable 的关系,一直讲到桌面应用里 HWND 初始化与 package identity 的卡点。
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
ActiveX 迁移
整理保留、包装或替换 COM / ActiveX / OCX 资产的阶段性判断的主题页面。
与本主题相关的服务
本文与以下服务页面相关联,欢迎从最接近的入口查看。
既有资产活用 & 迁移支持
理解 COM 的设计与兼容性,很适合作为思考如何盘活现有 Windows 资产的切入点。
技术咨询 & 设计评审
如果希望基于 IUnknown、GUID 和边界设计的视角来梳理方案,可以进一步转入技术咨询与设计评审。
常见问题
汇总了咨询这一主题时常见的问题。
- COM 是什么?
- COM(Component Object Model)是让 Windows 上的组件彼此交互的“二进制契约”。它是一种跨越语言与编译器差异,通过接口这种严格契约来通信的机制。其根本是“面向契约而非面向实现进行编程”的设计思想。
- IUnknown 是什么?
- IUnknown 是所有 COM 接口都会继承的基本接口。它提供三个功能:用于询问对象是否具备另一个接口的 QueryInterface、用于增加引用计数的 AddRef,以及用于减少引用计数并在计数归零时销毁自身的 Release。COM 的对象生命周期管理正是建立在这个引用计数之上的。
- COM 现在还在使用吗?
- 是的,COM 至今仍是在 Windows 核心中持续被使用的机制。它出现在资源管理器扩展(右键菜单、预览显示)、Excel 与 Word 的 Office 自动化、与 .NET 的 COM Interop、包含 ActiveX 的现有系统、DirectX 与 Windows Shell API 等众多场景中。只要从事 Windows 开发,COM 就一定会在某处出现。
- COM 的优势是什么?
- 大致有四点:一是二进制兼容性,构建一次的组件可以不受语言或运行时限制地重用;二是接口分离,隐藏实现而只公开契约;三是版本共存,通过添加新接口来保持向后兼容;四是通过 Out-of-proc COM(EXE 服务器)安全地调用其他进程中的功能。