Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke

· Обновлено: · · C++/CLI, C#, Разработка Windows, Нативное взаимодействие

История изменений (1 обновлений, последнее 30 Aug 2026)

Журнал изменений этой статьи. Там, где версия до правки была заархивирована, она остаётся доступной для чтения по постоянной ссылке с DOI.

Русский текст переписан как полноценный технический перевод, а не калька с японского. Утверждения статьи не менялись.
Первая публикация
Цитирование статьи(DOI: 10.5281/zenodo.21619635)

Статья заархивирована на Zenodo. Ниже приведены DOI, который всегда ведёт к последней версии, и DOI, закреплённый за версией, которую вы читаете.

Го Комура (2026). Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke. KomuraSoft LLC. https://doi.org/10.5281/zenodo.21619635 https://comcomponent.com/ru/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/

DOI (последняя версия)
10.5281/zenodo.21619635
DOI (эта версия)
10.5281/zenodo.21619636

Требование «вызывать из C# уже существующий Windows-код или готовые DLL» встречается довольно часто. Если на той стороне — прямой C-интерфейс вроде Win32 API, P/Invoke вполне достаточно.

Но на практике попадаются DLL куда неудобнее. В них есть классы C++, свои соглашения о владении, летают исключения, а std::wstring и std::vector появляются как нечто само собой разумеющееся. Если это пытаться закрыть одним P/Invoke, граница обычно постепенно становится всё тяжелее.

В этой статье — о том, что становится проще, если в таких случаях вставить одну тонкую обёртку на C++/CLI. Речь не о том, что P/Invoke плох: смысл в том, что случаи, где P/Invoke достаточно, и случаи, где работает C++/CLI, — разные.

