從 C# 呼叫原生 DLL:C++/CLI 包裝層 vs P/Invoke

· 更新日期: · · C++/CLI, C#, Windows 開發, 原生整合

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

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

已將繁體中文版改寫為日文原文的完整翻譯。先前的繁體中文版只譯出日文原文的一部分,遺漏了章節、表格、Mermaid 圖、圖說與 FAQ。本次依日文原文將這些內容全部補回,並新增本文的知識地圖章節。技術主張與日文版一致。 查看更新前的版本 (DOI: 10.5281/zenodo.21616238)
初次發布
引用本文(DOI: 10.5281/zenodo.21616237)

本文保存於 Zenodo。以下同時提供一律指向最新版本的 DOI,以及固定於您正在閱讀版本的 DOI。

Go Komura(2026)。〈從 C# 呼叫原生 DLL:C++/CLI 包裝層 vs P/Invoke〉。小村軟體有限公司。https://doi.org/10.5281/zenodo.21616237 https://comcomponent.com/zh-TW/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/

DOI(最新版本)
10.5281/zenodo.21616237
DOI(此版本)
10.5281/zenodo.22297067

想從 C# 使用 Windows 的既有資產或既有 DLL,這樣的需求相當常見。 如果對方是像 Win32 API 那樣直接了當的 C 介面,P/Invoke 就夠了。

不過,實務上碰到的是更有脾氣的 DLL。 裡面有 C++ 類別,有自己一套所有權的規矩,例外也會往外拋,std::wstringstd::vector 更是家常便飯。 在這裡只靠 P/Invoke 硬撐,邊界通常會愈做愈辛苦。

本文要寫的是:這種時候 用 C++/CLI 插一層薄的包裝層 會讓哪些事情變輕鬆。 這不是在說 P/Invoke 不好,重點是 P/Invoke 就夠用的場合,和 C++/CLI 才派得上用場的場合並不一樣

另外,本文出現的程式碼片段,已經整理成可以建置的完整範例(原生 C++ 函式庫、C API 橋接層、C++/CLI 包裝層,以及 P/Invoke 版與 C++/CLI 版的 C# 呼叫端程式碼)公開在 GitHub 上。

cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)

目標讀者與前提

本文寫給這樣的開發者:已經從 C# 呼叫過原生 DLL,也寫得出 DllImport 宣告,但一碰到對方是 C++ 類別庫就卡住,不知道下一步該怎麼走。沒碰過 C++/CLI 也沒關係。反過來說,如果還沒寫過 P/Invoke,先讀「在 C# 中安全呼叫 Win32 API ── P/Invoke 實務指南」會比較快。

假設的環境是 Windows 加上 Visual Studio 2022,可以把 C++/CLI 包裝層(.vcxproj)和 C# 專案放在同一個方案裡。目標可以是 .NET Framework,也可以是 .NET 8 等版本,但 .NET 這一側有它特有的限制,整理在第 7 章。

先掌握的用語

用語 意思
P/Invoke(Platform Invoke) 用 C# 的 DllImport / LibraryImport 屬性(attribute)宣告原生 DLL 的匯出函式,直接呼叫它的機制
封送處理(marshalling) 在邊界上,把 .NET 的型別(string、陣列等)與原生的表示法(wchar_t*、原始指標等)互相轉換
ABI(Application Binary Interface) 呼叫慣例、引數的傳遞方式、結構在記憶體中的排列、名稱修飾等,讓編譯完成的二進位檔彼此接得起來的約定。C 的函式約定單純又穩定,但 C++ 的類別在名稱修飾與 vtable 的排列上取決於編譯器,C# 無法直接依賴(5.4)
SafeHandle 包住原生控制代碼的 .NET 抽象類別。為了避免控制代碼漏放,以及「使用中被釋放」的事故,用它取代裸著帶來帶去的 IntPtr(6.2)
StructLayout 讓 C# 結構在記憶體中的排列對齊原生側的屬性。用途包括以 LayoutKind.Sequential 依宣告順序排列、以 CharSet 指定字串的處理方式等(6.2)
marshal_as C++/CLI 提供的轉換輔助工具。像 marshal_as<std::wstring>(managedString) 這樣,在 .NET 的型別與原生的型別之間互相轉換。使用時要引入 msclr/marshal_cppstd.h 等標頭(6.3)
混合組件 同時含有原生機器碼指令與 MSIL 的 DLL。C++/CLI 包裝層就是這種東西(第 7 章)

