什麼是 COM - Windows COM 的設計為何至今依然優美
· 更新日期: · Go Komura · COM, ActiveX, Windows 開發
更新紀錄(2 筆,最後更新 2026年09月04日)
本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。
引用本文(DOI: 10.5281/zenodo.21616229)
本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。
Go Komura(2026)。〈什麼是 COM - Windows COM 的設計為何至今依然優美〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616229 https://comcomponent.com/zh-TW/blog/2026/01/25/001-why-com-is-beautiful/
- DOI(最新版本)
- 10.5281/zenodo.21616229
- DOI(此版本)
- 10.5281/zenodo.22297057
什麼是 COM?
COM(Component Object Model,元件物件模型)是 讓 Windows 上的元件彼此往來的「二進位契約」。它跨越語言與編譯器的差異,以介面這種嚴格的契約進行通訊,底層的設計思想是「針對契約而不是針對實作來撰寫程式」。
圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 23 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle
COM 的三個重要元素
本章列出的是構成 COM 這套機制的零件。後面的「COM 的四項強項」則是把這些零件組合起來之後才得到的性質,並不是把同一件事講兩遍。兩者的對照關係會在強項那一章的最後以表格呈現。
1. 以介面為中心的設計
在 COM 裡「契約先於實作」。就算不知道物件內部的實作,只要知道公開的介面就能使用它。
2. 以 GUID(CLSID / IID)識別
所有元件與介面都會配到全世界唯一的識別碼(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 API)開始。這裡寫下的 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 伺服器) | |
|---|---|---|
| 執行的位置 | 與呼叫端同一個處理程序 | 另一個處理程序 |
| 呼叫的實際情形 | 透過函式指標直接呼叫 | 把引數改裝過(封送處理,marshalling)再以處理程序間通訊傳遞 |
| 速度 | 快 | 多了處理程序間通訊,因此較慢 |
| 對方當掉時 | 呼叫端也會被拖著一起當掉 | 呼叫端還活著(呼叫會以失敗傳回) |
| 位元數(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 的結構描述產生用戶端/伺服器 |
| 不變更既有介面,而是追加新的介面 | 在 Protocol Buffers 中不重複使用欄位編號而是新增欄位,並替 API 分版本 |
| 不限實作語言(二進位契約) | 不限實作語言(跨網路的訊息契約) |
| Proxy/Stub 隱藏處理程序邊界 | RPC 的用戶端 stub 隱藏網路邊界 |
不同的只有邊界的種類(同一台機器的處理程序邊界,還是網路)與契約的表現形式(二進位,還是文字/結構描述)。先理解 COM,之後在微服務之間的介面設計上,「為什麼要先訂好契約」「為什麼不能刪掉既有欄位」就能真正想通,理解到那不是流行而是必然。
相關文章
共用相同標籤的最新文章。能以相近的主題延伸理解。
委外・委託開發 Windows 應用程式前該整理的事項
在委外・委託開發 Windows 應用程式之前,整理既有軟體改版、設備整合、COM/ActiveX、發布與更新、維護等應留意的重點。
開發 COM 元件、OCX/ActiveX 時常見的坑 - 整理 Visual Studio 的 32bit/64bit、註冊、管理員權限
整理開發 COM、OCX、ActiveX 元件時最容易卡關的四個面向:宿主行程的 32bit/64bit、Visual Studio 2022 變成 64bit 後的設計時整合、regsvr32 與 Regasm 的註冊位置、以及管理員權限與 HKCU/HKLM 的關係,協...
COM / ActiveX / OCX 是什麼 - 差異與關係一次整理
從實務角度梳理 COM 是什麼、ActiveX 是什麼、OCX 是什麼,涵蓋三者的差異與關係、與 OLE 的關聯、實際用在哪些地方,以及現在該怎麼看待它們。
ActiveX / OCX 現在該怎麼處理 - 保留、包裝、取代的判斷表
整理發現 ActiveX / OCX 時該選保留、包裝還是取代,把 32 位元與 64 位元、註冊、瀏覽器相依性以至於廠商維護都一起納入判斷。
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 伺服器)安全地呼叫另一個處理程序的功能。