Фрагменты кода из этой статьи опубликованы на GitHub как полный собираемый набор примеров (нативная библиотека на C++, мост C API, обёртка на C++/CLI, а также вызывающий код на C# для вариантов P/Invoke и C++/CLI).

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

Кому статья и что предполагается

Статья рассчитана на разработчика, который уже вызывал нативную DLL из C# и умеет написать объявление DllImport, но останавливается, когда на той стороне оказывается библиотека классов C++. Сам C++/CLI можно не знать. Если P/Invoke вы ещё не писали, быстрее сначала прочитать «Безопасный вызов Win32 API из C# — практическое руководство по P/Invoke».

Предполагаемая среда — Windows + Visual Studio 2022, в одном решении лежат обёртка C++/CLI (.vcxproj) и проект C#. Цель может быть и .NET Framework, и .NET 8, но на стороне .NET есть свои ограничения — они собраны в главе 7.

Термины, которые стоит сразу пояснить

Термин Смысл
P/Invoke (Platform Invoke) Механизм, которым атрибуты DllImport / LibraryImport в C# объявляют экспортируемые функции нативной DLL и вызывают их напрямую
Маршалинг Взаимное преобразование типов .NET (string, массивы и т. п.) и нативного представления (wchar_t*, сырые указатели и т. п.) на границе
ABI (Application Binary Interface) Договорённости, по которым собранные двоичные файлы стыкуются: соглашение о вызове, передача аргументов, раскладка структур в памяти, декорирование имён. У C-функций договорённости простые и стабильные; у классов C++ декорирование имён и раскладка vtable зависят от компилятора, и на них нельзя опираться напрямую из C# (5.4)
SafeHandle Абстрактный класс .NET, который оборачивает нативный дескриптор. Им пользуются вместо сырого IntPtr, чтобы не терять освобождение дескриптора и не освобождать его, пока им ещё пользуются (6.2)
StructLayout Атрибут, которым раскладку структуры C# подгоняют под нативную сторону. Типичные применения: LayoutKind.Sequential — поля в порядке объявления, CharSet — как обращаться со строками (6.2)
marshal_as Вспомогательное преобразование, которое даёт C++/CLI. marshal_as<std::wstring>(managedString) взаимно преобразует типы .NET и нативные типы. Подключают заголовки вроде msclr/marshal_cppstd.h (6.3)
Смешанная сборка DLL, в которой есть и нативный машинный код, и MSIL. Обёртка C++/CLI получается именно такой (глава 7)

Содержание

  1. Сначала вывод (в двух словах)
  2. Случаи, когда P/Invoke достаточно
  3. Граница, за которой P/Invoke внезапно становится тяжёлым
  4. Архитектура с обёрткой на C++/CLI
  5. Что становится проще благодаря C++/CLI
  6. Фрагменты кода
  7. Случаи, когда C++/CLI всё же лучше не выбирать
  8. Итог
  9. Справочные материалы

Карта знаний этой статьи

Когда из C# вызывают native DLL, плоский набор функций extern C естественно закрывать через P/Invoke; если же это библиотека на классах C++ и в дело входят владение, строки, исключения и обратные вызовы, тонкая обёртка на C++/CLI проще в сопровождении. P/Invoke обычно выражает native-типы через SafeHandle и StructLayout, но в итоге приходится писать свой мост в стиле C. C++/CLI оставляет на стороне C++ преобразование типов через marshal_as, шаблон Dispose/Finalize через деструктор и финализатор и перевод исключений в .NET, а C# показывает только стабильный API. С другой стороны C++/CLI работает только на Windows и не совместим с Native AOT, требует компиляции с /clr и зависимости от ijwhost.dll, поэтому при кроссплатформенности или жёстких ограничениях распространения его выбрать нельзя.

Карта знаний: когда C++/CLI-обёртка, когда P/InvokeСхема, как выбирать P/Invoke и C++/CLI-обёртку в зависимости от сложности native DLL, и как при этом меняются маршалинг, владение, исключения, обратные вызовы и ограничения распространениятребуетрекомендуется длярекомендуется дляне рекомендуетсяне рекомендуетсяиспользуетреализуетиспользуетиспользуетреализуеттребуетреализуеттребуеттребуетнесовместимо стребуетнастраиваетсяреализуеттребуетпреемникнесовместимо сC++/CLIP/Invokeмост C APIплоский C API (extern &quot;C&quot;)нативная библиотека C++ на классахмаршалингmarshal_asSafeHandleStructLayoutпаттерн Dispose/Finalize (C++/CLI)управление владением и временем жизнипреобразование исключений в .NETуправление lifetime callback-делегатаNative AOTijwhost.dllпараметр компиляции /clrсмешанная сборка (mixed assembly)требование кроссплатформенности

На схеме сплошная линия обозначает отношение, которое выполняется всегда, а пунктирная — условное отношение (условия указаны в пояснении к каждому отношению на странице сведений). Полный список отношений (всего 21, с доказательствами и степенью уверенности) и определения основных понятий собраны на странице сведений карты знаний (на японском). Данные: JSON-LD / Turtle

1. Сначала вывод (в двух словах)

  • Если на той стороне — набор C-функций, естественнее P/Invoke
  • Если на той стороне — библиотека на C++, сопровождать код проще, вставив одну обёртку на C++/CLI
  • Особенно если задействованы классы, владение, строки, массивы, исключения и обратные вызовы, не стоит взваливать это на сторону C#

Иными словами, не тащить особенности нативной DLL напрямую в C#. Особенности нативной стороны принимает C++, а .NET показывают только приведённую в порядок поверхность. Когда такое разделение работает, и код, и отладка становятся заметно спокойнее.

Выбор по форме DLL на той сторонеЕсли на той стороне набор C-функций, естественнее P/Invoke; если библиотека на C++, сопровождать проще, вставив одну обёртку на C++/CLI — вывод статьи показан как ветвление.набор C-функцийбиблиотека на C++Какая DLL на той стороне?P/Invoke естественнееВставить обёртку C++/CLIНативные особенности принимает сторона C++

Рис. 1: Вывод по выбору. Набор C-функций — P/Invoke, библиотека на C++ — обёртка C++/CLI.

2. Случаи, когда P/Invoke достаточно

Если задачу закрывает P/Invoke, это самый простой вариант. Тащить сюда C++/CLI незачем.

P/Invoke подходит, например, в таких случаях.

  • API — плоский набор функций, опубликованных через extern "C"
  • аргументы и возвращаемые значения обходятся целыми, указателями, простыми структурами и тому подобным
  • соглашения о строках понятны, ответственность за буферы проста
  • управление ресурсами устроено понятно, по схеме Create / Destroy
  • на стороне C# естественно пишутся SafeHandle и StructLayout

Если всё настолько аккуратно, достаточно объявить и использовать это на стороне C# — ощущение близко к вызову Windows API, поэтому реализация тоже остаётся читаемой.

Условия, при которых достаточно P/InvokeЕсли есть плоский C API, простые аргументы и возвращаемые значения и ясные соглашения о строках и ресурсах, достаточно объявить и использовать это на стороне C#.Плоский функциональный API через extern CДостаточно P/InvokeАргументы и возвращаемые значения простыСоглашения о строках и ресурсах ясныДостаточно объявить и использовать на стороне C#

Рис. 2: Если API на той стороне настолько аккуратен, тащить C++/CLI незачем.

3. Граница, за которой P/Invoke внезапно становится тяжёлым

Проблема начинается, когда на той стороне уже не «просто C API». Именно здесь ситуация резко меняется.

3.1. Когда приходится иметь дело с классами C++

Если нативная DLL спроектирована вокруг классов C++, на самом деле хочется вызывать методы классов напрямую, но через P/Invoke напрямую можно обращаться только к экспортируемым функциям DLL. То есть рано или поздно всё равно понадобится слой, сводящий всё к функциям в стиле C.

На этом этапе то, что вы делаете, — почти «писать обёртку». А раз так, естественнее перенести обёртку на сторону C++, чем плодить на стороне C# горы IntPtr и функций освобождения.

К чему приводит выбор P/Invoke против классов C++Методов классов C++ хочется вызывать напрямую, но P/Invoke видит только экспортируемые функции DLL, поэтому нужен слой сведения к C, и по сути вы уже пишете обёртку.Хочется вызвать методы класса C++Вызвать можно только экспортируемые функцииНужен слой сведения к функциям в стиле CПо сути это уже обёрткаТогда естественнее сдвинуть её на сторону C++

Рис. 3: Даже если идти только P/Invoke, против классов C++ обёртку всё равно придётся писать где-то.

3.2. Когда владение и время жизни плохо видны

В C++ совершенно обычны вопросы вроде:

  • освобождает ли объект вызывающая сторона;
  • является ли возвращённый указатель заимствованным;
  • это const& или передача владения;
  • есть ли внутреннее кеширование с ограничениями по времени жизни.

Если выражать всё это через IntPtr на стороне C#, поначалу код может работать, но перечитывать его позже довольно тяжело. Как только начинается «а кто и когда должен удалить этот указатель», граница быстро размывается.

Как размывается граница, когда предпосылки владения выражают через IntPtrКто освобождает, заимствование это или передача владения, есть ли ограничения по времени жизни — если эти обстоятельства C++ выражать через IntPtr на стороне C#, позже код тяжело читать и граница размывается.Кто освобождает?Выражают через IntPtr на стороне C#Заимствование или передача владения?Есть ли ограничения по времени жизни?Сначала работает, потом не читаетсяГраница сразу размывается

Рис. 4: Если предпосылки владения и времени жизни таскать через IntPtr, граница размывается из-за вопроса «кто и когда это удаляет».

3.3. Когда появляются std::wstring, std::vector, обратные вызовы и исключения

С этого момента P/Invoke входит в область «написать можно, но приятного мало».

  • хочется представить std::wstring напрямую средствами C#
  • хочется вернуть std::vector<T>
  • хочется получать прогресс нативной обработки через обратный вызов
  • при сбое выбрасывается исключение C++

По мере накопления таких элементов на стороне C# растёт количество MarshalAs, ручных буферов, массивов фиксированной длины, управления временем жизни делегатов и интерпретации кодов ошибок.

Конечно, если постараться, всё это можно написать. Но тяжело то, что место приложения усилий — не суть задачи. На самом деле хочется заниматься прикладной логикой или интерфейсом, а не борьбой на границе.

Рост элементов C++ и накопление нагрузки на стороне C#Чем больше элементов вроде возврата wstring или vector, прогресса через обратный вызов и исключений C++ при сбое, тем больше на стороне C# копятся MarshalAs, ручные буферы и управление временем жизни делегатов.Хочется вернуть wstring или vectorНа стороне C# описание копитсяХочется прогресс через обратный вызовПри сбое летит исключение C++MarshalAs, ручные буферыВремя жизни делегатов, разбор ошибок

Рис. 5: Каждый новый элемент в духе C++ добавляет нагрузку на граничный код на стороне C#.

3.4. Когда не хочется, чтобы особенности C++ просачивались в C#

API нативной DLL не обязательно изначально ориентирован на C#.

Например, даже если на нативной стороне заложено:

  • объединение нескольких вызовов методов в одну логическую операцию
  • возврат ошибок через возвращаемое значение и out-параметры
  • ограничения на порядок инициализации
  • ограничения потокобезопасности

на стороне C# часто хотят показать более прямой API. Как слой, который это преобразует, C++/CLI оказывается весьма удобным.

Слой, который преобразует особенности native и показывает их C#Особенности проектирования нативного API вроде порядка инициализации и ограничений потокобезопасности принимает преобразовательный слой C++/CLI и показывает стороне C# более прямой API.Особенности проектирования нативного APIПорядок инициализации, ограничения потоков и т. п.Преобразовательный слой C++/CLIC# показывают прямой API

Рис. 6: Особенности проектирования нативной стороны не направляют в C# как есть, а вставляют C++/CLI как слой преобразования.

4. Архитектура с обёрткой на C++/CLI

Архитектура получается простой.

API для .NETнапрямую работает с нативными заголовками и типамиприложение на C#обёрточная DLL на C++/CLIнативная DLL на C++

Рис. 7: Между приложением на C# и нативной DLL на C++ вставляют одну обёрточную DLL на C++/CLI.

Со стороны C# должен быть виден только API в духе .NET, а на стороне C++/CLI нужно спрятать:

  • преобразование строк
  • преобразование массивов и векторов
  • преобразование исключений
  • разбор владения
  • интерпретацию кодов ошибок
  • при необходимости — поглощение границ потоков и обратных вызовов

Важно не давать проекту C++/CLI разрастаться сверх меры. Его роль — исключительно «перевод» и «приведение к нужной форме». Если в него начинает проникать прикладная логика, этот слой сам становится главным действующим лицом.

Работа, которую оставляют в обёртке C++/CLIПеревод и приведение к нужной форме — преобразование строк и массивов, исключений и кодов ошибок, разбор владения — оставляют на стороне C++/CLI и прикладную логику туда не кладут.Роль обёртки C++/CLIПреобразование строк и массивовПреобразование исключений и кодов ошибокРазбор владенияПрикладную логику не класть

Рис. 8: Роль обёртки ограничивают «переводом» и «приведением к нужной форме» и не раздувают — это важно.

5. Что становится проще благодаря C++/CLI

5.1. Типы C++ можно обрабатывать как типы C++

Это довольно важный момент. На стороне C++/CLI можно подключить нативные заголовки и работать напрямую с типами C++.

То есть на стороне C# больше не нужно насильно «воссоздавать мир C++». И std::wstring, и std::vector можно сначала принять как типы C++, а затем передать в .NET в нужной форме.

Поток: принять типы C++ и передать в .NETНативные заголовки подключают, wstring и vector принимают как типы C++, преобразуют в нужную форму и передают в .NET — на стороне C# мир C++ воссоздавать не нужно.Нативные wstring и vectorНа стороне C++/CLI принимают как типы C++Преобразуют в нужную формуПередают в .NETМир C++ на стороне C# не воссоздают

Рис. 9: Типы C++ сначала принимают как типы C++, преобразуют и только потом передают в .NET.

5.2. API можно привести к виду, привычному для .NET

Стороне C# можно показать API в привычной форме:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • исключения

Эта разница выглядит скромно, но сильно меняет нагрузку на использующую сторону. Особенно в командной разработке это ценно тем, что даже участники, не знакомые с тонкостями нативного кода, могут спокойно с этим работать.

5.3. Ответственность за исключения и ошибки легче привести в порядок

Если на нативной стороне вперемешку встречаются исключения и коды ошибок, принимать их как есть на стороне C# неудобно. На стороне C++/CLI можно один раз всё собрать:

  • преобразовать исключения в исключения .NET
  • преобразовать коды ошибок в осмысленные исключения или типы результата
  • дополнить контекстом, необходимым для журналирования

Если один раз на границе перевести сбой в «осмысленный сбой», вызывающая сторона становится намного чище.

Перевод исключений и кодов ошибок на границеСмешанные на нативной стороне исключения и коды ошибок один раз собирают на стороне C++/CLI, исключения переводят в исключения .NET, коды ошибок — в осмысленные исключения или типы результата и дополняют контекстом для журнала.Нативные исключения и коды ошибокНа стороне C++/CLI один раз собираютИсключения переводят в исключения .NETКоды ошибок переводят в осмысленную формуДополняют контекстом для журнала

Рис. 10: Если на границе один раз перевести сбой в «осмысленный сбой», вызывающая сторона на C# становится чище.

5.4. Нестабильность ABI можно скрыть от стороны C#

Классы и методы C++ не имеют такого простого ABI, как C-функции. Как только C# напрямую начинает знать эти особенности, наружу вылезают заботы об экспортируемых функциях и маршалинге.

Если вставить обёртку на C++/CLI, можно оставить особенности C++ на стороне C++ и показывать C# только стабильную поверхность. Такое разделение оказывается полезным и при обновлении библиотеки.

Как обёртка перекрывает нестабильность ABIУ классов и методов C++ нет такого простого ABI, как у C-функций, поэтому эти особенности оставляют на стороне C++, C# показывают только стабильную поверхность, и разделение работает и при обновлении библиотеки.ABI классов C++ непростоеОсобенности C++ оставляют на стороне C++C# показывают только стабильную поверхностьРазделение работает и при обновлении библиотеки

Рис. 11: Особенности маршалинга и экспортируемых функций наружу не выпускают, C# показывают только стабильную поверхность.

5.5. Легче выполнять поэтапную миграцию

Переписывать всю существующую нативную DLL целиком и сразу — тяжело. С обёрткой на C++/CLI проще выполнить поэтапную миграцию: сначала тонко обернуть только нужные API и начать использовать их из новых экранов или рабочих процессов на стороне C#.

Это хорошо подходит для сценариев, когда нужно сохранить уже существующий Windows-код, постепенно перенося окружение на .NET.

6. Фрагменты кода

Здесь приводятся не «полностью рабочие готовые примеры», а лишь фрагменты, достаточные, чтобы представить себе границу.

6.1. Как выглядит API на стороне нативной DLL

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

Как нативный C++ этот API выглядит вполне обычно. Но работать с ним напрямую из C# довольно трудоёмко.

6.2. Что получится, если попытаться сделать это через P/Invoke

Прежде всего, чтобы вызывать это напрямую из C#, где-то нужно свести всё к функциям в стиле C. Например, придётся отдельно подготовить вот такие функции-мосты.

// Набросок моста, сведённого к C API
extern "C"
{
    __declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
    __declspec(dllexport) void  Analyzer_Destroy(void* handle);

    __declspec(dllexport) int Analyzer_Analyze(
        void* handle,
        const wchar_t* imagePath,
        const AnalyzeOptionsNative* options,
        AnalyzeResultNative* result);
}

На стороне C# всё это будет выглядеть примерно так.

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

Если бы на этом всё заканчивалось, было бы хорошо, но на практике добавляются ещё такие вопросы:

  • как возвращать данные переменной длины;
  • кто освобождает строковые буферы;
  • куда помещать подробности об ошибке;
  • как защитить время жизни обратного вызова.

То есть часто оказывается, что, вроде бы выбрав P/Invoke, вы фактически начали проектировать C-совместимый API.

Как вариант P/Invoke по сути становится проектированием C-совместимого APIДаже если собирались вызывать через P/Invoke напрямую, приходится отдельно готовить C-мост, писать на стороне C# SafeHandle и StructLayout, добавляются вопросы переменной длины, освобождения и обратных вызовов — и по сути начинается проектирование C-совместимого API.Собирались вызывать через P/Invoke напрямуюОтдельно готовят C-мостПишут SafeHandle и StructLayoutВопросы переменной длины, освобождения, обратных вызововПо сути начинается проектирование C-совместимого API

Рис. 12: Казалось, «просто выбрали P/Invoke», а по факту уже проектируете C-совместимый API.

Эти вопросы, если сдвинуть их к C++/CLI, заменяются так. Они не исчезают как по волшебству: точнее сказать, что они переходят в форму, которую на стороне C++ пишут естественно.

Вопрос, который возникает при P/Invoke Типичный ответ на стороне P/Invoke Как это выглядит на стороне C++/CLI
Как возвращать данные переменной длины Готовят две ступени — «функция, которая спрашивает нужный размер» и «функция, которая заполняет буфер», — и буфер выделяют на стороне C# std::vector, который вернула нативная сторона, принимают как есть и перекладывают в List<int> или массив (6.3)
Кто освобождает строковые буферы В C API добавляют функцию освобождения и на стороне C# всегда её вызывают Время жизни std::wstring замыкается на нативной стороне, в C# уходит только новый String^ (6.3)
Куда помещать подробности об ошибке К коду ошибки в возвращаемом значении добавляют функцию извлечения подробностей или структуру в out-параметре try / catch принимают нативное исключение и перебрасывают его как осмысленное исключение .NET (6.3)
Как защитить время жизни обратного вызова Чтобы сборщик не забрал делегат, ссылку держат в поле и т. п. Регистрацию и снятие обратного вызова оставляют на стороне C++, C# показывают только события или делегаты
Как выразить владение дескриптором Наследуют SafeHandle и из ReleaseHandle вызывают функцию освобождения В деструкторе / финализаторе обёртки нативный объект делают delete (6.3)

6.3. Как это можно написать с обёрткой на C++/CLI

На стороне C++/CLI особенности нативного кода принимают на себя, а API, показываемый 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;
};