目錄

  1. 先下結論(一句話)
  2. P/Invoke 就夠用的場合
  3. P/Invoke 突然變辛苦的邊界
  4. 插一層 C++/CLI 包裝層的架構
  5. 用 C++/CLI 會輕鬆在哪裡
  6. 程式碼片段
  7. 即使如此也不該選 C++/CLI 的場合
  8. 總結
  9. 參考資料

圖中實線表示始終成立的關係,虛線表示附帶條件的關係(成立條件寫在詳細頁面中各關係的說明中)。關係的完整清單(共 21 條,附依據與可信度)以及主要概念的定義,彙整在知識地圖詳細頁面(日文)。資料:JSON-LD / Turtle

1. 先下結論(一句話)

  • 對方是 C 的函式群,就用 P/Invoke 最直接了當
  • 對方是 C++ 函式庫,插一層 C++/CLI 包裝層會比較好維護
  • 特別是牽涉到 類別、所有權、字串、陣列、例外、回呼 時,別讓 C# 這一端硬撐

說到底,就是 別把原生 DLL 的內情直接帶進 C#。 原生的內情由 C++ 這一側接住,只把要給 .NET 看的那一面整理好。 這樣分工做得好,程式碼和偵錯都會平順許多。

依對方 DLL 的形態決定的取捨以分支呈現本文的結論,也就是對方是 C 的函式群時 P/Invoke 最直接了當,是 C++ 函式庫時插一層 C++/CLI 包裝層比較好維護的圖。C 的函式群C++ 函式庫對方的 DLL 是哪一種P/Invoke 最直接了當插一層 C++/CLI 包裝層原生的內情由 C++ 這一側接住

圖 1: 取捨的結論。對方是 C 的函式群就用 P/Invoke,是 C++ 函式庫就用 C++/CLI 包裝層。

2. P/Invoke 就夠用的場合

如果 P/Invoke 就能解決,那是最簡單的。沒必要硬把 C++/CLI 搬進來。

P/Invoke 適合的,例如是這樣的場合。

  • extern "C" 公開,是扁平的函式 API
  • 引數與傳回值用整數、指標、單純的結構就夠
  • 字串的約定明確,緩衝區的職責也單純
  • 資源管理像 Create / Destroy 那樣清楚
  • C# 這一側可以直接了當地寫出 SafeHandleStructLayout

整齊到這個程度,在 C# 這一側宣告後直接用就行了,感覺接近呼叫 Windows API,實作也比較好讀。

判斷 P/Invoke 就夠用的條件呈現只要湊齊扁平的 C API、單純的引數與傳回值、明確的字串與資源約定這些條件,在 C# 這一側宣告後直接用就行的圖。extern C 的扁平函式 APIP/Invoke 就夠用引數、傳回值單純字串、資源的約定明確在 C# 這一側宣告後直接用

圖 2: 對方的 API 整齊到這個程度,就沒必要硬把 C++/CLI 搬進來。

3. P/Invoke 突然變辛苦的邊界

問題出在對方不只是「單純的 C API」的時候。 從這裡開始,氣氛急轉直下。

3.1. 開始要面對 C++ 類別時

原生 DLL 若以 C++ 類別為核心設計,其實真正想呼叫的是類別的方法,但 P/Invoke 能直接面對的只有 DLL 的匯出函式。 也就是說,最後還是得在某處放一層 把它壓成 C 形式函式 的東西。

到了這一步,做的事情幾乎就是「寫包裝層」。 既然如此,與其在 C# 這一側長出一堆 IntPtr 和釋放函式,把包裝層收攏到 C++ 這一側更自然

