Appeler une DLL native depuis C# : wrapper C++/CLI vs P/Invoke

· Mis à jour le: · · C++/CLI, C#, Développement Windows, Interopérabilité native

Vouloir utiliser depuis C# des actifs Windows existants ou des DLL déjà en place est une exigence assez courante. Si l’autre côté est une interface C simple comme l’API Win32, P/Invoke suffit.

Mais ce qui apparaît dans la pratique, ce sont des DLL plus capricieuses. Il y a des classes C++, des conventions de propriété, des exceptions qui volent, et std::wstring ou std::vector apparaissent tout naturellement. Si l’on force le passage avec P/Invoke seul dans ce cas, la frontière devient généralement de plus en plus douloureuse.

Dans cet article, nous allons voir ce qui devient plus facile quand on insère à ce moment-là un wrapper C++/CLI léger. Il ne s’agit pas de dire que P/Invoke est mauvais : le propos est que les cas où P/Invoke suffit et les cas où C++/CLI est efficace sont différents.

Par ailleurs, les extraits de code présentés dans cet article sont publiés sur GitHub sous la forme d’un ensemble d’exemples buildable (une bibliothèque C++ native, un pont d’API C, un wrapper C++/CLI, et le code consommateur C# en version P/Invoke et en version C++/CLI).

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

Table des matières

  1. D’abord la conclusion (en une ligne)
  2. Les cas où P/Invoke suffit
  3. La frontière où P/Invoke devient soudain pénible
  4. L’architecture avec un wrapper C++/CLI
  5. Ce que C++/CLI facilite
  6. Extraits de code
  7. Les cas où il vaut mieux tout de même ne pas choisir C++/CLI
  8. Résumé
  9. Références

1. D’abord la conclusion (en une ligne)

  • Si l’autre côté est un ensemble de fonctions C, P/Invoke est le choix naturel
  • Si l’autre côté est une bibliothèque C++, insérer un wrapper C++/CLI facilite la maintenance
  • En particulier si des classes, de la propriété, des chaînes, des tableaux, des exceptions et des callbacks sont en jeu, il vaut mieux ne pas forcer le côté C#

En somme, cela revient à ne pas apporter directement au C# les contraintes de la DLL native. On absorbe les contraintes natives côté C++, et on ne présente au .NET que la surface polie. Quand cette répartition du travail fonctionne bien, le code comme le débogage deviennent nettement plus sereins.

2. Les cas où P/Invoke suffit

Si P/Invoke suffit à régler le problème, c’est l’option la plus simple. Il n’y a pas lieu de forcer l’introduction de C++/CLI.

P/Invoke convient par exemple dans des cas comme ceux-ci.

  • L’API est un ensemble plat de fonctions exposées via extern "C"
  • Les arguments et les valeurs de retour se limitent à des entiers, des pointeurs, des structures simples, etc.
  • Les conventions de chaînes sont claires et la responsabilité des buffers est simple
  • La gestion des ressources est facile à suivre, comme un schéma Create / Destroy
  • On peut écrire naturellement SafeHandle et StructLayout côté C#

Si les choses sont aussi bien rangées, il suffit de déclarer et d’utiliser depuis C#, et comme cela ressemble à l’appel de l’API Windows, l’implémentation reste facile à lire.

3. La frontière où P/Invoke devient soudain pénible

Le problème survient quand l’autre côté n’est pas « une simple API C ». C’est à partir de là que l’ambiance change brusquement.

3.1. Quand on commence à avoir affaire à des classes C++

Si la DLL native est conçue autour de classes C++, on aimerait en réalité appeler directement les méthodes des classes, mais ce que P/Invoke peut viser directement, ce sont les fonctions exportées de la DLL. Autrement dit, il faut de toute façon, quelque part, une couche qui ramène cela à des fonctions de style C.

À ce stade, ce que l’on fait revient presque à « écrire un wrapper ». Dans ce cas, plutôt que de faire pousser une multitude d’IntPtr et de fonctions de libération côté C#, il est plus naturel de déplacer le wrapper côté C++.

3.2. Quand la propriété et la gestion de la durée de vie sont difficiles à voir

En C++, des questions comme celles-ci sont tout à fait courantes :

  • Est-ce à l’appelant de libérer ?
  • Le pointeur retourné est-il emprunté ?
  • S’agit-il d’un const& ou d’un transfert de propriété ?
  • Y a-t-il une mise en cache interne avec des hypothèses sur la durée de vie ?

Si on exprime cela avec IntPtr côté C#, ça peut fonctionner au début, mais c’est assez pénible à relire ensuite. Dès que le problème « qui supprime ce pointeur et quand, déjà ? » commence à se poser, la frontière devient rapidement trouble.

3.3. Quand std::wstring, std::vector, les callbacks et les exceptions entrent en jeu

C’est à partir d’ici que P/Invoke entre dans un territoire « pas impossible à écrire, mais pas agréable ».

  • On veut représenter std::wstring tel quel depuis C#
  • On veut retourner un std::vector<T>
  • On veut recevoir la progression du traitement natif via un callback
  • Une exception C++ est levée en cas d’échec

Quand ces éléments s’accumulent, le côté C# voit croître MarshalAs, les buffers manuels, les tableaux de longueur fixe, la gestion de la durée de vie des délégués, l’interprétation des codes d’erreur, et ainsi de suite.

Bien sûr, on peut l’écrire en s’y employant. Mais ce qui est pénible, c’est que là où il faut redoubler d’efforts n’est pas l’essentiel. Ce que l’on veut vraiment faire, c’est de la logique métier ou de l’UI, pas un art martial de la frontière.

3.4. Quand on ne veut pas laisser fuir les contraintes de C++ vers C#

L’API du côté de la DLL native n’est pas forcément adaptée telle quelle à C#.

Par exemple, même si le côté natif est conçu de la façon suivante :

  • Combiner plusieurs appels de méthodes en un seul traitement
  • Renvoyer les erreurs via la valeur de retour et des arguments out
  • Avoir des hypothèses sur l’ordre d’initialisation
  • Avoir des contraintes de sécurité vis-à-vis des threads

on veut souvent, côté C#, montrer une API bien plus directe. En tant que couche qui effectue cette conversion, C++/CLI est remarquablement pratique.

4. L’architecture avec un wrapper C++/CLI

L’architecture est simple.

API pensée pour .NETManipule directement les en-têtes et types natifsApplication C#DLL wrapper C++/CLIDLL C++ native

Ce que voit C# se limite à une API à la façon .NET, et l’on confine côté C++/CLI :

  • La conversion de chaînes
  • La conversion des tableaux et des vecteurs
  • La conversion des exceptions
  • La mise en ordre de la propriété
  • L’interprétation des codes d’erreur
  • Si nécessaire, l’absorption des frontières de threads et des callbacks

L’important est de ne pas laisser grossir excessivement le projet C++/CLI lui-même. Son rôle reste strictement la « traduction » et la « mise en forme ». Si l’on commence à y faire entrer de la logique métier, c’est alors cette couche qui devient le personnage principal.

5. Ce que C++/CLI facilite

5.1. On peut manipuler les types C++ tels quels

C’est un point assez important. Côté C++/CLI, on peut inclure les en-têtes natifs et utiliser directement les types C++.

Autrement dit, le côté C# n’a plus besoin de forcer la « reconstitution du monde C++ ». std::wstring comme std::vector peuvent d’abord être reçus comme des types C++, puis transmis sous la forme voulue au côté .NET.

5.2. On peut mettre l’API en forme pour .NET

Au côté C#, on peut exposer l’API sous des formes familières comme :

  • string
  • byte[]
  • List<T>
  • IDisposable
  • les exceptions

Cette différence paraît discrète, mais elle change considérablement la charge du côté utilisateur. En particulier en développement d’équipe, cela permet même aux membres qui ne connaissent pas les subtilités natives de manipuler le code facilement, ce qui est précieux.

5.3. Il devient plus facile d’organiser les responsabilités des exceptions et des erreurs

Si le côté natif mélange exceptions et codes d’erreur, les recevoir tels quels côté C# est difficile à gérer. Côté C++/CLI, on peut les regrouper une fois pour toutes et :

  • convertir les exceptions en exceptions .NET
  • convertir les codes d’erreur en exceptions ou en types de résultat porteurs de sens
  • ajouter le contexte nécessaire à la journalisation

En traduisant une fois à la frontière en « échecs porteurs de sens », le côté appelant devient nettement plus clair.

5.4. On peut cacher au côté C# les fluctuations de l’ABI

Les classes et méthodes C++ n’ont pas un ABI aussi simple que celui des fonctions C. Dès que C# commence à connaître directement ces détails, les contraintes des fonctions exportées et du marshaling remontent à la surface.

En insérant un wrapper C++/CLI, on peut confiner les contraintes de C++ côté C++, et ne présenter à C# qu’une surface stable. Cette séparation est également utile lors des mises à jour de la bibliothèque.

5.5. La migration progressive est facile

Refaire d’un coup l’intégralité d’une DLL native existante est lourd. Avec un wrapper C++/CLI, on peut d’abord envelopper légèrement seulement les API nécessaires, puis commencer à les utiliser depuis de nouveaux écrans ou workflows côté C# : une migration progressive facile à mener.

Cela s’accorde particulièrement bien avec les situations où l’on veut tirer parti des actifs Windows existants tout en rapprochant la périphérie de .NET.

6. Extraits de code

Ici, plutôt qu’un « échantillon complet fonctionnant tel quel », nous ne présentons que des extraits suffisants pour donner une idée de la frontière.

6.1. Aperçu de l’API côté DLL native

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

Cette API est ordinaire pour du C++ natif. Mais la manipuler directement depuis C# demande un effort non négligeable.

6.2. Ce que cela donne si l’on essaie avec P/Invoke

D’abord, pour appeler directement depuis C#, il faut, quelque part, ramener cela à des fonctions de style C. On finit par devoir préparer séparément des fonctions de pont comme celles-ci, par exemple.

// Aperçu d'un pont ramené à une 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);
}

