C#からネイティブDLLを呼ぶ:C++/CLIラッパー vs P/Invoke

· 更新日: · · C++/CLI, C#, Windows開発, ネイティブ連携

更新履歴(6件・最終更新 2026年08月22日)

この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。

レビュー指摘に対応し、今日追加した図のうち幅が大きすぎたものを縦向きの構成に直し、一部の図とキャプションの表現を本文の記述に合わせて正確にしました。本文の文章は変えていません。
P/InvokeとC++/CLIの使い分けや境界の設計を図でも追えるように、Mermaid図を13点追加しました(地の文500〜750字につき1図の規約に合わせたものです)。既存の構成図にはキャプションを付けました。本文の文章は変えていません。
記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
C++/CLIラッパーの`Analyze`が破棄後の検査をしていませんでした。デストラクター(Dispose)が`_native`を`nullptr`にしているため、破棄したあとに残っている参照から呼ぶと、`ObjectDisposedException`ではなくnullポインター経由でネイティブに入り、アクセス違反でプロセスごと落ちます。`_native`を使う前に検査して例外にするようにしました。
冒頭に対象読者と前提、用語の表(ABI、`SafeHandle`、`StructLayout`、`marshal_as`、マーシャリング、混合アセンブリ)を初出章つきで追加しました。`~T()`と`!T()`が`Dispose`と`Finalize`に対応することの注釈、AOTと配布の制約を実務影響つきで整理した表、P/Invokeでの論点がC++/CLI側ではどうなるかの対応表を追加しました。
初版公開
この記事を引用する(DOI(登録済みアーカイブ): 10.5281/zenodo.21589589)

以下のDOIは過去に登録されたアーカイブを指しており、現在の本文とは一致しない場合があります。現在の本文を参照するときは、このページのURLを使用してください。

小村 豪(2026)「C#からネイティブDLLを呼ぶ:C++/CLIラッパー vs P/Invoke」合同会社小村ソフト. https://comcomponent.com/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/

DOI(登録済みアーカイブ)
10.5281/zenodo.21589589
DOI(前回登録した版)
10.5281/zenodo.21732611

Windows の既存資産や既存 DLL を C# から使いたい、という要件はかなりよくあります。 相手が Win32 API のような素直な C インターフェースなら、P/Invoke で十分です。

ただ、実務で出てくるのはもっと癖のある DLL です。 C++ のクラスがあり、所有権の流儀があり、例外も飛び、std::wstringstd::vector も普通に出てきます。 ここで P/Invoke だけで押し切ると、たいてい境界面がだんだん苦しくなります。

この記事では、そういうときに C++/CLI で薄いラッパーを 1 枚挟む と何が楽になるのかを書いていきます。 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 属性で、ネイティブ DLL のエクスポート関数を宣言して直接呼ぶ仕組みです
マーシャリング .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 ラッパーを 1 枚挟むと保守しやすい
  • 特に クラス・所有権・文字列・配列・例外・コールバック が絡むなら、C# 側に無理をさせないほうがよい

要するに、C# にネイティブ DLL の都合を直接持ち込まない ということです。 ネイティブの都合は C++ 側で受けて、.NET に見せる面だけを整える。 この分業がうまくいくと、コードもデバッグもかなり穏やかになります。

相手のDLLの形で決まる使い分け相手がCの関数群ならP/Invokeが素直で、C++のライブラリならC++/CLIラッパーを1枚挟むと保守しやすいという、この記事の結論を分岐で示す。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やvectorを返したいC#側の記述が積み上がるコールバックで進捗を受けたい失敗時にC++例外が飛ぶMarshalAs・手動バッファデリゲート寿命管理・エラー解釈

図5: C++らしい要素が増えるたびに、C#側の境界コードの負担が積み上がっていく。

3.4. C++ の都合を C# に漏らしたくないとき

ネイティブ DLL 側の API がそのまま C# に向いているとは限りません。

たとえばネイティブ側では、

  • 複数のメソッド呼び出しを組み合わせて 1 回の処理にする
  • エラーは戻り値と out 引数で返す
  • 初期化順序に前提がある
  • スレッドセーフ性に制約がある

という設計でも、C# 側にはもっと素直な API を見せたいことが多いです。 ここを変換する層として、C++/CLI はかなり都合がよいです。

ネイティブの都合を変換してC#に見せる層初期化順序やスレッドセーフ性の制約などネイティブAPIの設計の都合を、C++/CLIの変換層で受け止めて、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を1枚挟む構成。

C# から見えるのは .NET らしい API だけにして、

  • 文字列変換
  • 配列やベクターの変換
  • 例外の変換
  • 所有権の整理
  • エラーコードの解釈
  • 必要ならスレッド境界やコールバックの吸収

を C++/CLI 側に閉じ込めます。

大事なのは、C++/CLI プロジェクト自体を大きくしすぎない ことです。 役割はあくまで「翻訳」と「整形」です。 業務ロジックまで入れ始めると、今度はその層が主役になってしまいます。

C++/CLIラッパーに閉じ込める仕事文字列や配列の変換、例外とエラーコードの変換、所有権の整理といった翻訳と整形の仕事をC++/CLI側に閉じ込め、業務ロジックは入れないという役割の線引きを示す。C++/CLIラッパーの役割文字列・配列の変換例外・エラーコードの変換所有権の整理業務ロジックは入れない

図8: ラッパーの役割は「翻訳」と「整形」に限定し、大きくしすぎないのが大事。

5. C++/CLI で何が楽になるのか

5.1. C++ の型を C++ のまま扱える

これはかなり大きいです。 C++/CLI 側ではネイティブのヘッダーをインクルードして、そのまま C++ の型を使えます。

つまり、C# 側で無理に「C++ の世界を再現」しなくて済みます。 std::wstringstd::vector も、まずは C++ の型として受け止めてから、必要な形で .NET 側に渡せばよいです。

C++の型を受け止めてから.NETへ渡す流れネイティブのヘッダーをインクルードしてwstringやvectorをC++の型のまま受け止め、必要な形に変換してから.NET側へ渡すことで、C#側でC++の世界を再現しなくて済むことを示す。ネイティブのwstringやvectorC++/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 側ではどうなるか
可変長データをどう返すか 「必要なサイズを問い合わせる関数」と「バッファを埋める関数」を 2 段構えで用意し、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() の 2 つです。どちらも C++ のデストラクターに見えますが、役割は .NET の Dispose パターンに対応しています。

C++/CLI の書き方 コンパイラーが生成するもの C# から見た挙動
~AnalyzerWrapper()(デストラクター) IDisposable を実装する Dispose() using を抜けたとき、または Dispose() を呼んだときに実行される
!AnalyzerWrapper()(ファイナライザー) Object::Finalize をオーバーライドする Finalize() 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相当)Disposeの呼び忘れGC回収時にファイナライザーファイナライザーを呼ぶネイティブ資源をdeleteSuppressFinalizeで二重解放を防ぐ

図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>IDisposable です。 IntPtr や解放関数やネイティブ文字列バッファの都合は見えません。 ここが大きいです。

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 として参照する構成にします
プロジェクト形式 SDK スタイルの csproj ではなく .vcxproj を使います。1 つのプロジェクトで複数の .NET をマルチターゲットすることもできません .NET Framework 版と .NET 版の両方が必要なら、プロジェクトファイルを分けます
ランタイム依存 /clr を指定すると /MD も有効になるため、MSVC ランタイムの DLL が必要です。.NET をターゲットにする場合はさらに ijwhost.dll を出力に置く必要があります XCOPY 配布や単一ファイル発行を前提にしているなら、先に確認しておきます
CPU アーキテクチャ 混合アセンブリはネイティブの機械語命令を含むので、C# の AnyCPU のように 1 つのバイナリで全アーキテクチャをまかなうことはできません 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や配布制約を厳密に見ているという4つの場面では、C++/CLIラッパーを選ばないほうがよいことを示す。C APIが既にあるC++/CLIを選ばないクロス対応が必要境界が小さく型も単純AOT・配布制約が厳格どこで翻訳するのが自然か

図14: C++/CLIは万能ではない。この4つに当てはまるなら、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 で薄いラッパーを1枚挟むほうが保守しやすくなります。判断基準は「ネイティブ DLL の複雑さに対して、どこで翻訳するのがいちばん自然か」で、単純なら P/Invoke、複雑なら C++/CLI という切り分けでだいたいうまくいきます。
C++/CLIラッパーを挟むと何が楽になりますか?
C++/CLI 側ではネイティブのヘッダーをインクルードして 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 や配布制約を厳密に見ている場合も構成全体の要件を先に確認したほうがよいです。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る