更新履歴(4件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(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)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「C#からネイティブDLLを呼ぶ:C++/CLIラッパー vs P/Invoke」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589589 https://staging.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::wstring や std::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 章) |
目次
- まず結論(ひとことで)
- P/Invoke で十分なケース
- P/Invoke が急にしんどくなる境界
- C++/CLI ラッパーを挟む構成
- C++/CLI で何が楽になるのか
- コード抜粋
- それでも C++/CLI を選ばないほうがよいケース
- まとめ
- 参考資料
この記事の知識マップ
この記事は、C#からネイティブDLLを呼ぶ際、相手がextern Cのフラットな関数群ならP/Invokeが素直だが、C++のクラス中心のライブラリで所有権・文字列・例外・コールバックが絡む場合はC++/CLIで薄いラッパーを挟むほうが保守しやすいと整理します。P/InvokeはSafeHandleやStructLayoutでネイティブ型を表現しつつ結局C形式のブリッジ層を自作することになりがちですが、C++/CLIはmarshal_asによる型変換やデストラクター・ファイナライザーによるDispose/Finalizeパターン、例外の.NET変換をC++側に閉じ込め、C#には安定したAPIだけを見せられます。一方でC++/CLIはWindows専用かつNative AOTと両立せず、/clrコンパイルとijwhost.dllへの依存を伴うため、クロスプラットフォームや配布制約が厳しい場合は選べません。
flowchart LR
accTitle: C++/CLIラッパーとP/Invokeの使い分けの知識マップ
accDescr: ネイティブDLLの複雑さに応じてP/InvokeとC++/CLIラッパーをどう選び、マーシャリングや所有権・例外・コールバックの扱い、配布制約がそれぞれどう変わるかを示す図です。
cpp_cli["C++/CLI"]
p_invoke["P/Invoke"]
c_api_bridge["C APIブリッジ層"]
c_native_api["フラットなC API"]
cpp_class_native_library["C++のクラス中心のネイティブライブラリ"]
com_marshaling["マーシャリング"]
marshal_as["marshal_as"]
safehandle["SafeHandle"]
structlayout["StructLayout"]
dispose_finalize_pattern["Dispose/Finalizeパターン(C++/CLI)"]
ownership_lifetime_management["所有権・寿命管理"]
exception_translation["例外の.NET変換"]
callback_delegate_lifetime["コールバックのデリゲート寿命管理"]
native_aot["Native AOT"]
ijwhost["ijwhost.dll"]
clr_compilation_flag["/clrコンパイルオプション"]
mixed_assembly["混合アセンブリ"]
cross_platform_requirement["クロスプラットフォーム要件"]
p_invoke -.->|"前提とする"| c_api_bridge
p_invoke -->|"推奨される対応"| c_native_api
cpp_cli -->|"推奨される対応"| cpp_class_native_library
p_invoke -->|"用いるのは非推奨"| cpp_class_native_library
cpp_cli -->|"用いるのは非推奨"| c_native_api
cpp_cli -->|"利用する"| com_marshaling
marshal_as -->|"実装を担う"| com_marshaling
p_invoke -.->|"利用する"| safehandle
p_invoke -.->|"利用する"| structlayout
cpp_cli -->|"実装を担う"| dispose_finalize_pattern
cpp_cli -->|"前提とする"| ownership_lifetime_management
cpp_cli -->|"実装を担う"| exception_translation
cpp_cli -.->|"前提とする"| callback_delegate_lifetime
p_invoke -.->|"前提とする"| callback_delegate_lifetime
cpp_cli -->|"両立しない"| native_aot
cpp_cli -.->|"前提とする"| ijwhost
cpp_cli -->|"で構成できる"| clr_compilation_flag
cpp_cli -->|"実装を担う"| mixed_assembly
c_api_bridge -->|"前提とする"| c_native_api
cpp_cli -.->|"の後継"| c_api_bridge
cpp_cli -->|"両立しない"| cross_platform_requirement
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全21件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
1. まず結論(ひとことで)
- 相手が C の関数群なら、P/Invoke が素直
- 相手が C++ のライブラリなら、C++/CLI ラッパーを 1 枚挟むと保守しやすい
- 特に クラス・所有権・文字列・配列・例外・コールバック が絡むなら、C# 側に無理をさせないほうがよい
要するに、C# にネイティブ DLL の都合を直接持ち込まない ということです。 ネイティブの都合は C++ 側で受けて、.NET に見せる面だけを整える。 この分業がうまくいくと、コードもデバッグもかなり穏やかになります。
2. P/Invoke で十分なケース
P/Invoke で片付くなら、それがいちばん簡単です。無理に C++/CLI を持ち込む必要はありません。
P/Invoke が向いているのは、たとえばこんなケースです。
extern "C"で公開されたフラットな関数 API になっている- 引数や戻り値が、整数・ポインタ・単純な構造体などで済む
- 文字列の規約が明確で、バッファの責務も単純
- リソース管理が
Create/Destroyのように分かりやすい - C# 側で
SafeHandleやStructLayoutを素直に書ける
このくらい整っているなら、C# 側で宣言して使うだけで済みますし、Windows API を呼ぶ感覚に近いので実装も読みやすくなります。
3. P/Invoke が急にしんどくなる境界
問題は、相手が「ただの C API」ではないときです。 ここから急に空気が変わります。
3.1. C++ のクラスを相手にし始めたとき
ネイティブ DLL が C++ のクラス中心で設計されている場合、本当はクラスのメソッドをそのまま呼びたいわけですが、P/Invoke で直接相手にできるのは DLL のエクスポート関数 です。 つまり、結局どこかで C 形式の関数に落とす層 が必要になります。
この時点で、やっていることはほぼ「ラッパーを書く」です。
だったら、C# 側に IntPtr と解放関数を大量に生やすより、C++ 側にラッパーを寄せたほうが自然 です。
3.2. 所有権と寿命管理が見えにくいとき
C++ では、
- 呼び出し側が解放するのか
- 返されたポインタは借り物なのか
const&なのか所有権移動なのか- 内部でキャッシュしていて寿命に前提があるのか
といった話が普通にあります。
これを C# の IntPtr ベースで表現すると、最初は動いても、後で読み返したときにかなりつらいです。
「このポインタ、誰がいつ消すんだっけ問題」が始まると、境界面はすぐ濁ります。
3.3. std::wstring、std::vector、コールバック、例外が出てきたとき
この辺から、P/Invoke は「書けなくはないが、気持ちよくはない」領域に入ります。
std::wstringをそのまま C# から表したいstd::vector<T>を返したい- ネイティブ処理の進捗をコールバックで受けたい
- 失敗時に C++ 例外が飛ぶ
こういう要素が増えると、C# 側に MarshalAs、手動バッファ、固定長配列、デリゲート寿命管理、エラーコード解釈などが増えてきます。
もちろん頑張れば書けます。 ただ、がんばりどころが本質ではない のがつらいところです。 本来やりたいのは業務ロジックや UI であって、境界面の格闘技ではありません。
3.4. C++ の都合を C# に漏らしたくないとき
ネイティブ DLL 側の API がそのまま C# に向いているとは限りません。
たとえばネイティブ側では、
- 複数のメソッド呼び出しを組み合わせて 1 回の処理にする
- エラーは戻り値と out 引数で返す
- 初期化順序に前提がある
- スレッドセーフ性に制約がある
という設計でも、C# 側にはもっと素直な API を見せたいことが多いです。 ここを変換する層として、C++/CLI はかなり都合がよいです。
4. C++/CLI ラッパーを挟む構成
構成としてはシンプルです。
flowchart LR
Cs[C# アプリ] -->|.NET 向けの API| Wrapper[C++/CLI ラッパー DLL]
Wrapper -->|ネイティブのヘッダーや型を直接扱う| Native[ネイティブ C++ DLL]
C# から見えるのは .NET らしい API だけにして、
- 文字列変換
- 配列やベクターの変換
- 例外の変換
- 所有権の整理
- エラーコードの解釈
- 必要ならスレッド境界やコールバックの吸収
を C++/CLI 側に閉じ込めます。
大事なのは、C++/CLI プロジェクト自体を大きくしすぎない ことです。 役割はあくまで「翻訳」と「整形」です。 業務ロジックまで入れ始めると、今度はその層が主役になってしまいます。
5. C++/CLI で何が楽になるのか
5.1. C++ の型を C++ のまま扱える
これはかなり大きいです。 C++/CLI 側ではネイティブのヘッダーをインクルードして、そのまま C++ の型を使えます。
つまり、C# 側で無理に「C++ の世界を再現」しなくて済みます。
std::wstring も std::vector も、まずは C++ の型として受け止めてから、必要な形で .NET 側に渡せばよいです。
5.2. API を .NET 向けに整形できる
C# 側には、
stringbyte[]List<T>IDisposable- 例外
といった、見慣れた形で API を出せます。
この差は地味に見えて、使う側の負担を大きく変えます。 特にチーム開発だと、ネイティブ事情を知らないメンバーでも触りやすくなるのが効きます。
5.3. 例外とエラーの責務を整理しやすい
ネイティブ側で例外やエラーコードが混在していると、C# 側でそのまま受けるのは扱いづらいです。 C++/CLI 側で一度まとめて、
- 例外は .NET の例外へ変換する
- エラーコードは意味のある例外や結果型に変換する
- ログに必要な文脈を補う
といったことができます。
境界で一度「意味のある失敗」に翻訳しておくと、呼び出し側はかなりすっきりします。
5.4. ABI の揺れを C# 側から隠せる
C++ のクラスやメソッドは、C の関数のように単純な ABI ではありません。 C# が直接その事情を知り始めると、エクスポート関数やマーシャリングの都合が表に出てきます。
C++/CLI ラッパーを挟めば、C++ の都合は C++ 側に閉じ込めて、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 の設計を始めている ことが多いです。
これらの論点は、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」にまとまっています。
// 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# から見えるのは、string と List<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。 この切り分けでだいたいうまくいきます。
8. まとめ
C# からネイティブ DLL を使う方法として、P/Invoke は今でも王道です。 ただし、それは 相手が C API として素直なとき の話です。
ネイティブ側が C++ ライブラリとして設計されているなら、
C# 側に IntPtr とマーシャリング属性を並べて頑張るより、C++/CLI で薄いラッパーを作ったほうが境界面がきれいに保てる ことが多いです。
特に、
- クラスベースの API
- 所有権の前提
std::wstringやstd::vector- 例外変換
- コールバック
- 段階的な移行
が絡むなら、C++/CLI はかなり現実的な選択肢です。
やることは派手ではありません。 でも、こういう「境界をどこで整えるか」は、後の保守性にきっちり効いてきます。 Windows の既存資産と .NET を一緒に生かしたいとき、C++/CLI はまだまだ便利です。
9. 参考資料
- この記事のサンプルコード一式(ネイティブ C++ ライブラリ、C++/CLI ラッパー、C# 消費側) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Interop (C++) のパフォーマンスに関する考慮事項 - Microsoft Learn
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Arm版Windowsで業務アプリは動くのか ── x64エミュレーション(Prism)とネイティブDLL・COMの現実
「Arm版Windowsで業務アプリは動くのか」に開発者・情シス向けに答えます。x64エミュレーション(Prism)の仕組み、ドライバーなど動かない層、.NETのAnyCPUとP/Invokeの問題、Arm対応チェックリストまで整理します。
C# Native AOT DLLをC/C++から呼び出す方法
C# のクラスライブラリを Native AOT でネイティブ DLL として発行し、UnmanagedCallersOnly のエントリポイントを C/C++ から呼び出す構成を、使いどころと実装パターンと注意点から整理します。
WMI/CIMをC#・PowerShellから使う ── ハードウェア情報取得・プロセス監視・リモート照会の実務ガイド
PCのシリアル番号取得、ディスク空き監視、プロセス起動検知の定番がWMI/CIMです。Get-CimInstance等のCIMコマンドレットの使い方と旧Get-WmiObjectからの移行、C#のSystem.ManagementとCIM APIの使い分け、実例レシピと落と...
WindowsアプリでUSB機器を扱う方法 ── 仮想COM・HID・WinUSB・専用SDKの選び方
Windowsアプリから装置やUSB機器を制御する方法を、仮想COMポート・HID・WinUSB・ベンダー製SDKの4方式で比較します。ドライバー導入の要否、複数台接続時の識別、抜き差しへの追従、性能の上限までを実務目線で整理します。
業務システムのコード設計 ── 商品コード・顧客コードの決め方とチェックディジット
商品コード・顧客コードなど業務システムのコード体系を決める実践ガイド。有意コードと無意味連番の判断表、JAN・Luhn等のチェックディジット算式とC#実装、Excelの0落ち対策、桁あふれと移行まで整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
32bit / 64bit テーマ
32bit / 64bit、ネイティブ連携、C++/CLI まわりを整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- 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 や配布制約を厳密に見ている場合も構成全体の要件を先に確認したほうがよいです。