Le côté C# prend, lui aussi, cette allure.

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

Si cela s’arrêtait là, ce serait bien, mais en pratique, d’autres questions continuent de s’ajouter :

  • Comment retourner des données de longueur variable ?
  • Qui libère les buffers de chaînes ?
  • Où placer les détails de l’erreur ?
  • Comment protéger la durée de vie des callbacks ?

Autrement dit, on pensait avoir choisi P/Invoke, mais on a en fait commencé, dans les faits, à concevoir une API compatible C.

6.3. Ce que cela donne avec un wrapper C++/CLI

Côté C++/CLI, on absorbe les contraintes natives et on met en forme l’API présentée à 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()));
    }
}

Le côté C# devient remarquablement simple.

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

Ce que voit C#, ce sont string, List<int> et IDisposable. Les contraintes d’IntPtr, des fonctions de libération et des buffers de chaînes natifs sont invisibles. C’est là que réside l’essentiel.

7. Les cas où il vaut mieux tout de même ne pas choisir C++/CLI

Bien sûr, C++/CLI n’est pas une solution universelle. Il existe des situations où il vaut mieux ne pas le choisir.

  • L’autre côté expose dès le départ une API C propre
    • Dans ce cas, P/Invoke est plus direct.
  • Le multiplateforme est nécessaire
    • C++/CLI suppose Windows.
  • La surface de la frontière est petite et les types sont simples
    • Le coût d’ajouter une DLL wrapper supplémentaire peut être plus important.
  • On regarde de très près l’AOT ou des contraintes de distribution
    • Mieux vaut d’abord examiner les exigences de l’ensemble de l’architecture.

Autrement dit, le critère de décision est : « étant donné la complexité de la DLL native, où est-il le plus naturel de faire la traduction ? » Simple → P/Invoke, complexe → C++/CLI. Cette répartition fonctionne bien dans la plupart des cas.

8. Résumé

Comme méthode pour utiliser une DLL native depuis C#, P/Invoke reste encore aujourd’hui la voie royale. Mais cela vaut lorsque l’autre côté se comporte de façon simple comme une API C.

Si le côté natif est conçu comme une bibliothèque C++, plutôt que de s’acharner à aligner IntPtr et attributs de marshaling côté C#, construire un wrapper C++/CLI léger permet souvent de garder la frontière beaucoup plus propre.

En particulier, quand entrent en jeu :

  • des API basées sur des classes
  • des hypothèses de propriété
  • std::wstring et std::vector
  • la conversion des exceptions
  • les callbacks
  • une migration progressive

C++/CLI est une option tout à fait réaliste.

Ce que l’on fait n’a rien de spectaculaire. Mais ce genre de décision sur « où arranger la frontière » a un impact réel sur la maintenabilité par la suite. Quand on veut faire vivre ensemble les actifs Windows existants et .NET, C++/CLI reste encore très utile.

9. Références

Articles récents partageant les mêmes étiquettes, pour approfondir des sujets proches.

Ces pages replacent le sujet dans un contexte plus large de services et de décisions.

Cet article est directement lié aux services suivants.

Questions fréquentes

Questions souvent posées lors d’une consultation sur le sujet de cet article.

Comment choisir entre P/Invoke et un wrapper C++/CLI ?
Si l'autre côté est un ensemble de fonctions C plates exposées via extern "C", P/Invoke est direct et le plus simple. Si l'autre côté est une bibliothèque centrée sur des classes C++, avec de la propriété, des chaînes, des tableaux, des exceptions et des callbacks en jeu, insérer un wrapper C++/CLI léger devient plus facile à maintenir. Le critère de décision est « étant donné la complexité de la DLL native, où est-il le plus naturel de faire la traduction ? » : simple → P/Invoke, complexe → C++/CLI. Cette répartition fonctionne bien dans la plupart des cas.
Qu'est-ce qu'un wrapper C++/CLI facilite concrètement ?
Côté C++/CLI, vous pouvez inclure les en-têtes natifs et manipuler std::wstring et std::vector directement comme des types C++, ce qui évite au côté C# d'avoir à recréer le monde C++. Vous pouvez n'exposer à C# que des API typiquement .NET comme string, byte[], List<T>, IDisposable et les exceptions, en cachant les contraintes liées à IntPtr, aux fonctions de libération et au marshaling. Les exceptions C++ et les codes d'erreur peuvent être convertis en exceptions .NET à la frontière, ce qui facilite aussi une migration progressive qui tire parti des actifs existants.
Dans quels cas rester uniquement sur P/Invoke devient-il pénible ?
Si la DLL native est conçue autour de classes C++, tout ce que P/Invoke peut appeler directement, ce sont les fonctions exportées de la DLL. Il faut donc de toute façon une couche qui ramène cela à des fonctions de style C, ce qui revient en pratique à commencer à concevoir une API compatible C. De plus, dès que s'ajoutent des éléments comme vouloir retourner std::wstring ou std::vector, recevoir la progression via des callbacks, ou des exceptions C++ qui sont levées, le côté C# accumule MarshalAs, des buffers manuels et la gestion de la durée de vie des délégués. Exprimer les hypothèses de propriété et de durée de vie sur la base d'IntPtr devient assez pénible à relire par la suite.
Y a-t-il des cas où il vaut mieux ne pas choisir C++/CLI ?
Oui. Si l'autre côté expose dès le départ une API C propre, P/Invoke est plus direct. Par ailleurs, C++/CLI suppose Windows, donc il n'est pas utilisable si le multiplateforme est nécessaire. Si la surface de la frontière est petite et les types simples, le coût d'ajouter une DLL wrapper supplémentaire peut être plus important que le bénéfice, et si vous êtes attentif de près à l'AOT ou à des contraintes de distribution, mieux vaut d'abord vérifier les exigences de l'ensemble de l'architecture.

Profil de l’auteur

Page de présentation de l’auteur de l’article.

Go Komura

Représentant de KomuraSoft LLC

Spécialisé dans le développement de logiciels Windows, le conseil technique et l’analyse de pannes, notamment pour les systèmes existants et les incidents difficiles à reproduire.

Retour au blog