面對 C++ 類別卻選 P/Invoke 的結果呈現就算想呼叫 C++ 類別的方法,P/Invoke 能面對的也只有 DLL 的匯出函式,因此需要一層壓成 C 形式的東西,做的事情實質上就是在寫包裝層的流程的圖。想呼叫 C++ 類別的方法能呼叫的只有匯出函式需要一層壓成 C 形式函式做的事情幾乎就是包裝層那就收攏到 C++ 這一側更自然

圖 3: 就算想用 P/Invoke 硬撐,面對 C++ 類別,最後還是得在某處寫包裝層。

3.2. 所有權與生命週期管理看不清楚時

在 C++ 裡,

  • 是呼叫端負責釋放嗎
  • 傳回的指標是借來的嗎
  • const&,還是所有權轉移
  • 內部有沒有快取,對生命週期有沒有前提

這類事情是家常便飯。

把這些拿 C# 的 IntPtr 來表達,一開始能動,之後回頭讀會相當吃力。 一旦開始出現「這個指標,到底誰在什麼時候刪掉」的問題,邊界馬上就混濁了。

用 IntPtr 表達所有權前提時的混濁呈現把誰負責釋放、是借來的還是所有權轉移、生命週期有沒有前提這些 C++ 這一側的事情,用 C# 的 IntPtr 來表達後,之後回頭讀會吃力,邊界也會混濁的圖。誰負責釋放用 C# 的 IntPtr 表達是借來的還是所有權轉移生命週期有沒有前提一開始能動之後讀不懂邊界馬上混濁

圖 4: 把所有權與生命週期的前提用 IntPtr 帶來帶去,就會因為「誰在什麼時候刪掉」的問題讓邊界混濁。

3.3. std::wstringstd::vector、回呼、例外開始出現時

從這一帶開始,P/Invoke 進入「不是寫不出來,但寫起來不舒服」的區域。

  • 想把 std::wstring 原樣在 C# 表達出來
  • 想傳回 std::vector<T>
  • 想用回呼接收原生處理的進度
  • 失敗時會拋出 C++ 例外

這類要素一多,C# 這一側就會冒出 MarshalAs、手動緩衝區、固定長度陣列、委派的生命週期管理、錯誤碼的解讀等等。

當然,肯下工夫還是寫得出來。 只是 下工夫的地方並不是本質,這才是難受的地方。 本來想做的是業務邏輯或 UI,而不是在邊界上比拚格鬥技。

C++ 要素增加與 C# 這一側負擔的累積呈現想傳回 wstring 或 vector、想用回呼接收進度、會拋出 C++ 例外這些要素愈多,C# 這一側的 MarshalAs、手動緩衝區、委派生命週期管理就愈堆愈高的圖。想傳回 wstring 或 vectorC# 這一側的程式碼愈堆愈多想用回呼接收進度失敗時拋出 C++ 例外MarshalAs、手動緩衝區委派生命週期管理、錯誤解讀

圖 5: C++ 風格的要素每多一個,C# 這一側邊界程式碼的負擔就多疊一層。

3.4. 不想把 C++ 的內情漏到 C# 時

原生 DLL 這一側的 API,未必原樣就適合給 C# 用。

例如原生這一側就算是這樣設計的:

  • 把好幾次方法呼叫組合成一次處理
  • 錯誤用傳回值加 out 引數回報
  • 初始化順序有前提
  • 執行緒安全上有限制

也常常會想給 C# 這一側看更直接了當的 API。 作為在這裡做轉換的一層,C++/CLI 相當好用。

轉換原生內情後給 C# 看的一層呈現由 C++/CLI 的轉換層接住初始化順序、執行緒安全限制等原生 API 的設計內情,再給 C# 這一側看更直接了當的 API 的職責劃分的圖。原生 API 的設計內情初始化順序、執行緒限制等C++/CLI 的轉換層給 C# 看直接了當的 API

圖 6: 不把原生這一側的設計內情直接對著 C#,而是插一層 C++/CLI 當作轉換的層。

4. 插一層 C++/CLI 包裝層的架構

架構本身很單純。

給 .NET 用的 API直接處理原生的標頭與型別C# 應用程式C++/CLI 包裝層 DLL原生 C++ DLL

圖 7: 在 C# 應用程式與原生 C++ DLL 之間,插一層 C++/CLI 包裝層 DLL 的架構。

讓 C# 看得到的只有 .NET 風格的 API,並把

  • 字串轉換
  • 陣列與 vector 的轉換
  • 例外的轉換
  • 所有權的釐清
  • 錯誤碼的解讀
  • 必要時吸收執行緒邊界與回呼

都關進 C++/CLI 這一側。

重要的是 別把 C++/CLI 專案本身做得太大。 它的職責始終只是「翻譯」和「整理」。 一旦連業務邏輯都開始放進去,這一層反而變成主角了。

關進 C++/CLI 包裝層的工作呈現把字串與陣列的轉換、例外與錯誤碼的轉換、所有權的釐清這些翻譯與整理的工作關進 C++/CLI 這一側,而不放業務邏輯的職責界線的圖。C++/CLI 包裝層的職責字串、陣列的轉換例外、錯誤碼的轉換所有權的釐清不放業務邏輯

圖 8: 包裝層的職責限定在「翻譯」和「整理」,別做得太大很重要。

5. 用 C++/CLI 會輕鬆在哪裡

5.1. C++ 的型別可以就當 C++ 的型別處理

這一點相當關鍵。 在 C++/CLI 這一側可以 #include 原生的標頭,直接使用 C++ 的型別。

也就是說,C# 這一側不必硬去「重現 C++ 的世界」。 std::wstring 也好,std::vector 也好,先當成 C++ 的型別接住,再依需要的形態交給 .NET 這一側就好。

先接住 C++ 型別再交給 .NET 的流程呈現引入原生的標頭,把 wstring 與 vector 當成 C++ 的型別接住,轉換成需要的形態後再交給 .NET 這一側,因此 C# 這一側不必重現 C++ 世界的圖。原生的 wstring 或 vector在 C++/CLI 這一側當 C++ 型別接住轉換成需要的形態交給 .NET 這一側C# 不必重現 C++ 的世界

圖 9: C++ 的型別先當成 C++ 的型別接住,轉換之後再交給 .NET 就好。

5.2. 可以把 API 整理成 .NET 風格

給 C# 這一側,可以端出

  • string
  • byte[]
  • List<T>
  • IDisposable
  • 例外

這些看慣了的 API 形式。

這個差別看起來不起眼,卻大幅改變了使用端的負擔。 特別是團隊開發時,不熟原生內情的成員也比較容易上手,這點很有用。

5.3. 例外與錯誤的職責比較好釐清

原生這一側混著例外與錯誤碼時,C# 這一側原樣接住會很難處理。 在 C++/CLI 這一側先統一整理一次,

  • 把例外轉換成 .NET 的例外
  • 把錯誤碼轉換成有意義的例外或結果型別
  • 補上日誌需要的脈絡

這些都做得到。

在邊界上先翻譯成一次「有意義的失敗」,呼叫端就會清爽很多。

例外與錯誤碼在邊界上的翻譯呈現把原生這一側混雜的例外與錯誤碼在 C++/CLI 這一側統一整理一次,例外轉成 .NET 的例外,錯誤碼轉成有意義的例外或結果型別,並補上日誌需要的脈絡的圖。原生的例外與錯誤碼在 C++/CLI 這一側統一整理例外轉成 .NET 的例外錯誤碼轉成有意義的形態補上日誌需要的脈絡

圖 10: 在邊界上先翻譯成一次「有意義的失敗」,C# 的呼叫端就會清爽起來。

5.4. 可以把 ABI 的浮動對 C# 藏起來

C++ 的類別與方法,不像 C 的函式那樣有單純的 ABI。 一旦 C# 開始直接認識這些內情,匯出函式與封送處理的細節就會浮上檯面。

插一層 C++/CLI 包裝層,就能 把 C++ 的內情關在 C++ 這一側,只給 C# 看穩定的那一面。 這種分離在函式庫更新時也派得上用場。

用包裝層擋下 ABI 浮動的結構呈現 C++ 的類別與方法不像 C 的函式那樣有單純的 ABI,因此把這些內情關在 C++ 這一側,只給 C# 看穩定的那一面,在函式庫更新時這種分離也派得上用場的圖。C++ 類別的 ABI 並不單純C++ 的內情關在 C++ 這一側只給 C# 看穩定的那一面函式庫更新時也派得上用場的分離

圖 11: 不讓封送處理與匯出函式的細節浮上檯面,只給 C# 看穩定的那一面。

5.5. 階段式遷移比較好做

把既有的原生 DLL 一口氣全部重做太沉重。 用 C++/CLI 包裝層的話,可以先把需要的 API 薄薄包一層,從 C# 這一側新的畫面或新的工作流程開始用起,這種階段式的遷移比較好做。

想一邊活用 Windows 的既有資產,一邊把周邊集中到 .NET 的場合,這種做法相當合用。

6. 程式碼片段

這裡不放「原樣就能跑的完整範例」,只放足以看出邊界樣貌的片段。

6.1. 原生 DLL 這一側的 API 樣貌

// NativeLib.hpp
#pragma once
#include <string>
#include <vector>

namespace NativeLib
{
    struct AnalyzeOptions
    {
        int threshold;
        std::wstring modelPath;
    };

    struct AnalyzeResult
    {
        bool ok;
        std::wstring message;
        std::vector<int> scores;
    };

    class Analyzer
    {
    public:
        explicit Analyzer(const std::wstring& licensePath);
        AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
    };
}

這個 API 就原生 C++ 來說很普通。 但要從 C# 直接碰它,相當費工夫。

6.2. 想用 P/Invoke 做的話會變成這樣

首先,為了從 C# 直接呼叫,得在某處把它壓成 C 形式的函式。 例如就得另外準備這樣的橋接函式。

// 壓成 C API 的橋接層樣貌
extern "C"
{
    __declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
    __declspec(dllexport) void  Analyzer_Destroy(void* handle);

    __declspec(dllexport) int Analyzer_Analyze(
        void* handle,
        const wchar_t* imagePath,
        const AnalyzeOptionsNative* options,
        AnalyzeResultNative* result);
}

C# 這一側大概也會長這樣。

internal sealed class SafeAnalyzerHandle : SafeHandle
{
    private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }

    public override bool IsInvalid => handle == IntPtr.Zero;

    protected override bool ReleaseHandle()
    {
        NativeMethods.Analyzer_Destroy(handle);
        return true;
    }
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
    public int Threshold;
    public IntPtr ModelPath;
}

internal static class NativeMethods
{
    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);

    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern void Analyzer_Destroy(IntPtr handle);

    [DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
    internal static extern int Analyzer_Analyze(
        SafeAnalyzerHandle handle,
        string imagePath,
        ref AnalyzeOptionsNative options,
        out AnalyzeResultNative result);
}

如果到這裡就結束還好,但實際上還會多出

  • 可變長度的資料要怎麼傳回
  • 字串緩衝區由誰釋放
  • 錯誤細節要放在哪裡
  • 回呼的生命週期要怎麼守住

這些議題。

也就是說,本來以為只是選了 P/Invoke,實際上多半已經開始在設計一套 C 相容 API

P/Invoke 方案實質上變成 C 相容 API 設計的流程呈現就算打算用 P/Invoke 直接呼叫,還是得另外準備 C 橋接函式,在 C# 這一側寫 SafeHandle 與 StructLayout,再多出可變長度、釋放、回呼等議題,實質上已經開始設計 C 相容 API 的流程的圖。打算用 P/Invoke 直接呼叫另外準備 C 橋接函式寫 SafeHandle 或 StructLayout可變長度、釋放、回呼的議題實質上開始設計 C 相容 API

圖 12: 本來以為「只是選了 P/Invoke」,回過神來多半是在設計 C 相容 API。

這些議題收攏到 C++/CLI 之後,會替換成下面這樣。它們不會像魔法一樣消失,準確的說法是 移到 C++ 這一側能自然寫出來的形態

P/Invoke 會冒出的議題 P/Invoke 這一側典型的處理 在 C++/CLI 這一側會變成什麼
可變長度的資料要怎麼傳回 準備「查詢所需大小的函式」與「填滿緩衝區的函式」兩段式,由 C# 這一側配置緩衝區 直接接住原生傳回的 std::vector,改裝成 List<int> 或陣列再傳回(6.3)
字串緩衝區由誰釋放 在 C API 加上釋放用的函式,並遵守 C# 這一側一定要呼叫的約定 std::wstring 的生命週期在原生這一側就結束,給 C# 的只是新建一個 String^ 傳回(6.3)
錯誤細節要放在哪裡 除了傳回值的錯誤碼,還要準備取出細節的函式,或以 out 引數傳出的結構 try / catch 接住原生的例外,轉換成有意義的 .NET 例外再重新拋出(6.3)
回呼的生命週期要怎麼守住 為了不讓委派被 GC 回收,用欄位等方式持續保住參考 把回呼的註冊與取消註冊關進 C++ 這一側,只給 C# 看事件或委派
控制代碼的所有權要怎麼表達 衍生 SafeHandle,從 ReleaseHandle 呼叫釋放函式 在包裝層的解構函式/完成項裡 delete 原生的物件(6.3)

6.3. 用 C++/CLI 包裝層可以這樣寫

在 C++/CLI 這一側接住原生的內情,把要給 C# 看的 API 整理好。

// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"

using namespace System;
using namespace System::Collections::Generic;

public ref class AnalysisOptions
{
public:
    property int Threshold;
    property String^ ModelPath;
};

public ref class AnalysisResult
{
public:
    property bool Ok;
    property String^ Message;
    property List<int>^ Scores;
};

public ref class AnalyzerWrapper : IDisposable
{
public:
    AnalyzerWrapper(String^ licensePath);
    ~AnalyzerWrapper();
    !AnalyzerWrapper();

    AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);

private:
    NativeLib::Analyzer* _native;
};

這裡 C++/CLI 特有的是 ~AnalyzerWrapper()!AnalyzerWrapper() 這兩個。兩個看起來都像 C++ 的解構函式,但職責上對應到 .NET 的 Dispose 模式。

C++/CLI 的寫法 編譯器產生的東西 從 C# 看到的行為
~AnalyzerWrapper()(解構函式) 實作 IDisposableDispose() 離開 using 時,或呼叫 Dispose() 時執行
!AnalyzerWrapper()(完成項) 覆寫 Object::FinalizeFinalize() GC 回收時執行。何時執行並不確定

標準做法是 把原生資源的釋放寫在完成項裡,再由解構函式呼叫它。下面的實作中,~AnalyzerWrapper() 只是呼叫 this->!AnalyzerWrapper() 就是這個意思;這樣寫的話,就算 C# 這一側忘了呼叫 Dispose(),最後 GC 也會替你收拾。解構函式被呼叫時,GC::SuppressFinalize 會抑制完成項執行,所以不會變成重複釋放。

另外,Dispose()Finalize()Dispose(bool) 由編譯器產生,所以不用自己在 C++/CLI 這一側寫。反過來說,也不能從 C++/CLI 的程式碼直接呼叫 Dispose(),而是用 delete 運算子呼叫解構函式。這組對照關係整理在 How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn 的「Destructors and finalizers」。

解構函式與完成項的分工呈現從 C# 的 using 或 Dispose 會呼叫解構函式,再經由完成項釋放原生資源,忘了呼叫 Dispose 時由 GC 呼叫完成項最後收拾,並由 SuppressFinalize 防止重複釋放的圖。C# 的 using 或 Dispose解構函式(相當於 Dispose)忘了呼叫 DisposeGC 回收時的完成項呼叫完成項delete 原生資源用 SuppressFinalize 防止重複釋放

圖 13: 釋放寫在完成項,再由解構函式呼叫它的標準做法。就算忘了 Dispose,最後 GC 也會收拾。

// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>

using msclr::interop::marshal_as;

AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
    _native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}

AnalyzerWrapper::~AnalyzerWrapper()
{
    this->!AnalyzerWrapper();
}

AnalyzerWrapper::!AnalyzerWrapper()
{
    delete _native;
    _native = nullptr;
}

AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
    // 若在處置之後被呼叫,就在進入原生之前於此擋下。
    // 解構函式(= Dispose)會把 _native 設成 nullptr,
    // 少了這道檢查就會經由 null 指標進入原生,
    // 得到的不是 .NET 的例外,而是存取違規讓整個處理程序當掉。
    // 從 C# 這一側來看,「Dispose 之後再碰就拋出 ObjectDisposedException」
    // 才是預期的行為,所有使用 _native 的方法都需要這道檢查
    if (_native == nullptr)
    {
        throw gcnew ObjectDisposedException("AnalyzerWrapper");
    }

    NativeLib::AnalyzeOptions nativeOptions{};
    nativeOptions.threshold = options->Threshold;
    nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);

    try
    {
        auto nativeResult = _native->Analyze(
            marshal_as<std::wstring>(imagePath),
            nativeOptions);

        auto managed = gcnew AnalysisResult();
        managed->Ok = nativeResult.ok;
        managed->Message = gcnew String(nativeResult.message.c_str());
        managed->Scores = gcnew List<int>();

        for (int score : nativeResult.scores)
        {
            managed->Scores->Add(score);
        }

        return managed;
    }
    catch (const std::exception& ex)
    {
        throw gcnew InvalidOperationException(gcnew String(ex.what()));
    }
}

C# 這一側就相當直接了當。

using var analyzer = new AnalyzerWrapper(@"C:\license.dat");

var result = analyzer.Analyze(
    @"C:\input.png",
    new AnalysisOptions
    {
        Threshold = 80,
        ModelPath = @"C:\model.bin"
    });

if (!result.Ok)
{
    Console.WriteLine(result.Message);
}

C# 看得到的只有 stringList<int>IDisposableIntPtr、釋放函式、原生字串緩衝區的細節都看不到。 這一點很關鍵。

7. 即使如此也不該選 C++/CLI 的場合

當然,C++/CLI 不是萬能的。 也有不該選它的場合。

  • 對方一開始就公開了乾淨的 C API
    • 這種情況 P/Invoke 比較直接了當。
  • 需要跨平台
    • C++/CLI 以 Windows 為前提。
  • 邊界面很小,型別也單純
    • 多一個包裝層 DLL 的成本反而更大。
  • 對 AOT 或散發限制看得相當嚴格
    • 先看過整體架構的需求比較好。

只有最後的「AOT 或散發限制」比較抽象,這裡把實際會發生作用的限制列出來。這一塊若還帶著 .NET Framework 時代的感覺,很容易踩到。

限制 內容 實務上的影響
OS 以 .NET(.NET Core 系)為目標的 C++/CLI 只支援 Windows 若計畫要在 Linux 容器或 macOS 上跑,在這一步就選不了
Native AOT Native AOT 的不支援清單裡明列了 C++/CLI。連帶地,Assembly.LoadFile 這類動態載入、System.Reflection.Emit、Windows 內建的 COM 也都不能用 與用 PublishAot 產生單一原生二進位檔的方針無法並存
輸出形式 以 .NET 為目標時,不能做成 exe,只能是 DLL。也不能以 .NET Standard 為目標 進入點放在 C# 這一側的 exe,C++/CLI 則以 DLL 的形式被參考
專案格式 使用的是 .vcxproj,而不是 SDK 樣式的 csproj。一個專案也無法同時以多個 .NET 版本為目標 若同時需要 .NET Framework 版與 .NET 版,就把專案檔分開
執行階段相依性 指定 /clr 會連帶啟用 /MD,因此需要 MSVC 執行階段的 DLL。以 .NET 為目標時,還要把 ijwhost.dll 放進輸出目錄 若前提是 XCOPY 散發或單一檔案發行,要先確認清楚
CPU 架構 混合組件含有原生的機器碼指令,因此無法像 C# 的 AnyCPU 那樣用一個二進位檔涵蓋所有架構 按 x86 / x64 等目標分別建置再散發
載入方式 .NET 7 以後一律載入到預設的 AssemblyLoadContext。.NET 6 以前,若最初是從原生這一側被呼叫,有時會載入到另一個 AssemblyLoadContext 在每個外掛各自使用不同載入內容的架構下,要先確認行為

另外,C++/CLI 專案能以 .NET(.NET Core 系)為目標,是 Visual Studio 2019 以後的事。若手上只有更舊的環境,就得先以 .NET Framework 為前提來考慮。

也就是說,判斷基準是「相對於原生 DLL 的複雜度,在哪裡翻譯最自然」。 單純就 P/Invoke,複雜就 C++/CLI。 這樣分辨大致上都行得通。

不該選 C++/CLI 的場合呈現已經有乾淨的 C API、需要跨平台、邊界很小且型別單純、對 AOT 與散發限制看得嚴格這四種場合下,不該選 C++/CLI 包裝層的圖。已經有 C API不選 C++/CLI需要跨平台支援邊界小且型別單純AOT、散發限制嚴格在哪裡翻譯最自然

圖 14: C++/CLI 不是萬能的。若符合這四點,先考慮 P/Invoke 或重新檢視架構。

8. 總結

從 C# 使用原生 DLL 的方法裡,P/Invoke 至今仍是最正統的做法。 只不過,那是 對方作為 C API 夠直接了當時 的情況。

如果原生這一側是以 C++ 函式庫的形式設計的, 與其在 C# 這一側排一堆 IntPtr 與封送處理的屬性硬撐,用 C++/CLI 做一層薄的包裝層,往往更能把邊界維持乾淨

特別是牽涉到

  • 以類別為基礎的 API
  • 所有權的前提
  • std::wstringstd::vector
  • 例外轉換
  • 回呼
  • 階段式的遷移

這些時候,C++/CLI 是相當務實的選項。

要做的事情並不華麗。 但「在哪裡把邊界整理好」這種事,之後在維護上會確實顯現出來。 想同時活用 Windows 的既有資產與 .NET 時,C++/CLI 到現在仍然很好用。

9. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

常見問題

整理諮詢這個主題時常見的問題。

P/Invoke 與 C++/CLI 包裝層該怎麼取捨?
對方若是以 extern "C" 公開的扁平 C 函式群,P/Invoke 最直接了當也最簡單。對方若是以 C++ 類別為核心的函式庫,又牽涉到所有權、字串、陣列、例外、回呼,用 C++/CLI 插一層薄的包裝層會比較好維護。判斷基準是「相對於原生 DLL 的複雜度,在哪裡翻譯最自然」,單純就 P/Invoke、複雜就 C++/CLI,這樣分辨大致上都行得通。
插一層 C++/CLI 包裝層會讓什麼變輕鬆?
在 C++/CLI 這一側可以 #include 原生的標頭,把 std::wstring 與 std::vector 當成 C++ 的型別直接處理,因此 C# 這一側不必重現 C++ 的世界。給 C# 看的只有 string、byte[]、List<T>、IDisposable、例外這些 .NET 風格的 API,IntPtr、釋放函式與封送處理的細節都可以藏起來。C++ 例外與錯誤碼可以在邊界上轉換成 .NET 的例外,也比較好做活用既有資產的階段式遷移。
只用 P/Invoke 走下去,什麼時候會變得辛苦?
原生 DLL 若以 C++ 類別為核心設計,P/Invoke 能直接呼叫的只有 DLL 的匯出函式,最後還是需要一層壓成 C 形式函式的東西,等於實質上開始設計 C 相容 API。再加上想傳回 std::wstring 或 std::vector、想用回呼接收進度、會拋出 C++ 例外這些要素,C# 這一側的 MarshalAs、手動緩衝區、委派的生命週期管理就會愈堆愈高。把所有權與生命週期的前提用 IntPtr 來表達,之後回頭讀會相當吃力。
有不該選 C++/CLI 的情況嗎?
有。對方一開始就公開了乾淨的 C API 時,P/Invoke 比較直接了當。另外 C++/CLI 以 Windows 為前提,需要跨平台時就不能用。邊界面小、型別也單純時,多一個包裝層 DLL 的成本反而更大;對 AOT 或散發限制看得嚴格時,也最好先確認整體架構的需求。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