Специфика C++/CLI здесь — пара ~AnalyzerWrapper() и !AnalyzerWrapper(). Оба выглядят как деструкторы C++, но по роли соответствуют шаблону Dispose в .NET.

Запись в C++/CLI Что генерирует компилятор Поведение со стороны C#
~AnalyzerWrapper() (деструктор) Dispose(), реализующий IDisposable Выполняется при выходе из using или при вызове Dispose()
!AnalyzerWrapper() (финализатор) Finalize(), переопределяющий Object::Finalize Выполняется, когда сборщик забирает объект. Когда именно — не определено

Правило такое: освобождение нативного ресурса пишут в финализаторе и вызывают его из деструктора. В реализации ниже ~AnalyzerWrapper() только вызывает this->!AnalyzerWrapper() — как раз это. Тогда даже если сторона C# забудет Dispose(), в конце объект подберёт сборщик. Если деструктор уже вызван, GC::SuppressFinalize подавляет финализацию, поэтому двойного освобождения нет.

Dispose(), Finalize() и Dispose(bool) генерирует компилятор, на стороне C++/CLI их сами не пишут. Наоборот, из кода C++/CLI Dispose() напрямую вызвать нельзя: деструктор вызывают оператором delete. Это соответствие собрано в разделе «Destructors and finalizers» статьи How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn.

Разделение ролей деструктора и финализатораИз using или Dispose на стороне C# вызывается деструктор и через финализатор освобождает нативный ресурс; если Dispose забыли, сборщик вызывает финализатор и в конце подбирает объект; SuppressFinalize предотвращает двойное освобождение.using или Dispose на стороне C#Деструктор (аналог Dispose)Dispose забылиФинализатор при сборке сборщикомВызывают финализаторнативный ресурс делают deleteSuppressFinalize предотвращает двойное освобождение

Рис. 13: Правило: освобождение пишут в финализаторе и вызывают его из деструктора. Даже если Dispose забыли, в конце объект подберёт сборщик.

// 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)
{
    // Если вызвали после уничтожения объекта, останавливаемся здесь,
    // до входа в нативный код.
    // Деструктор (= Dispose) обнуляет _native,
    // и без этой проверки вызов уйдёт в нативный код через нулевой указатель:
    // процесс упадёт по нарушению доступа, а не по исключению .NET.
    // Со стороны C# ожидаемое поведение — ObjectDisposedException
    // при обращении после Dispose; проверка нужна во всех методах, которые используют _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()));
    }
}

На стороне C# код получается гораздо проще.

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

Со стороны C# видны только string, List<int> и IDisposable. Особенности IntPtr, функций освобождения и нативных строковых буферов не видны. Именно в этом главное преимущество.

7. Случаи, когда C++/CLI всё же лучше не выбирать

Разумеется, C++/CLI не панацея. Есть ситуации, когда его лучше не выбирать.

  • Та сторона изначально публикует чистый C API
    • В этом случае естественнее P/Invoke.
  • Нужна кроссплатформенность
    • C++/CLI рассчитан только на Windows.
  • Граница взаимодействия небольшая, а типы простые
    • Стоимость ещё одной обёрточной DLL иногда оказывается выше выгоды.
  • Строго учитывают ограничения AOT или распространения
    • Сначала стоит посмотреть требования архитектуры в целом.

Последний пункт — «AOT и ограничения распространения» — звучит слишком абстрактно, поэтому ниже перечислены ограничения, которые реально сказываются. Здесь легко оступиться, если оставаться на ощущениях эпохи .NET Framework.

Ограничение Содержание Влияние на практике
ОС C++/CLI с целью .NET (семейство .NET Core) — только Windows Если планируете Linux-контейнеры или macOS, на этом шаге выбрать нельзя
Native AOT В списке несовместимости Native AOT C++/CLI указан явно. Вместе с ним нельзя динамическую загрузку вроде Assembly.LoadFile, System.Reflection.Emit и встроенный COM Windows С курсом на один нативный двоичный файл через PublishAot это не совмещается
Формат вывода Если цель — .NET, exe сделать нельзя, только DLL. .NET Standard тоже нельзя взять целью Точку входа ставят в exe на стороне C#, C++/CLI подключают как DLL
Формат проекта Используют не SDK-style csproj, а .vcxproj. Один проект не мультитаргетится на несколько .NET Если нужны и .NET Framework, и .NET, файлы проектов разделяют
Зависимость от среды выполнения /clr включает и /MD, поэтому нужны DLL среды выполнения MSVC. Если цель — .NET, в вывод дополнительно кладут ijwhost.dll Если исходите из XCOPY-распространения или публикации одним файлом, это проверяют заранее
Архитектура ЦП Смешанная сборка содержит нативный машинный код, поэтому одним двоичным файлом, как AnyCPU в C#, все архитектуры не закрыть Собирают и распространяют отдельно для x86 / x64 и т. д.
Как загружается С .NET 7 всегда загружается в AssemblyLoadContext по умолчанию. В .NET 6 и раньше при первом вызове со стороны native могла загрузиться в другой AssemblyLoadContext В схемах, где у каждого плагина свой контекст загрузки, поведение проверяют

C++/CLI-проект может брать целью .NET (семейство .NET Core) начиная с Visual Studio 2019. Если доступна только более старая среда, сначала исходят из .NET Framework.

То есть критерий выбора — «учитывая сложность нативной DLL, где преобразование получится самым естественным». Для простого случая — P/Invoke, для сложного — C++/CLI. Такое разделение обычно работает.

Ситуации, в которых C++/CLI лучше не выбиратьЧетыре ситуации, в которых обёртку C++/CLI лучше не выбирать: чистый C API уже есть, нужна кроссплатформенность, граница мала и типы просты, жёстко смотрят на AOT и ограничения распространения.C API уже естьC++/CLI не выбиратьНужна кроссплатформенностьГраница мала, типы простыЖёсткие ограничения AOT и распространенияГде преобразование получится естественным

Рис. 14: C++/CLI не универсален. Если подходит одно из этих четырёх, сначала P/Invoke или пересмотр архитектуры.

8. Итог

Как способ использовать нативную DLL из C#, P/Invoke по-прежнему остаётся основной дорогой. Но это верно, когда та сторона ведёт себя как обычный честный C API.

Если нативная сторона спроектирована как библиотека C++, то вместо того чтобы упорно расставлять на стороне C# IntPtr и атрибуты маршалинга, часто лучше сохраняет чистоту границы создание тонкой обёртки на C++/CLI.

