什麼是 COM - Windows COM 的設計為何至今依然優美

· 更新日期: · · COM, ActiveX, Windows 開發

更新紀錄(2 筆,最後更新 2026年09月04日)

本文的修改紀錄。已保存的更新前版本,可透過附有 DOI 的永久連結閱讀。

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.22278988)
補上了日文原文中已有的諮詢引導(consultation_services),並刪除了內文開頭與標題重複的一級標題。版面本來就會顯示標題,讀者原先會看到兩次。 查看更新前的版本 (DOI: 10.5281/zenodo.21616230)
初次發布
引用本文(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 識別、方法排列順序已固定的介面」。語言與編譯器都不會出現在這份契約裡。

實作 ── 語言不拘呼叫端 ── 語言不拘以 C++ 撰寫的元件以 C# 撰寫的元件C++ 的應用程式C# 的應用程式VBA / Python 等契約(以二進位固定下來的部分)・以 IID(GUID)唯一決定「是哪一份契約」・從 IUnknown 的三個方法開始的排列順序・每個方法的引數與傳回值型別,以及呼叫慣例

圖 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。用傳回值而不是例外來表示成敗,是跨語言時必要的約定。各語言拋出例外的方式不同,但整數的傳回值誰都能解讀。
參考計數與 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 伺服器)
執行的位置 與呼叫端同一個處理程序 另一個處理程序
呼叫的實際情形 透過函式指標直接呼叫 把引數改裝過(封送處理,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 就必須以重新啟動與重新連線的復原處理的形式納入設計。準確地說,這是換取安全性所增加的工作。

對方處理程序消失時的流程說明在 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 的結構描述產生用戶端/伺服器
不變更既有介面,而是追加新的介面 在 Protocol Buffers 中不重複使用欄位編號而是新增欄位,並替 API 分版本
不限實作語言(二進位契約) 不限實作語言(跨網路的訊息契約)
Proxy/Stub 隱藏處理程序邊界 RPC 的用戶端 stub 隱藏網路邊界

不同的只有邊界的種類(同一台機器的處理程序邊界,還是網路)與契約的表現形式(二進位,還是文字/結構描述)。先理解 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 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