Llamar DLL nativas desde C#: envoltorio C++/CLI frente a P/Invoke

· Actualizado el: · · C++/CLI, C#, Desarrollo en Windows, Integración nativa

El requisito de usar activos o DLL existentes de Windows desde C# es bastante habitual. Si el otro lado es una interfaz C sencilla, como la de la API de Win32, P/Invoke basta.

Sin embargo, en el trabajo real suelen aparecer DLL con más particularidades. Hay clases C++, hay convenciones de propiedad, saltan excepciones, y std::wstring o std::vector aparecen con toda normalidad. Si aquí se insiste en usar solo P/Invoke, la frontera suele volverse cada vez más incómoda.

En este artículo explico qué se facilita en esos casos al interponer un envoltorio delgado en C++/CLI. No se trata de que P/Invoke sea malo: la idea central es que los casos en los que basta P/Invoke y los casos en los que C++/CLI resulta útil son distintos.

Los fragmentos de código que aparecen en este artículo se publican en GitHub como un conjunto de muestras compilables (biblioteca C++ nativa, puente de API C, envoltorio C++/CLI y el código consumidor en C# tanto de la versión P/Invoke como de la versión C++/CLI).

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

Público objetivo y requisitos previos

Este artículo está dirigido a desarrolladores que ya han llamado DLL nativas desde C#, que saben escribir declaraciones DllImport, pero que se quedan atascados en cuanto el otro lado se convierte en una biblioteca de clases C++. No hace falta experiencia previa con C++/CLI. Al contrario, si todavía no ha escrito nada con P/Invoke, es más rápido leer antes «Llamar a la API de Win32 desde C# de forma segura — Guía práctica de P/Invoke».

El entorno previsto es Windows con Visual Studio 2022, con una configuración en la que el envoltorio C++/CLI (.vcxproj) y el proyecto C# pueden convivir en la misma solución. El objetivo puede ser .NET Framework o .NET 8, por ejemplo, pero .NET tiene restricciones propias que se resumen en el capítulo 7.

Terminología que conviene tener clara de antemano

Término Significado
P/Invoke (Platform Invoke) Mecanismo que, mediante los atributos DllImport / LibraryImport de C#, declara y llama directamente a las funciones exportadas de un DLL nativo
Marshalización (marshaling) Conversión mutua, en la frontera, entre tipos de .NET (string, arreglos, etc.) y representaciones nativas (wchar_t*, punteros crudos, etc.)
ABI (Application Binary Interface) Conjunto de convenciones (convención de llamada, forma de pasar argumentos, disposición en memoria de las estructuras, decoración de nombres, etc.) para que binarios ya compilados encajen entre sí. Las funciones C tienen convenciones simples y estables, pero en las clases C++ la decoración de nombres y la disposición de la vtable dependen del compilador, así que no se puede confiar en ellas directamente desde C# (5.4)
SafeHandle Clase abstracta de .NET que envuelve un identificador (handle) nativo. Se usa en lugar de manejar un IntPtr a pelo, para evitar fugas por no liberar el handle y accidentes del tipo “se libera mientras está en uso” (6.2)
StructLayout Atributo para hacer coincidir la disposición en memoria de una estructura C# con el lado nativo. Se usa, por ejemplo, con LayoutKind.Sequential para ordenar los campos según el orden de declaración, o con CharSet para especificar el tratamiento de las cadenas (6.2)
marshal_as Ayudante de conversión que ofrece C++/CLI. Convierte tipos de .NET y tipos nativos entre sí, como en marshal_as<std::wstring>(managedString). Se usa incluyendo encabezados como msclr/marshal_cppstd.h (6.3)
Ensamblado mixto DLL que contiene tanto instrucciones de código máquina nativo como MSIL. Los envoltorios C++/CLI son de este tipo (capítulo 7)

Índice

  1. Primero, la conclusión (en una frase)
  2. Casos en los que P/Invoke basta
  3. El límite en el que P/Invoke se vuelve de repente incómodo
  4. Estructura con un envoltorio C++/CLI interpuesto
  5. Qué se facilita con C++/CLI
  6. Fragmentos de código
  7. Casos en los que aun así conviene no elegir C++/CLI
  8. Resumen
  9. Referencias

1. Primero, la conclusión (en una frase)

  • Si el otro lado es un conjunto de funciones C, P/Invoke es la opción directa
  • Si el otro lado es una biblioteca C++, interponer un envoltorio C++/CLI facilita el mantenimiento
  • Sobre todo cuando entran en juego clases, propiedad, cadenas, arreglos, excepciones y callbacks, es mejor no forzar al lado C#

En resumen, se trata de no llevar directamente a C# las particularidades del DLL nativo. Las particularidades nativas se reciben en el lado C++, y solo se organiza la cara que se muestra a .NET. Cuando esta división del trabajo funciona bien, tanto el código como la depuración se vuelven bastante más tranquilos.

2. Casos en los que P/Invoke basta

Si P/Invoke resuelve el problema, es la opción más sencilla. No hace falta forzar la introducción de C++/CLI.

P/Invoke es adecuado, por ejemplo, en casos como estos.

  • Se expone como una API de funciones planas con extern "C"
  • Los argumentos y valores de retorno se resuelven con enteros, punteros o estructuras simples
  • La convención de cadenas es clara y la responsabilidad del búfer es simple
  • La gestión de recursos es clara, del estilo Create / Destroy
  • En el lado C# se puede escribir SafeHandle o StructLayout sin complicaciones

Si el diseño está así de ordenado, basta con declarar y usar desde el lado C#, y como se parece a la sensación de llamar a la API de Windows, la implementación también resulta fácil de leer.

3. El límite en el que P/Invoke se vuelve de repente incómodo

El problema surge cuando el otro lado no es «una simple API C». A partir de aquí, el ambiente cambia de repente.

3.1. Cuando empieza a tratarse con clases C++

Cuando el DLL nativo está diseñado centrado en clases C++, en realidad se querría llamar directamente a los métodos de la clase, pero con P/Invoke lo único con lo que se puede tratar directamente son las funciones exportadas del DLL. Es decir, al final se necesita en algún lugar una capa que reduzca todo a funciones de estilo C.

En este punto, lo que se está haciendo es prácticamente «escribir un envoltorio». Si es así, en lugar de hacer proliferar IntPtr y funciones de liberación en el lado C#, resulta más natural situar el envoltorio en el lado C++.

3.2. Cuando la propiedad y la gestión del tiempo de vida no son evidentes

En C++ es habitual encontrarse con cuestiones como:

  • si es el llamador quien debe liberar
  • si el puntero devuelto es prestado
  • si es const& o hay transferencia de propiedad
  • si hay una caché interna con suposiciones sobre el tiempo de vida

Si esto se expresa en C# a base de IntPtr, aunque funcione al principio, resulta bastante penoso al volver a leerlo más adelante. En cuanto empieza el «¿quién y cuándo elimina este puntero?», la frontera se enturbia enseguida.

3.3. Cuando aparecen std::wstring, std::vector, callbacks y excepciones

A partir de aquí, P/Invoke entra en el territorio de «no es que no se pueda escribir, pero no resulta agradable».

  • Se quiere representar std::wstring directamente desde C#
  • Se quiere devolver std::vector<T>
  • Se quiere recibir el progreso del procesamiento nativo mediante un callback
  • Al fallar, salta una excepción de C++

Cuando aumentan estos elementos, se acumulan en el lado C# cosas como MarshalAs, búferes manuales, arreglos de longitud fija, gestión del tiempo de vida de los delegados e interpretación de códigos de error.

Por supuesto, con esfuerzo se puede escribir. Pero lo penoso es que el esfuerzo no se dedica a lo esencial. Lo que en realidad se quiere hacer es lógica de negocio o interfaz de usuario, no un combate cuerpo a cuerpo en la frontera.

3.4. Cuando no se quiere filtrar a C# las particularidades de C++

La API del lado del DLL nativo no siempre está orientada tal cual a C#.

Por ejemplo, aunque en el lado nativo el diseño sea de este tipo:

  • combinar varias llamadas a métodos en un único procesamiento
  • devolver los errores mediante el valor de retorno y argumentos de salida
  • suponer un orden de inicialización determinado
  • tener restricciones de seguridad frente a hilos

en muchos casos se quiere mostrar al lado C# una API más directa. Como capa de conversión para esto, C++/CLI resulta bastante conveniente.

4. Estructura con un envoltorio C++/CLI interpuesto

La estructura es sencilla.

Estructura del envoltorio C++/CLI entre C# y la DLL nativaC# solo ve una API pensada para .NET expuesta por el envoltorio C++/CLI, que a su vez maneja directamente los encabezados y tipos nativos de la DLL C++.API pensada para .NETmaneja directamente encabezados y tipos nativosAplicación C#DLL envoltorio C++/CLIDLL C++ nativa

Se hace que desde C# solo se vea una API propia de .NET, y se confina en el lado C++/CLI:

  • la conversión de cadenas
  • la conversión de arreglos y vectores
  • la conversión de excepciones
  • la organización de la propiedad
  • la interpretación de los códigos de error
  • si hace falta, la absorción de la frontera de hilos y de los callbacks

Lo importante es no dejar que el propio proyecto C++/CLI crezca demasiado. Su papel es, al fin y al cabo, «traducir» y «dar forma». Si se empieza a meter también lógica de negocio, esa capa termina convirtiéndose en la protagonista.

5. Qué se facilita con C++/CLI

5.1. Los tipos C++ se pueden manejar tal cual son

Esto es bastante importante. En el lado C++/CLI se pueden incluir los encabezados nativos y usar los tipos C++ tal cual.

Es decir, ya no hace falta forzar en el lado C# la «reproducción del mundo de C++». Tanto std::wstring como std::vector pueden recibirse primero como tipos C++, y luego pasarse al lado .NET en la forma que se necesite.

5.2. La API se puede dar forma pensada para .NET

Al lado C# se le puede ofrecer la API en una forma familiar, como:

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

Esta diferencia parece discreta, pero cambia mucho la carga para quien la usa. Sobre todo en el desarrollo en equipo, resulta muy útil que incluso los miembros que no conocen las particularidades nativas puedan trabajar con ella con facilidad.

5.3. Es fácil organizar la responsabilidad de excepciones y errores

Cuando en el lado nativo se mezclan excepciones y códigos de error, recibirlos tal cual en el lado C# resulta incómodo de manejar. En el lado C++/CLI se puede unificar todo una vez y, por ejemplo:

  • convertir las excepciones en excepciones de .NET
  • convertir los códigos de error en excepciones o tipos de resultado con sentido
  • añadir el contexto necesario para el registro (logging)

Traducir una vez, en la frontera, a un «fallo con sentido» deja bastante más ordenado al lado que hace la llamada.

5.4. Las variaciones del ABI se pueden ocultar al lado C#

Las clases y métodos de C++ no tienen un ABI tan simple como el de las funciones C. En cuanto C# empieza a conocer directamente esas particularidades, salen a la superficie los detalles de las funciones exportadas y de la marshalización.

Al interponer un envoltorio C++/CLI, se puede confinar las particularidades de C++ en el lado C++ y mostrar a C# solo una cara estable. Esta separación también resulta útil cuando se actualiza la biblioteca.

5.5. Facilita una migración gradual

Rehacer de golpe todo un DLL nativo existente es costoso. Con un envoltorio C++/CLI resulta fácil hacer una migración gradual: primero se envuelve de forma delgada solo la API necesaria, y se empieza a usar desde las pantallas o flujos de trabajo nuevos del lado C#.

Encaja bastante bien en escenarios en los que se quiere aprovechar los activos existentes de Windows mientras se acerca el entorno circundante a .NET.

6. Fragmentos de código

Aquí no se incluye «una muestra completa que funcione tal cual», sino solo fragmentos suficientes para hacerse una idea de la frontera.

6.1. Idea de la API del lado del DLL nativo

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

Esta API es normal como C++ nativo. Pero tratarla tal cual desde C# tiene su complicación.

6.2. Así queda si se intenta con P/Invoke

Primero, para llamar directamente desde C# hace falta reducirlo en algún lugar a funciones de estilo C. Por ejemplo, se termina preparando aparte funciones puente como estas.

// Idea del puente reducido a una 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);
}