Особенно если задействованы:

  • API на основе классов
  • предпосылки владения
  • std::wstring и std::vector
  • преобразование исключений
  • обратные вызовы
  • поэтапная миграция

C++/CLI оказывается вполне реалистичным выбором.

Сама по себе эта работа не выглядит эффектной. Но то, где именно наводить порядок на границе, впоследствии напрямую сказывается на удобстве сопровождения. Когда нужно совместно использовать уже существующий Windows-код и .NET, C++/CLI по-прежнему остаётся удобным инструментом.

9. Справочные материалы

Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Как выбирать между P/Invoke и обёрткой на C++/CLI?
Если на той стороне — плоский набор C-функций, опубликованных через extern "C", P/Invoke — самый прямой и простой выбор. Если нативная DLL спроектирована вокруг классов C++ и в дело входят владение, строки, массивы, исключения и обратные вызовы, сопровождать код проще, вставив одну тонкую обёртку на C++/CLI. Критерий — «учитывая сложность нативной DLL, где преобразование получится самым естественным»: для простого случая — P/Invoke, для сложного — C++/CLI. Такое разделение обычно работает.
Что становится проще, если вставить обёртку на C++/CLI?
На стороне C++/CLI можно подключить нативные заголовки и работать с std::wstring и std::vector как с обычными типами C++, поэтому на стороне C# больше не нужно воссоздавать мир C++. C# можно показать только привычный для .NET API — string, byte[], List<T>, IDisposable, исключения — и спрятать IntPtr, функции освобождения и маршалинг. Исключения и коды ошибок C++ на границе можно перевести в исключения .NET, и поэтапная миграция с опорой на уже существующий код тоже становится проще.
Когда идти только на P/Invoke становится тяжело?
Если нативная DLL спроектирована вокруг классов C++, через P/Invoke напрямую можно вызывать только экспортируемые функции DLL, поэтому рано или поздно всё равно понадобится слой, сводящий всё к функциям в стиле C, — по сути вы начинаете проектировать C-совместимый API. Дальше, когда добавляются возврат std::wstring или std::vector, прогресс через обратный вызов или исключения C++, на стороне C# копятся MarshalAs, ручные буферы и управление временем жизни делегатов. Если предпосылки владения и времени жизни выражать через IntPtr, перечитывать такой код позже довольно тяжело.
Бывают ли случаи, когда C++/CLI лучше не выбирать?
Да. Если та сторона изначально публикует чистый C API, естественнее P/Invoke. Кроме того, C++/CLI рассчитан только на Windows, поэтому при кроссплатформенности он не подходит. Если граница взаимодействия невелика, а типы просты, стоимость ещё одной обёрточной DLL может оказаться выше выгоды. Если жёстко смотрят на AOT или ограничения распространения, сначала стоит проверить требования архитектуры в целом.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог