Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke
· Updated: · Go Komura · C++/CLI, C#, Windows Development, Native Interop
Revision history (1 updates, last updated Sep 1, 2026)
A log of the changes made to this article. Where a pre-update version was archived, it stays readable at a permanent DOI link.
- Retranslated as a full translation of the Japanese original. The previous English version was an abridgement that carried only part of the source, so sections, tables, Mermaid diagrams, figure captions and FAQ entries were missing. All of them have been restored to match the Japanese original, and the technical claims are the same as in the Japanese version. Read the version before this update (DOI: 10.5281/zenodo.21614455)
- First published
Cite this article(DOI: 10.5281/zenodo.21614454)
This article is archived on Zenodo. Below are both the DOI that always resolves to the latest version and the DOI pinned to the version you are reading.
Go Komura (2026). Calling Native DLLs from C#: C++/CLI Wrapper vs P/Invoke. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21614454 https://comcomponent.com/en/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI (latest version)
- 10.5281/zenodo.21614454
- DOI (this version)
- 10.5281/zenodo.22217119
Wanting to use existing Windows assets or existing DLLs from C# is a very common requirement. If the other side is a straightforward C interface like the Win32 API, P/Invoke is enough.
But what shows up in real work is quirkier DLLs.
There are C++ classes, ownership conventions, exceptions flying around, and std::wstring and std::vector appearing as a matter of course.
If you try to push through with P/Invoke alone here, the boundary layer usually gets more and more painful.
In this article, we look at what becomes easier when you insert one thin C++/CLI wrapper in such cases. This is not an argument that P/Invoke is bad - the point is that the cases where P/Invoke is sufficient and the cases where C++/CLI pays off are different.
The code excerpts in this article are published on GitHub as a complete buildable sample set (a native C++ library, a C API bridge, a C++/CLI wrapper, and C# consumer code for both the P/Invoke and C++/CLI versions).
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
Who This Article Is For, and What It Assumes
This is written for developers who have called a native DLL from C# and can write a DllImport declaration, but who get stuck the moment the other side becomes a C++ class library. No prior C++/CLI experience is needed. Conversely, if you have never written P/Invoke, it is faster to read “Safely Calling Win32 APIs from C# - A Practical P/Invoke Guide” first.
The assumed environment is Windows plus Visual Studio 2022, in a setup where the C++/CLI wrapper (.vcxproj) and the C# project can live in the same solution. The target can be .NET Framework or .NET 8 or similar, but the .NET side has constraints of its own, which are collected in chapter 7.
Terms to Know Up Front
| Term | Meaning |
|---|---|
| P/Invoke (Platform Invoke) | The mechanism where C# declares an exported function of a native DLL with the DllImport / LibraryImport attribute and calls it directly |
| Marshaling | Converting between .NET types (string, arrays, and so on) and native representations (wchar_t*, raw pointers, and so on) in both directions at the boundary |
| ABI (Application Binary Interface) | The agreements that let compiled binaries fit together, such as the calling convention, how arguments are passed, struct memory layout, and name mangling. The agreements for C functions are simple and stable, but for C++ classes the name mangling and vtable layout are compiler-dependent, so C# cannot rely on them directly (5.4) |
SafeHandle |
The .NET abstract class that wraps a native handle. It is used instead of passing a bare IntPtr around, to prevent leaked handles and use-after-free, where a handle is released while it is still in use (6.2) |
StructLayout |
The C# attribute that makes a struct’s memory layout match the native side. It is used to lay fields out in declaration order with LayoutKind.Sequential, to specify string handling with CharSet, and so on (6.2) |
marshal_as |
The conversion helper provided by C++/CLI. It converts between .NET types and native types in both directions, as in marshal_as<std::wstring>(managedString). You include headers such as msclr/marshal_cppstd.h to use it (6.3) |
| Mixed assembly | A DLL that contains both native machine instructions and MSIL. A C++/CLI wrapper is one of these (chapter 7) |
Table of Contents
- The Conclusion First (In One Line)
- Cases Where P/Invoke Is Sufficient
- The Boundary Where P/Invoke Suddenly Gets Painful
- The Architecture With a C++/CLI Wrapper
- What C++/CLI Makes Easier
- Code Excerpts
- Cases Where You Still Should Not Choose C++/CLI
- Conclusion
- References
Knowledge map for this article
This article explains that when calling a native DLL from C#, P/Invoke is straightforward if the target is a flat set of extern C functions, but that when the library is a class-centric C++ library in which ownership, strings, exceptions, and callbacks are involved, inserting a thin wrapper written in C++/CLI is easier to maintain. With P/Invoke you represent native types with SafeHandle and StructLayout yet still tend to end up hand-writing a C-style bridge layer, whereas C++/CLI confines type conversion through marshal_as, the Dispose/Finalize pattern implemented with a destructor and a finalizer, and the translation of exceptions into .NET exceptions to the C++ side, so that only a stable API is exposed to C#. On the other hand, C++/CLI is Windows-only and incompatible with Native AOT, and it brings a dependency on /clr compilation and on ijwhost.dll, so it cannot be chosen when cross-platform support is required or distribution constraints are strict.
flowchart LR
accTitle: Choosing between a C++/CLI wrapper and P/Invoke
accDescr: Diagram showing how to choose between P/Invoke and a C++/CLI wrapper according to the complexity of the native DLL, and how marshaling, ownership, exceptions, callbacks, and distribution constraints differ between the two.
cpp_cli["C++/CLI"]
p_invoke["P/Invoke"]
c_api_bridge["C API Bridge Layer"]
c_native_api["Flat C API"]
cpp_class_native_library["Class-based native C++ library"]
com_marshaling["Marshaling"]
marshal_as["marshal_as"]
safehandle["SafeHandle"]
structlayout["StructLayout"]
dispose_finalize_pattern["Dispose/Finalize Pattern (C++/CLI)"]
ownership_lifetime_management["Ownership and Lifetime Management"]
exception_translation["Exception Translation at Layer Boundaries"]
callback_delegate_lifetime["Callback Delegate Lifetime Management"]
native_aot["Native AOT"]
ijwhost["ijwhost.dll"]
clr_compilation_flag["/clr Compiler Option"]
mixed_assembly["Mixed Assembly"]
cross_platform_requirement["Cross-platform requirement"]
p_invoke -.->|"requires"| c_api_bridge
p_invoke -->|"recommended for"| c_native_api
cpp_cli -->|"recommended for"| cpp_class_native_library
p_invoke -->|"not recommended for"| cpp_class_native_library
cpp_cli -->|"not recommended for"| c_native_api
cpp_cli -->|"uses"| com_marshaling
marshal_as -->|"implements"| com_marshaling
p_invoke -.->|"uses"| safehandle
p_invoke -.->|"uses"| structlayout
cpp_cli -->|"implements"| dispose_finalize_pattern
cpp_cli -->|"requires"| ownership_lifetime_management
cpp_cli -->|"implements"| exception_translation
cpp_cli -.->|"requires"| callback_delegate_lifetime
p_invoke -.->|"requires"| callback_delegate_lifetime
cpp_cli -->|"incompatible with"| native_aot
cpp_cli -.->|"requires"| ijwhost
cpp_cli -->|"configured by"| clr_compilation_flag
cpp_cli -->|"implements"| mixed_assembly
c_api_bridge -->|"requires"| c_native_api
cpp_cli -.->|"successor to"| c_api_bridge
cpp_cli -->|"incompatible with"| cross_platform_requirement
In the diagram a solid line marks a relation that always holds and a dashed line marks a conditional one (the conditions are given per relation on the detail page). The full list of relations (21 in total, with evidence and certainty) and the definitions of the main concepts are collected on the knowledge map detail page (in Japanese). Data: JSON-LD / Turtle
1. The Conclusion First (In One Line)
- If the other side is a set of C functions, P/Invoke is the natural choice
- If the other side is a C++ library, inserting one C++/CLI wrapper makes it easier to maintain
- Especially when classes, ownership, strings, arrays, exceptions, and callbacks are involved, it is better not to make the C# side strain itself
In short: do not bring the native DLL’s concerns directly into C#. Absorb the native concerns on the C++ side, and present only a polished surface to .NET. When this division of labor works, both the code and the debugging get much less painful.
flowchart TB
accTitle: Choosing based on the shape of the DLL you are calling
accDescr: Shows the conclusion of this article as a branch where a set of C functions leads to P/Invoke and a C++ library leads to inserting one C++/CLI wrapper for easier maintenance.
q{"Which is the DLL you are calling"}
q -->|"A set of C functions"| pi["P/Invoke is the natural choice"]
q -->|"A C++ library"| cli["Insert a C++/CLI wrapper"]
cli -.-> note["Native concerns are absorbed on the C++ side"]
Figure 1: The conclusion on choosing between the two. A set of C functions means P/Invoke, a C++ library means a C++/CLI wrapper.
2. Cases Where P/Invoke Is Sufficient
If P/Invoke gets the job done, it is the simplest option. There is no need to force C++/CLI in.
P/Invoke is well-suited to cases like these.
- The API is a flat set of functions exposed via
extern "C" - Arguments and return values are integers, pointers, simple structs, and so on
- The string conventions are clear and buffer responsibilities are simple
- Resource management is easy to follow, like
Create/Destroy - You can write
SafeHandleandStructLayoutnaturally on the C# side
If things are this tidy, you just declare and call from C#, and since it feels close to calling the Windows API, the implementation stays readable too.
flowchart TB
accTitle: Conditions that make P/Invoke sufficient
accDescr: Shows that when a flat C API, simple arguments and return values, and clear string and resource conventions are all in place, all you need to do is declare and call from the C# side.
c1["Flat function API with extern C"] --> ok["P/Invoke is enough"]
c2["Simple arguments and return values"] --> ok
c3["Clear string and resource conventions"] --> ok
ok --> use["Just declare and call from C#"]
Figure 2: If the API you are calling is this tidy, there is no need to force C++/CLI in.
3. The Boundary Where P/Invoke Suddenly Gets Painful
The problem is when the other side is not “just a C API.” From here the picture changes sharply.
3.1. When You Start Dealing With C++ Classes
If the native DLL is designed around C++ classes, you really want to call the class methods directly - but what P/Invoke can target is the DLL’s exported functions. That means somewhere along the line you need a layer that flattens things into C-style functions.
At that point, what you are doing is essentially “writing a wrapper.”
And if so, rather than sprouting piles of IntPtr and free functions on the C# side, moving the wrapper to the C++ side is more natural.
flowchart TB
accTitle: What happens when you pick P/Invoke against C++ classes
accDescr: Shows the path where you want to call C++ class methods but P/Invoke can only target exported functions, so a layer that flattens things into C style is needed and what you are doing is essentially writing a wrapper.
want["Want to call C++ class methods"] --> limit["Only exported functions can be called"]
limit --> bridge["A layer that flattens into C style functions is needed"]
bridge --> fact["What you are doing is essentially a wrapper"]
fact -.-> better["Then moving it to the C++ side is more natural"]
Figure 3: Even if you try to push through with P/Invoke, a C++ class on the other side means you end up writing a wrapper somewhere anyway.
3.2. When Ownership and Lifetime Management Are Hard to See
In C++, questions like these are entirely routine:
- Does the caller free it?
- Is the returned pointer borrowed?
- Is it a
const&or an ownership transfer? - Is something cached internally with lifetime assumptions?
If you express this with IntPtr on the C# side, it may work at first, but it is quite painful to read back later.
Once the “wait, who frees this pointer and when?” problem begins, the boundary layer turns murky fast.
flowchart TB
accTitle: How ownership assumptions turn murky when expressed with IntPtr
accDescr: Shows that expressing C++ side concerns such as who frees the memory, whether a pointer is borrowed or transferred, and whether lifetime assumptions exist through the C# IntPtr makes the code painful to read back later and turns the boundary murky.
q1["Who frees it"] --> ptr["Express it with C# IntPtr"]
q2["Borrowed or ownership transfer"] --> ptr
q3["Are there lifetime assumptions"] --> ptr
ptr --> bad["Works at first but unreadable later"]
bad --> muddy["The boundary turns murky fast"]
Figure 4: Carrying ownership and lifetime assumptions around as IntPtr values leaves the boundary murky, stuck on the question of who frees this pointer and when.
3.3. When std::wstring, std::vector, Callbacks, and Exceptions Appear
Around this point, P/Invoke enters the territory of “you can write it, but it is no fun.”
- You want to represent
std::wstringdirectly from C# - You want to return a
std::vector<T> - You want to receive native progress via callbacks
- C++ exceptions are thrown on failure
As these elements pile up, the C# side accumulates MarshalAs, manual buffers, fixed-length arrays, delegate lifetime management, error-code interpretation, and more.
Of course, you can write it all if you try hard enough. The painful part is that the effort is not where the substance is. What you actually want to build is business logic or UI, not a martial art of boundary marshaling.
flowchart TB
accTitle: More C++ elements mean more burden on the C# side
accDescr: Shows that as elements such as returning wstring or vector, receiving progress through callbacks, and C++ exceptions being thrown pile up, MarshalAs, manual buffers, and delegate lifetime management accumulate on the C# side.
e1["Want to return wstring or vector"] --> pile["Boundary code piles up on the C# side"]
e2["Want progress through callbacks"] --> pile
e3["C++ exceptions thrown on failure"] --> pile
pile --> load["MarshalAs and manual buffers"]
pile --> load2["Delegate lifetime management and error interpretation"]
Figure 5: Every time another C++ flavored element appears, the burden of boundary code on the C# side grows.
3.4. When You Do Not Want C++ Concerns Leaking Into C#
The native DLL’s API is not necessarily shaped for C# as is.
For example, even if the native side is designed so that:
- Several method calls are combined into one logical operation
- Errors are returned via return values and out parameters
- There are assumptions about initialization order
- There are constraints on thread safety
you usually want to show the C# side a more straightforward API. As the layer that performs this conversion, C++/CLI is remarkably convenient.
flowchart TB
accTitle: A layer that converts native concerns before C# sees them
accDescr: Shows the division of labor where design concerns of the native API such as initialization order and thread safety constraints are absorbed by the C++/CLI conversion layer so that C# is shown a more straightforward API.
nat["Design concerns of the native API"] -.-> ex["Initialization order and thread constraints"]
nat --> conv["C++/CLI conversion layer"]
conv --> api["Show C# a straightforward API"]
Figure 6: Rather than pointing the native design concerns straight at C#, insert C++/CLI as the converting layer.
4. The Architecture With a C++/CLI Wrapper
The architecture is simple.
flowchart LR
Cs[C# app] -->|API designed for .NET| Wrapper[C++/CLI wrapper DLL]
Wrapper -->|Works directly with native headers and types| Native[Native C++ DLL]
Figure 7: The architecture that inserts one C++/CLI wrapper DLL between the C# app and the native C++ DLL.
Make sure that all C# sees is a .NET-flavored API, and confine the following to the C++/CLI side:
- String conversion
- Array and vector conversion
- Exception conversion
- Ownership cleanup
- Error-code interpretation
- If needed, absorbing thread boundaries and callbacks
The important thing is to avoid letting the C++/CLI project itself grow too large. Its role is strictly “translation” and “shaping.” If business logic starts creeping in, that layer ends up being where the real work lives instead.
flowchart TB
accTitle: Work confined to the C++/CLI wrapper
accDescr: Shows the boundary of responsibility where string and array conversion, exception and error code conversion, and ownership cleanup are confined to the C++/CLI side while business logic does not go in.
w["Role of the C++/CLI wrapper"] --> t1["String and array conversion"]
w --> t2["Exception and error code conversion"]
w --> t3["Ownership cleanup"]
w -.-> warn["No business logic"]
Figure 8: Keep the wrapper’s role limited to translation and shaping, and do not let it grow too large.
5. What C++/CLI Makes Easier
5.1. You Can Handle C++ Types as C++ Types
This is a big one. On the C++/CLI side you can include the native headers and use the C++ types directly.
In other words, the C# side no longer has to forcibly “recreate the C++ world.”
std::wstring and std::vector can be received as C++ types first, then handed to the .NET side in whatever form is needed.
flowchart TB
accTitle: Receiving C++ types first and then handing them to .NET
accDescr: Shows that including the native headers lets wstring and vector be received as C++ types and converted into the required form before being handed to the .NET side, so the C# side never has to recreate the C++ world.
nt["Native wstring and vector"] --> recv["Receive as C++ types on the C++/CLI side"]
recv --> conv["Convert into the required form"]
conv --> net["Hand to the .NET side"]
net -.-> nofake["No recreating the C++ world in C#"]
Figure 9: Receive C++ types as C++ types first, then convert them before handing them to .NET.
5.2. You Can Shape the API for .NET
To the C# side you can expose the API in familiar forms:
stringbyte[]List<T>IDisposable- Exceptions
This difference looks modest but greatly changes the burden on consumers. Especially in team development, it pays off that members unfamiliar with native internals can still work with it comfortably.
5.3. Exception and Error Responsibilities Are Easier to Organize
When the native side mixes exceptions and error codes, receiving them raw on the C# side is unwieldy. On the C++/CLI side you can consolidate once:
- Convert exceptions into .NET exceptions
- Convert error codes into meaningful exceptions or result types
- Add the context needed for logging
If you translate failures into “meaningful failures” once at the boundary, the calling side becomes much cleaner.
flowchart TB
accTitle: Translating exceptions and error codes at the boundary
accDescr: Shows that exceptions and error codes mixed on the native side are consolidated once on the C++/CLI side, exceptions are converted into .NET exceptions, error codes are converted into meaningful forms, and the context needed for logging is added.
mixed["Native exceptions and error codes"] --> tr["Consolidate once on the C++/CLI side"]
tr --> e1["Convert exceptions into .NET exceptions"]
tr --> e2["Convert error codes into meaningful forms"]
tr -.-> log["Add the context needed for logging"]
Figure 10: Translating failures into meaningful failures once at the boundary keeps the C# calling side clean.
5.4. You Can Hide ABI Instability From C#
C++ classes and methods do not have a simple ABI the way C functions do. Once C# starts knowing about those details directly, exported-function and marshaling concerns surface in your code.
With a C++/CLI wrapper in between, C++ concerns stay confined to the C++ side, and C# sees only a stable surface. This separation also pays off when the library is updated.
flowchart TB
accTitle: Blocking ABI instability with a wrapper
accDescr: Shows that because C++ classes and methods do not have an ABI as simple as C functions, confining those concerns to the C++ side and showing C# only a stable surface gives a separation that also pays off when the library is updated.
abi["The ABI of C++ classes is not simple"] --> hide["Confine C++ concerns to the C++ side"]
hide --> stable["Show C# only a stable surface"]
stable -.-> update["Separation that pays off on library updates"]
Figure 11: Keep marshaling and exported-function concerns off the surface, and show C# only a stable face.
5.5. Incremental Migration Is Easier
Rewriting an entire existing native DLL all at once is heavy. With a C++/CLI wrapper, you can wrap only the APIs you need, thinly, and start using them from new C# screens and workflows - an incremental migration.
For scenarios where you want to keep existing Windows assets alive while moving the periphery to .NET, the fit is excellent.
6. Code Excerpts
Rather than a “complete sample that runs as is,” here are just enough excerpts to convey what the boundary looks like.
6.1. What the Native DLL’s API Looks Like
// 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);
};
}
As native C++ goes, this API is ordinary. But touching it directly from C# takes real effort.
6.2. What It Becomes If You Try P/Invoke
First, to call it directly from C#, it has to be flattened into C-style functions somewhere. You end up preparing bridge functions like these.
// Sketch of a bridge flattened into a 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);
}
The C# side ends up looking something like this.
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);
}
If it ended there, fine - but in practice more questions keep coming:
- How do you return variable-length data?
- Who frees the string buffers?
- Where do you put error details?
- How do you protect callback lifetimes?
In other words, you thought you chose P/Invoke, but you have effectively started designing a C-compatible API.
flowchart TB
accTitle: How a P/Invoke plan turns into designing a C compatible API
accDescr: Shows the path where even though you intend to call directly with P/Invoke, you prepare separate C bridge functions, write SafeHandle and StructLayout on the C# side, face questions of variable length data and freeing and callbacks, and have effectively started designing a C compatible API.
start["Intend to call directly with P/Invoke"] --> bridge["Prepare separate C bridge functions"]
bridge --> decl["Write SafeHandle and StructLayout"]
decl --> more["Questions of variable length, freeing, callbacks"]
more --> real["Designing a C compatible API begins"]
Figure 12: What felt like simply choosing P/Invoke often turns out to be designing a C-compatible API.
Moving to C++/CLI replaces these questions as follows. They do not vanish by magic - the accurate way to put it is that they move into a form that is natural to write on the C++ side.
| Question that comes up with P/Invoke | Typical answer on the P/Invoke side | What it becomes on the C++/CLI side |
|---|---|---|
| How do you return variable-length data? | Provide a two-step pair of “a function that reports the required size” and “a function that fills the buffer,” and allocate the buffer on the C# side | Receive the std::vector the native side returns as is, and copy it into a List<int> or an array to return (6.3) |
| Who frees the string buffers? | Add a free function to the C API and honor the convention that C# always calls it | The lifetime of the std::wstring is contained on the native side, and C# simply gets a newly created String^ (6.3) |
| Where do you put error details? | On top of the error code in the return value, provide a function that retrieves the details or an out parameter struct | Catch the native exception with try / catch, convert it into a meaningful .NET exception, and rethrow (6.3) |
| How do you protect callback lifetimes? | Hold a reference in a field or similar so the delegate is not collected by the GC | Confine registering and unregistering the callback to the C++ side, and show C# only an event or a delegate |
| How do you express handle ownership? | Derive from SafeHandle and call the free function from ReleaseHandle |
delete the native object in the wrapper’s destructor or finalizer (6.3) |
6.3. How It Reads With a C++/CLI Wrapper
On the C++/CLI side, you absorb the native concerns and shape the API shown to C#.
// 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;
};
The two members specific to C++/CLI here are ~AnalyzerWrapper() and !AnalyzerWrapper(). Both look like C++ destructors, but their roles map onto the .NET Dispose pattern.
| How you write it in C++/CLI | What the compiler generates | Behavior as seen from C# |
|---|---|---|
~AnalyzerWrapper() (destructor) |
Dispose(), which implements IDisposable |
Runs when a using block is exited, or when Dispose() is called |
!AnalyzerWrapper() (finalizer) |
Finalize(), which overrides Object::Finalize |
Runs when the GC collects the object. When it runs is not determined |
The standard approach is to write the release of native resources in the finalizer and call it from the destructor. That is exactly what the implementation below does, where ~AnalyzerWrapper() does nothing but call this->!AnalyzerWrapper(); written this way, even if the C# side forgets to call Dispose(), the GC picks it up in the end. When the destructor does run, GC::SuppressFinalize suppresses finalization, so there is no double free.
Note that Dispose(), Finalize(), and Dispose(bool) are generated by the compiler, so you do not write them yourself in C++/CLI. Conversely, C++/CLI code cannot call Dispose() directly; it invokes the destructor with the delete operator. This correspondence is summarized under “Destructors and finalizers” in How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.
flowchart TB
accTitle: Division of labor between the destructor and the finalizer
accDescr: Shows that a using block or Dispose in C# calls the destructor, which releases the native resource through the finalizer, that a forgotten Dispose still lets the GC call the finalizer so the object is picked up in the end, and that SuppressFinalize prevents a double free.
us["C# using or Dispose"] --> dtor["Destructor (equivalent to Dispose)"]
forget["Forgetting to call Dispose"] -.-> gc["Finalizer at GC collection"]
dtor --> fin["Call the finalizer"]
fin --> del["delete the native resource"]
gc -.-> del
dtor -.-> sup["SuppressFinalize prevents a double free"]
Figure 13: The standard approach of writing the release in the finalizer and calling it from the destructor. Even a forgotten Dispose is picked up by the GC in the end.
// 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)
{
// If this is called after disposal, stop here before entering native code.
// The destructor (= Dispose) sets _native to nullptr, so without this check
// the call goes into native code through a null pointer and takes the whole
// process down with an access violation instead of raising a .NET exception.
// From the C# side, the expected behavior is an ObjectDisposedException when
// the object is touched after Dispose, and every method that uses _native needs it
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()));
}
}
The C# side becomes remarkably plain.
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);
}
What C# sees is string, List<int>, and IDisposable.
The concerns of IntPtr, free functions, and native string buffers are invisible.
That is what matters.
7. Cases Where You Still Should Not Choose C++/CLI
Of course, C++/CLI is not a silver bullet. There are situations where it should not be chosen.
- The other side already exposes a clean C API
- In that case, P/Invoke is the more natural choice.
- You need cross-platform support
- C++/CLI assumes Windows.
- The boundary surface is small and the types are simple
- The cost of adding another wrapper DLL can outweigh the benefit.
- You are looking very strictly at AOT or distribution constraints
- Better to review the requirements of the overall configuration first.
Only that last item, “AOT or distribution constraints,” is abstract, so here are the constraints that actually bite. This is an area where carrying over the instincts of the .NET Framework era makes it easy to trip.
| Constraint | Details | Impact in practice |
|---|---|---|
| OS | C++/CLI targeting .NET (the .NET Core line) is Windows only | If there is any plan to run on Linux containers or macOS, it is already ruled out |
| Native AOT | C++/CLI is explicitly listed as unsupported by Native AOT. Along with it, dynamic loading such as Assembly.LoadFile, System.Reflection.Emit, and Windows built-in COM are unavailable too |
It is incompatible with a plan to produce a single native binary with PublishAot |
| Output format | When targeting .NET, only a DLL is possible, not an exe. .NET Standard cannot be targeted either |
Put the entry point in the C# exe and reference the C++/CLI project as a DLL |
| Project format | You use a .vcxproj, not an SDK-style csproj. A single project also cannot multi-target several .NET versions |
If you need both a .NET Framework build and a .NET build, split the project files |
| Runtime dependencies | Specifying /clr also enables /MD, so the MSVC runtime DLLs are required. When targeting .NET, ijwhost.dll must additionally be placed in the output |
If you are counting on XCOPY deployment or single-file publishing, check this first |
| CPU architecture | Because a mixed assembly contains native machine instructions, one binary cannot cover every architecture the way C# AnyCPU does | Build and ship per target, such as x86 and x64 |
| How it is loaded | From .NET 7 onward it is always loaded into the default AssemblyLoadContext. On .NET 6 and earlier, it can be loaded into a different AssemblyLoadContext when it is first called from the native side |
In setups that give each plugin its own load context, verify the behavior |
Also note that a C++/CLI project can target .NET (the .NET Core line) only from Visual Studio 2019 onward. If all you have is an older environment, you will be planning around .NET Framework to begin with.
So the criterion is: “given the complexity of the native DLL, where is the most natural place to do the translation?” Simple means P/Invoke; complex means C++/CLI. This split works out well most of the time.
flowchart TB
accTitle: Situations where C++/CLI is not the better choice
accDescr: Shows the four situations of a clean C API already existing, cross platform support being required, a small boundary with simple types, and strict AOT or distribution constraints, in which a C++/CLI wrapper is not the better choice.
n1["A C API already exists"] --> no["Do not choose C++/CLI"]
n2["Cross platform support needed"] --> no
n3["Small boundary and simple types"] --> no
n4["Strict AOT and distribution constraints"] --> no
no -.-> judge["Where is translation most natural"]
Figure 14: C++/CLI is not a silver bullet. If any of these four apply, P/Invoke or a rethink of the architecture comes first.
8. Conclusion
As a way to use native DLLs from C#, P/Invoke remains the standard route. But that is true when the other side is well-behaved as a C API.
If the native side is designed as a C++ library, then rather than lining up IntPtr and marshaling attributes on the C# side and toughing it out, building a thin C++/CLI wrapper often keeps the boundary much cleaner.
In particular, when the following are involved:
- Class-based APIs
- Ownership assumptions
std::wstringandstd::vector- Exception conversion
- Callbacks
- Incremental migration
C++/CLI is a very realistic option.
None of this is flashy work. But decisions like “where to tidy up the boundary” pay off squarely in later maintainability. When you want to make existing Windows assets and .NET thrive together, C++/CLI is still genuinely useful.
9. References
- Complete sample code for this article (native C++ library, C++/CLI wrapper, C# consumers) - 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
- Performance considerations for interop (C++) - Microsoft Learn
Related Articles
Recent articles sharing the same tags. Deepen your understanding with closely related topics.
Do Business Apps Run on Windows on Arm? — The Reality of x64 Emulation (Prism) and Native DLLs/COM
An answer, aimed at developers and IT staff, to 'will our business app run on Windows on Arm?' Covers how x64 emulation (Prism) works, th...
Safely Calling Win32 APIs from C# — A Practical P/Invoke Guide (DllImport / LibraryImport / CsWin32)
A practical rundown of what to watch for when calling Win32 APIs and native DLLs from C# via P/Invoke. Covers the differences between Dll...
Calling a C# Native AOT DLL from C/C++
Publish a C# class library as a native DLL with Native AOT and call its UnmanagedCallersOnly entry points from C/C++ - where the setup fi...
Named Pipes in Practice — Windows' Standard IPC from Design to Security
A practical guide to named pipes, Windows' standard inter-process communication. This article organises, from primary sources, the choice...
Using WMI/CIM from C# and PowerShell — A Practical Guide to Hardware Info Retrieval, Process Monitoring, and Remote Queries
WMI/CIM is the standard way to get a PC's serial number, monitor free disk space, and detect process launches. This article covers how to...
Related Topics
These topic pages place the article in a broader service and decision context.
Windows Technical Topics
Topic hub for KomuraSoft LLC's Windows development, investigation, and legacy-asset articles.
32-bit / 64-bit Interoperability
Topic page for 32-bit / 64-bit interoperability, native boundaries, and related Windows design decisions.
Where This Topic Connects
This article connects naturally to the following service pages.
Windows App Development
We support Windows desktop applications that involve resident processing, device integration, operational logging, and maintainable structure.
Frequently Asked Questions
Common questions about the topic of this article.
- How should I choose between P/Invoke and a C++/CLI wrapper?
- If the other side is a flat set of C functions exposed via extern "C", P/Invoke is the straightforward and simplest option. If the other side is a C++ class-based library where ownership, strings, arrays, exceptions, and callbacks are involved, inserting one thin C++/CLI wrapper is easier to maintain. The criterion is where the translation is most natural given the complexity of the native DLL, and the split of simple means P/Invoke, complex means C++/CLI works out well most of the time.
- What does inserting a C++/CLI wrapper make easier?
- On the C++/CLI side you can include the native headers and handle std::wstring and std::vector as C++ types, so the C# side never has to recreate the C++ world. C# can be shown only .NET-flavored APIs such as string, byte[], List<T>, IDisposable, and exceptions, while IntPtr, free functions, and marshaling details stay hidden. C++ exceptions and error codes can be converted into .NET exceptions at the boundary, and incremental migration that keeps existing assets alive becomes easier as well.
- When does going with P/Invoke alone become painful?
- When the native DLL is designed around C++ classes, all P/Invoke can call directly are the DLL's exported functions, so you end up needing a layer that flattens them into C-style functions - which means you have effectively started designing a C-compatible API. Add wanting to return std::wstring or std::vector, wanting progress through callbacks, and C++ exceptions being thrown, and MarshalAs, manual buffers, and delegate lifetime management pile up on the C# side. Expressing ownership and lifetime assumptions in terms of IntPtr becomes quite painful to read back later.
- Are there cases where I should not choose C++/CLI?
- Yes. If the other side already exposes a clean C API, P/Invoke is the more natural choice. C++/CLI also assumes Windows, so it is unusable when you need cross-platform support. When the boundary surface is small and the types are simple, the cost of adding another wrapper DLL can outweigh the benefit, and if you are looking strictly at AOT or distribution constraints it is better to review the requirements of the overall configuration first.