El lado C# también queda con este aspecto.

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 con esto bastara, perfecto, pero en la práctica se van sumando más cuestiones, como

  • cómo devolver datos de longitud variable
  • quién libera el búfer de cadenas
  • dónde colocar el detalle del error
  • cómo proteger el tiempo de vida del callback

Es decir, en muchos casos, aunque se creía haber elegido P/Invoke, en realidad se está empezando a diseñar una API compatible con C.

Estas cuestiones, al desplazarlas hacia C++/CLI, se sustituyen como sigue. No es que desaparezcan como por arte de magia; lo más exacto es decir que se trasladan a una forma que se puede escribir con naturalidad en el lado C++.

Cuestión que surge con P/Invoke Tratamiento típico en el lado P/Invoke Cómo queda en el lado C++/CLI
Cómo devolver datos de longitud variable Se preparan en dos pasos una «función que consulta el tamaño necesario» y una «función que rellena el búfer», y el búfer se reserva en el lado C# Se recibe tal cual el std::vector que devuelve la parte nativa y se reempaqueta en un List<int> o un arreglo antes de devolverlo (6.3)
Quién libera el búfer de cadenas Se añade a la API C una función de liberación, y se respeta la convención de llamarla siempre desde el lado C# El tiempo de vida de std::wstring se completa en el lado nativo, y hacia C# solo se crea y devuelve un nuevo String^ (6.3)
Dónde colocar el detalle del error Además del código de error en el valor de retorno, se prepara una función para extraer el detalle o una estructura con argumento de salida Se recibe la excepción nativa con try / catch, se convierte en una excepción de .NET con sentido y se vuelve a lanzar (6.3)
Cómo proteger el tiempo de vida del callback Se mantiene la referencia al delegado (por ejemplo, en un campo) para que el GC no lo recolecte El registro y la anulación del callback se confinan en el lado C++, y a C# solo se le muestran eventos o delegados
Cómo representar la propiedad del handle Se deriva de SafeHandle y se llama a la función de liberación desde ReleaseHandle El destructor o el finalizador del envoltorio hace delete sobre el objeto nativo (6.3)

