什么是 COM - 为什么 Windows COM 的设计至今依然优美

· 更新日期: · · COM, ActiveX, Windows 开发

更新记录(2 条,最后更新 2026年09月03日)

本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。

本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.22276546)
补充了日文原文中已有的咨询引导(consultation_services)。正文内容没有改动。 查看更新前的版本 (DOI: 10.5281/zenodo.21615407)
首次发布
引用本文(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 标识、方法排列顺序固定的接口”。语言和编译器都不会出现在这份契约里。

实现 ── 语言不限调用方 ── 语言不限用 C++ 编写的组件用 C# 编写的组件C++ 应用C# 应用VBA / Python 等契约(在二进制层面固定下来的部分)·用 IID(GUID)唯一确定“是哪一份契约”·从 IUnknown 的三个方法开始的排列顺序·每个方法的参数与返回值类型以及调用约定

图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。用返回值而不是异常来表示成败,是跨语言所必需的约定。抛出异常的方式各语言各不相同,而一个整数返回值谁都能解释。
引用计数与 Release 的原则CoCreateInstance 和 QueryInterface 返回的是已经 AddRef 过的指针,因此由接收方在用完后调用 Release,引用计数归零后对象被销毁,本图展示这一原则。显式调用 AddRefCoCreateInstance 或 QueryInterface返回已经 AddRef 过的指针接收方使用它接收方调用 Release引用计数归零后销毁在另一处也保存同一个指针

图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)代为调用。同一份契约,可以用各语言自然的写法来使用——这就是“二进制契约”的实际样貌。

C# 的写法与 COM 的对应关系在 C# 中,向接口类型的强制转换相当于 QueryInterface,Marshal.ReleaseComObject 相当于 Release,失败的 HRESULT 会被转换成异常,而 AddRef 与 Release 由 RCW 代为调用,本图展示这一对应关系。C# 代码向类型的强制转换ReleaseComObject失败的 HRESULT相当于 QueryInterface相当于 Release被转换成异常由 RCW 代管引用计数

图3:即使是同一份契约,在 C# 侧也会换成该语言自然的写法,转换工作由 RCW 和互操作层承担。

COM 的四大优势

1. 二进制兼容性

构建一次的组件,可以 不受编程语言或运行时限制 地重用。用 C++ 做出来的 COM 组件被 C# 或 Python 调用,是很平常的事情。

2. 接口分离

由于完全隐藏实现、只公开契约,因此自由更改内部实现也不会影响调用方。

3. 版本共存

为了在保持向后兼容的同时添加功能,基本做法是不断 添加新接口。这样就能在不修改旧接口的前提下提供新功能。

把新旧调用方与新旧组件组合起来,四种组合中有三种可以直接工作,剩下的一种也只是得到一个“不具备”的回答而已。

QueryInterface(IID_ICalcService)S_OKQueryInterface(IID_ICalcService)S_OK ── 升级后也不会坏QueryInterface(IID_ICalcServiceEx)S_OK ── 可以使用新功能QueryInterface(IID_ICalcServiceEx)E_NOINTERFACE ── 以旧功能继续运行旧的调用方只知道 ICalcService新的调用方会试着询问 ICalcServiceEx旧版本的组件只实现 ICalcService新版本的组件两者都实现

图4:不修改已有接口而只增加新的 IID,新旧的任意组合都能继续工作。虚线表示“不具备那份契约”这个正常的回答

4. 跨越进程边界的重用

COM 组件的存放位置有两种:作为 DLL 加载到与调用方 同一个进程 中的 In-proc(DLL 服务器),以及作为 独立进程 的 EXE 启动的 Out-of-proc(EXE 服务器,LocalServer)。两者在调用代码上的写法完全一样。

组件的两种存放位置写法完全相同的调用代码,既可以连到作为 DLL 加载进调用方同一进程的 In-proc,也可以连到作为独立进程 EXE 启动的 Out-of-proc,本图展示这一点。调用方的代码(写法完全相同)In-proc(DLL 服务器)Out-of-proc(EXE 服务器)作为 DLL 加载到同一个进程中作为独立进程的 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 中,这件事会作为 重启与重新连接的恢复处理 进入设计。准确地说,这是用换来的安全性所增加的工作量。

对方进程消失时的流程在 Out-of-proc 中,服务器进程因崩溃或退出而消失时调用会以失败返回,调用方能够存活下来,因此需要把重启与重新连接的恢复处理纳入设计,本图展示这一流程。服务器进程崩溃或退出调用以失败返回调用方存活下来进入重启与重新连接的恢复处理不是坏掉了,而是对方已经不在了

图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 实现的引用计数,以及能够透明处理进程间通信的机制。

COM 设计的核心语言中立的接口设计、通过 GUID 实现的唯一识别与版本管理、通过 IUnknown 实现的引用计数、透明的进程间通信这四项机制,共同支撑起独立于语言、进程与实现这一设计核心,本图展示这一关系。语言中立的设计独立于语言、进程与实现通过 GUID 实现的唯一识别IUnknown 的引用计数透明的进程间通信

图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 之后,在设计微服务之间的接口时,“为什么要先确定契约”“为什么不能删除已有字段”就会成为必然而非潮流,让人真正想通。

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

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 服务器)安全地调用其他进程中的功能。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表