Llamar DLL nativas desde C#: envoltorio C++/CLI frente a P/Invoke
· Actualizado el: · Go Komura · 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
- Primero, la conclusión (en una frase)
- Casos en los que P/Invoke basta
- El límite en el que P/Invoke se vuelve de repente incómodo
- Estructura con un envoltorio C++/CLI interpuesto
- Qué se facilita con C++/CLI
- Fragmentos de código
- Casos en los que aun así conviene no elegir C++/CLI
- Resumen
- 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
SafeHandleoStructLayoutsin 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::wstringdirectamente 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.
flowchart LR
accTitle: Estructura del envoltorio C++/CLI entre C# y la DLL nativa
accDescr: C# 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++.
Cs[Aplicación C#] -->|API pensada para .NET| Wrapper[DLL envoltorio C++/CLI]
Wrapper -->|maneja directamente encabezados y tipos nativos| Native[DLL 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:
stringbyte[]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::wstringostd::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
- Conjunto de código de muestra de este artículo (biblioteca C++ nativa, envoltorio C++/CLI, lado consumidor en C#) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- Consideraciones sobre el rendimiento en la interoperabilidad (C++) - Microsoft Learn
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Cómo invocar una DLL nativa de C# Native AOT desde C/C++
Publicar una biblioteca de C# como DLL nativa con Native AOT e invocar sus puntos UnmanagedCallersOnly desde C/C++: casos de uso, patrone...
Usar WMI/CIM desde C# y PowerShell ── Guía práctica de obtención de información de hardware, monitorización de procesos y consultas remotas
WMI/CIM es la solución estándar para leer el número de serie, monitorizar el disco y detectar procesos. Cmdlets CIM, migración desde Get-...
Cómo gestionar dispositivos USB en aplicaciones de Windows — cómo elegir entre COM virtual, HID, WinUSB y SDK propietario
Comparamos cuatro formas de controlar equipos y dispositivos USB desde una aplicación de Windows —puerto COM virtual, HID, WinUSB y SDK d...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Cómo versionar el esquema de la base de datos de una aplicación empresarial — Migraciones que evitan que «cada cliente tenga una base de datos distinta»
Guía práctica para versionar el esquema de bases de datos de aplicaciones empresariales dispersas entre clientes: PRAGMA user_version, mi...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Interoperabilidad de 32 / 64 bits
Compatibilidad de 32/64 bits, límites nativos y decisiones de diseño en Windows.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
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.