6.3. Así se puede escribir con un envoltorio C++/CLI

En el lado C++/CLI se reciben las particularidades nativas y se ordena la API que se muestra 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;
};

Aquí lo específico de C++/CLI son ~AnalyzerWrapper() y !AnalyzerWrapper(). Ambos parecen destructores de C++, pero su papel corresponde al patrón Dispose de .NET.

Forma de escribirlo en C++/CLI Lo que genera el compilador Comportamiento visto desde C#
~AnalyzerWrapper() (destructor) Dispose(), que implementa IDisposable Se ejecuta al salir de un bloque using, o al llamar a Dispose()
!AnalyzerWrapper() (finalizador) Finalize(), que sobrescribe Object::Finalize Se ejecuta cuando el GC recolecta el objeto. No está determinado cuándo se ejecutará

La práctica habitual es escribir la liberación de recursos nativos en el finalizador y llamarlo desde el destructor. Eso es justo lo que hace la siguiente implementación, en la que ~AnalyzerWrapper() se limita a llamar a this->!AnalyzerWrapper(); escribiéndolo así, aunque el lado C# olvide llamar a Dispose(), al final lo recoge el GC. Cuando se llama al destructor, GC::SuppressFinalize suprime la finalización, de modo que no se produce una doble liberación.

Cabe señalar que Dispose(), Finalize() y Dispose(bool) los genera el compilador, así que no se escriben a mano en el lado C++/CLI. A la inversa, tampoco se puede llamar directamente a Dispose() desde código C++/CLI: el destructor se invoca con el operador delete. Esta correspondencia está resumida en la sección «Destructors and finalizers» de How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.

// 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)
{
    // Si se llama tras destruir, aquí se detiene antes de entrar en la nativa.
    // El destructor (= Dispose) deja _native en nullptr, así que sin esta
    // comprobación se entraría en la nativa por un puntero nulo, y en vez
    // de una excepción de .NET, el proceso entero cae por acceso indebido.
    // Visto desde C#, lo esperado es que tocar tras Dispose lance
    // ObjectDisposedException; esta comprobación hace falta en todo método que usa _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()));
    }
}

El lado C# queda bastante directo.

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

Lo que se ve desde C# es string, List<int> e IDisposable. No se ven las particularidades de IntPtr, las funciones de liberación ni los búferes de cadenas nativos. Esto es lo importante.

7. Casos en los que aun así conviene no elegir C++/CLI

Por supuesto, C++/CLI no es la solución universal. Hay escenarios en los que es mejor no elegirlo.

  • El otro lado ya expone desde el principio una API C limpia
    • En este caso, P/Invoke es la opción más directa.
  • Se necesita ser multiplataforma
    • C++/CLI presupone Windows.
  • La superficie de la frontera es pequeña y los tipos son simples
    • El costo de añadir un DLL envoltorio puede ser mayor que el beneficio.
  • Se están mirando con bastante rigor las restricciones de AOT o de distribución
    • Conviene revisar antes los requisitos de toda la configuración.

El último punto, «restricciones de AOT o de distribución», es el único que queda abstracto, así que a continuación se enumeran las restricciones que realmente entran en juego en la práctica. Este es un punto fácil de pisar mal si se mantiene la sensibilidad de la época de .NET Framework.

Restricción Contenido Impacto en la práctica
Sistema operativo El C++/CLI que apunta a .NET (la familia .NET Core) es exclusivo de Windows Si hay planes de ejecutarlo en un contenedor Linux o en macOS, ya en este punto no se puede elegir
Native AOT C++/CLI figura explícitamente en la lista de lo no compatible con Native AOT. Junto con ello, tampoco se puede usar carga dinámica como Assembly.LoadFile, System.Reflection.Emit ni el COM integrado de Windows No es compatible con la política de generar un único binario nativo mediante PublishAot
Formato de salida Al apuntar a .NET, no puede generar un exe, solo un DLL. Tampoco puede apuntar a .NET Standard El punto de entrada se coloca en un exe del lado C#, y C++/CLI se configura como un DLL al que se hace referencia
Formato de proyecto Se usa .vcxproj, no un csproj de estilo SDK. Tampoco se puede hacer multi-targeting a varias versiones de .NET desde un mismo proyecto Si hacen falta tanto una versión para .NET Framework como una para .NET, se separan los archivos de proyecto
Dependencia del runtime Al especificar /clr también se activa /MD, por lo que se necesita el DLL del runtime de MSVC. Al apuntar a .NET, además hay que colocar ijwhost.dll en la salida Si se presupone una distribución XCOPY o una publicación de archivo único, conviene comprobarlo antes
Arquitectura de CPU Como el ensamblado mixto contiene instrucciones de código máquina nativo, no se puede cubrir todas las arquitecturas con un único binario, como sí ocurre con AnyCPU en C# Se compila y distribuye por separado para cada destino, como x86 / x64
Forma de carga Desde .NET 7 en adelante, siempre se carga en el AssemblyLoadContext predeterminado. En .NET 6 o anterior, si la primera llamada viene del lado nativo, a veces se carga en un AssemblyLoadContext distinto En configuraciones que separan el contexto de carga por cada plugin, conviene comprobar el comportamiento

Cabe señalar que un proyecto C++/CLI solo puede apuntar a .NET (la familia .NET Core) desde Visual Studio 2019 en adelante. Si solo se dispone de un entorno más antiguo, habrá que plantearlo primero pensando en .NET Framework.

En definitiva, el criterio es «dado lo complejo que es el DLL nativo, dónde resulta más natural traducirlo». Si es simple, P/Invoke; si es complejo, C++/CLI. Con esta separación suele funcionar bien.

8. Resumen

Como método para usar DLL nativas desde C#, P/Invoke sigue siendo hoy el camino principal. Sin embargo, eso es válido cuando el otro lado es una API C directa.

Si el lado nativo está diseñado como una biblioteca C++, en muchos casos crear un envoltorio delgado en C++/CLI mantiene la frontera más limpia que esforzarse alineando IntPtr y atributos de marshalización en el lado C#.

Sobre todo cuando entran en juego:

  • una API basada en clases
  • suposiciones de propiedad
  • std::wstring o std::vector
  • la conversión de excepciones
  • callbacks
  • una migración gradual

C++/CLI es una opción bastante realista.

Lo que hay que hacer no es nada vistoso. Pero esto de «dónde se ordena la frontera» repercute con claridad en el mantenimiento posterior. Cuando se quiere aprovechar juntos los activos existentes de Windows y .NET, C++/CLI sigue siendo muy útil.

9. Referencias

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Cómo se decide entre P/Invoke y un envoltorio C++/CLI?
Si el otro lado es un conjunto de funciones C planas expuestas con extern "C", P/Invoke es la opción directa y más sencilla. Si el otro lado es una biblioteca C++ centrada en clases, donde entran en juego la propiedad, las cadenas, los arreglos, las excepciones y los callbacks, resulta más fácil de mantener interponer un envoltorio delgado en C++/CLI. El criterio es "dado lo complejo que es el DLL nativo, dónde resulta más natural traducirlo": si es simple, P/Invoke; si es complejo, C++/CLI. Con esta separación suele funcionar bien.
¿Qué se facilita al interponer un envoltorio C++/CLI?
En el lado C++/CLI se pueden incluir los encabezados nativos y manejar std::wstring o std::vector como tipos C++ sin más, por lo que ya no hace falta reproducir el mundo de C++ en el lado C#. A C# solo se le muestra una API propia de .NET: string, byte[], List<T>, IDisposable y excepciones, mientras se ocultan los detalles de IntPtr, las funciones de liberación y la marshalización. Las excepciones de C++ o los códigos de error pueden convertirse en la frontera a excepciones de .NET, y también facilita una migración gradual que aprovecha los activos existentes.
¿Cuándo se vuelve difícil avanzar solo con P/Invoke?
Cuando el DLL nativo está diseñado centrado en clases C++, lo único que se puede llamar directamente con P/Invoke son las funciones exportadas del DLL, así que al final hace falta una capa que las reduzca a funciones de estilo C, y en la práctica se termina diseñando una API compatible con C. Además, si se suman elementos como querer devolver std::wstring o std::vector, recibir el progreso mediante un callback o que salten excepciones de C++, se acumulan en el lado C# cosas como MarshalAs, búferes manuales y la gestión del tiempo de vida de los delegados. Expresar las suposiciones de propiedad y tiempo de vida a base de IntPtr resulta bastante penoso cuando se vuelve a leer el código más adelante.
¿Hay casos en los que sea mejor no elegir C++/CLI?
Sí los hay. Si el otro lado ya expone desde el principio una API C limpia, P/Invoke es la opción más directa. Además, C++/CLI presupone Windows, así que no puede usarse si se necesita ser multiplataforma. Cuando la superficie de la frontera es pequeña y los tipos son simples, el costo de añadir un DLL envoltorio puede ser mayor que el beneficio, y si se están mirando con rigor las restricciones de AOT o de distribución, conviene revisar antes los requisitos de toda la configuración.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog