Chiamare DLL native da C#: wrapper C++/CLI vs P/Invoke

· Aggiornato il: · · C++/CLI, C#, Sviluppo Windows, Native Interop

Volere usare asset Windows esistenti o DLL esistenti da C# è un requisito molto comune. Se l’altra parte è un’interfaccia C diretta come le API Win32, P/Invoke basta.

Ma ciò che compare nel lavoro reale sono DLL più particolari. Ci sono classi C++, convenzioni di ownership, eccezioni in giro, e std::wstring e std::vector che appaiono come cosa normale. Se provi a forzare con solo P/Invoke qui, lo strato di confine di solito diventa sempre più doloroso.

In questo articolo guardiamo cosa diventa più facile quando inserisci un sottile wrapper C++/CLI in questi casi. Non è un argomento che P/Invoke sia cattivo — il punto è che i casi in cui P/Invoke è sufficiente e quelli in cui C++/CLI ripaga sono diversi.

Gli estratti di codice in questo articolo sono pubblicati su GitHub come un set di esempi completo, compilabile (una libreria C++ nativa, un bridge API C, un wrapper C++/CLI e codice C# consumer sia per la versione P/Invoke che C++/CLI).

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

1. La conclusione prima di tutto (in una riga)

  • Se l’altra parte è un insieme di funzioni C, P/Invoke è la scelta naturale
  • Se l’altra parte è una libreria C++, inserire un wrapper C++/CLI la rende più facile da mantenere
  • Specialmente quando sono coinvolti classi, ownership, stringhe, array, eccezioni e callback, è meglio non far sforzare il lato C#

In sintesi: non portare i problemi della DLL nativa direttamente in C#. Assorbi i problemi nativi sul lato C++, e presenta a .NET solo una superficie rifinita. Quando questa divisione del lavoro funziona, sia il codice che il debug diventano considerevolmente più tranquilli.

2. Casi in cui P/Invoke è sufficiente

Se P/Invoke fa il lavoro, è l’opzione più semplice. Non c’è bisogno di forzare C++/CLI.

P/Invoke si adatta bene a casi come questi.

  • L’API è un insieme piatto di funzioni esposte tramite extern "C"
  • Argomenti e valori di ritorno sono interi, puntatori, struct semplici e così via
  • Le convenzioni sulle stringhe sono chiare e le responsabilità sui buffer sono semplici
  • La gestione delle risorse è facile da seguire, come Create / Destroy
  • Puoi scrivere SafeHandle e StructLayout naturalmente sul lato C#

Se le cose sono così ordinate, dichiari e chiami da C#, e poiché sembra vicino a chiamare le API Windows, l’implementazione resta leggibile.

3. Il confine dove P/Invoke diventa improvvisamente doloroso

Il problema è quando l’altra parte non è “solo una API C”. Qui l’umore cambia all’improvviso.

3.1. Quando inizi a trattare con classi C++

Se la DLL nativa è progettata intorno a classi C++, vorresti davvero chiamare i metodi di classe direttamente — ma ciò che P/Invoke può puntare sono le funzioni esportate della DLL. Questo significa che da qualche parte devi uno strato che appiattisce le cose in funzioni in stile C.

A quel punto, ciò che stai facendo è essenzialmente “scrivere un wrapper”. E se è così, piuttosto che far germogliare mucchi di IntPtr e funzioni libere sul lato C#, spostare il wrapper sul lato C++ è più naturale.

3.2. Quando ownership e gestione del lifetime sono difficili da vedere

In C++, domande come queste sono del tutto routine:

  • Chi libera?
  • Il puntatore restituito è in prestito?
  • È un const& o un transfer di ownership?
  • Qualcosa è cached internamente con assunzioni sul lifetime?

Se esprimi questo con IntPtr sul lato C#, all’inizio può funzionare, ma è piuttosto doloroso rileggerlo in seguito. Una volta che inizia il problema “aspetta, chi libera questo puntatore e quando?”, lo strato di confine diventa rapidamente torbido.

3.3. Quando appaiono std::wstring, std::vector, callback ed eccezioni

Da qui in poi, P/Invoke entra nel territorio del “scrivibile, ma non piacevole”.

  • Vuoi rappresentare std::wstring direttamente da C#
  • Vuoi restituire un std::vector<T>
  • Vuoi ricevere progresso nativo tramite callback
  • Vengono lanciate eccezioni C++ in caso di fallimento

Mentre questi elementi si accumulano, il lato C# accumula MarshalAs, buffer manuali, array a lunghezza fissa, gestione del lifetime di delegate, interpretazione dei codici di errore e altro.

Certo, puoi scrivere tutto se ci provi abbastanza. La parte dolorosa è che lo sforzo non è dove sta la sostanza. Ciò che vuoi effettivamente costruire è logica di business o UI, non un’arte marziale del marshaling di confine.

3.4. Quando non vuoi che i problemi C++ fuoriescano in C#

L’API della DLL nativa non è necessariamente già sagomata per C# così com’è.

Ad esempio, anche se il lato nativo è progettato in modo tale che:

  • Più chiamate a metodo sono combinate in un’unica operazione logica
  • Gli errori sono restituiti tramite valori di ritorno e parametri out
  • Ci sono assunzioni sull’ordine di inizializzazione
  • Ci sono vincoli di thread safety

di solito vuoi mostrare al lato C# un’API più lineare. Come strato che esegue questa conversione, C++/CLI è notevolmente comodo.

4. L’architettura con un wrapper C++/CLI

L’architettura è semplice.

API progettata per .NETLavora direttamente con header e tipi nativiapp C#DLL wrapper C++/CLIDLL C++ nativo

Assicurati che C# veda solo un’API in stile .NET, e confina quanto segue nel lato C++/CLI:

  • Conversione di stringhe
  • Conversione di array e vector
  • Conversione di eccezioni
  • Cleanup dell’ownership
  • Interpretazione dei codici di errore
  • Se necessario, assorbimento di confini di thread e callback

La cosa importante è non lasciare che il progetto C++/CLI stesso cresca troppo. Il suo ruolo è strettamente “traduzione” e “modellatura”. Se la logica di business inizia a insinuarsi, quel livello diventa il protagonista.

5. Cosa rende più facile C++/CLI

5.1. Puoi gestire i tipi C++ come tipi C++

Questo è importante. Sul lato C++/CLI puoi includere gli header nativi e usare i tipi C++ direttamente.

In altre parole, il lato C# non deve più ricreare forzatamente “il mondo C++”. std::wstring e std::vector possono essere ricevuti prima come tipi C++, poi passati al lato .NET nella forma necessaria.

5.2. Puoi sagomare l’API per .NET

Al lato C# puoi esporre l’API in forme familiari:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • Eccezioni

Questa differenza sembra modesta ma cambia molto il carico sui consumatori. Specialmente nello sviluppo di team, paga il fatto che membri non familiari con i dettagli nativi possano comunque lavorarci comodamente.

5.3. Le responsabilità sulle eccezioni e gli errori sono più facili da organizzare

Quando il lato nativo mescola eccezioni e codici di errore, riceverli grezzi sul lato C# è scomodo. Sul lato C++/CLI puoi consolidare una volta:

  • Convertire le eccezioni in eccezioni .NET
  • Convertire i codici di errore in eccezioni o tipi di risultato significativi
  • Aggiungere il contesto necessario per il logging

Se traduci i fallimenti in “fallimenti significativi” una volta al confine, il lato chiamante diventa molto più pulito.

5.4. Puoi nascondere l’instabilità dell’ABI a C#

Le classi e i metodi C++ non hanno un ABI semplice come le funzioni C. Una volta che C# inizia a conoscere quei dettagli direttamente, emergono preoccupazioni su funzioni esportate e marshaling nel tuo codice.

Con un wrapper C++/CLI in mezzo, i problemi C++ restano confinati sul lato C++, e C# vede solo una superficie stabile. Questa separazione ripaga anche quando la libreria viene aggiornata.

5.5. La migrazione incrementale è più facile

Riscrivere un’intera DLL nativa esistente in una volta è pesante. Con un wrapper C++/CLI, puoi wrappare solo le API che ti servono, sottilmente, e iniziare a usarle da nuove schermate e flussi C# — una migrazione incrementale.

Per scenari in cui vuoi mantenere vivi gli asset Windows esistenti mentre sposti la periferia verso .NET, l’abbinamento è eccellente.

6. Estratti di codice

Piuttosto che un “esempio completo che gira così com’è”, ecco abbastanza estratti da trasmettere com’è fatto il confine.

6.1. Com’è l’API della DLL nativa

// 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);
    };
}

Per essere C++ nativo, questa API è ordinaria. Ma toccarla direttamente da C# richiede uno sforzo reale.

6.2. Cosa diventa se provi con P/Invoke

Per prima cosa, per chiamarla direttamente da C#, deve essere appiattita in funzioni in stile C da qualche parte. Finisci per preparare funzioni bridge come queste.

// Abbozzo di bridge appiattito in API C
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);
}

Il lato C# finisce per apparire più o meno così.

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);
}

Se finisse qui, andrebbe bene — ma in pratica continuano a venire altre domande:

  • Come restituisci dati a lunghezza variabile?
  • Chi libera i buffer stringa?
  • Dove metti i dettagli dell’errore?
  • Come proteggi i lifetime dei callback?

In altre parole, pensavi di aver scelto P/Invoke, ma in effetti hai iniziato a progettare un’API compatibile C.

6.3. Come si legge con un wrapper C++/CLI

Sul lato C++/CLI, assorbi i problemi nativi e sagomi l’API mostrata a 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;
};
// 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)
{
    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()));
    }
}

Il lato C# diventa notevolmente semplice.

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);
}

Ciò che C# vede è string, List<int> e IDisposable. Le preoccupazioni di IntPtr, funzioni libere e buffer stringa nativi sono invisibili. Questo è ciò che conta.

7. Casi in cui non dovresti ancora scegliere C++/CLI

Certo, C++/CLI non è una panacea. Ci sono situazioni in cui non va scelto.

  • L’altra parte espone già una API C pulita
    • In quel caso, P/Invoke è la scelta più naturale.
  • Hai bisogno di supporto cross-platform
    • C++/CLI assume Windows.
  • La superficie di confine è piccola e i tipi sono semplici
    • Il costo di aggiungere un’altra DLL wrapper può superare il beneficio.
  • Stai guardando molto strettamente i vincoli AOT o di distribuzione
    • Meglio rivedere prima i requisiti dell’intera configurazione.

Quindi il criterio è: “data la complessità della DLL nativa, dov’è il posto più naturale per fare la traduzione?” Semplice → P/Invoke; complesso → C++/CLI. Questa divisione funziona bene la maggior parte delle volte.

8. Conclusione

Come modo per usare DLL native da C#, P/Invoke resta la via standard. Ma questo è vero quando l’altra parte è ben comportata come API C.

Se il lato nativo è progettato come libreria C++, allora piuttosto che allineare IntPtr e attributi di marshaling sul lato C# e resistere, costruire un sottile wrapper C++/CLI spesso mantiene il confine molto più pulito.

In particolare, quando sono coinvolti:

  • API basate su classi
  • Assunzioni di ownership
  • std::wstring e std::vector
  • Conversione di eccezioni
  • Callback
  • Migrazione incrementale

C++/CLI è un’opzione molto realistica.

Nessuno di questi è un lavoro appariscente. Ma decisioni come “dove ripulire il confine” ripagano quadratamente nella manutenibilità futura. Quando vuoi far convivere asset Windows esistenti e .NET, C++/CLI è ancora genuinamente utile.

9. Riferimenti

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Quando P/Invoke è sufficiente per chiamare una DLL nativa da C#?
P/Invoke è la scelta naturale quando l'altra parte è un insieme piatto di funzioni C esposte tramite extern "C", con argomenti e valori di ritorno che sono interi, puntatori o struct semplici. Aiuta anche se le convenzioni sulle stringhe sono chiare, le responsabilità sui buffer sono semplici e la gestione delle risorse segue un pattern facile da seguire come Create/Destroy. In quei casi dichiari e chiami da C#, in modo simile a chiamare le API Windows, e il codice resta leggibile.
Quando dovrei usare un wrapper C++/CLI invece di P/Invoke?
Un wrapper C++/CLI ripaga quando la DLL nativa è progettata come libreria C++ piuttosto che come API C — quando sono coinvolte classi C++, convenzioni di ownership, std::wstring, std::vector, eccezioni e callback. P/Invoke può puntare solo a funzioni esportate, quindi un'API a classi C++ deve comunque essere appiattita da qualche parte, e fare quella traduzione sul lato C++ è più naturale che ammucchiare IntPtr e attributi di marshaling in C#. La regola empirica: confine semplice, P/Invoke; confine C++ complesso, C++/CLI.
Cosa rende effettivamente più facile un wrapper C++/CLI?
Sul lato C++/CLI puoi includere gli header nativi e gestire tipi C++ direttamente, poi esporre un'API in stile .NET usando string, byte[], List<T>, IDisposable e eccezioni .NET. Conversione di stringhe e vector, conversione di eccezioni, cleanup dell'ownership e interpretazione dei codici di errore restano confinati nel wrapper, quindi C# non vede mai IntPtr o buffer nativi. Inoltre nasconde l'instabilità dell'ABI C++ dietro una superficie stabile e rende più facile la migrazione incrementale di asset nativi esistenti.
Quando dovrei evitare C++/CLI?
Evitalo quando il lato nativo espone già una API C pulita, perché P/Invoke è allora la scelta più naturale. C++/CLI assume Windows, quindi non è adatto se serve supporto cross-platform. Non vale la pena quando la superficie di confine è piccola e i tipi sono semplici, perché aggiungere un'altra DLL wrapper può costare più di quanto risparmi, e merita attenzione se hai vincoli rigidi su AOT o distribuzione.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog