«Хороший сайт делается для людей, а поисковики приходят следом»

— из принципов редакции SeoLiga.ru
Главная » Статьи » Delphi и C++ » Взаимодействие C++ и управляемого кода

Взаимодействие C++ и управляемого кода

Редко какой проект начинается с чистого листа. Чаще у команды есть библиотека на C++, написанная много лет назад и прекрасно работающая, а интерфейс нужно сделать на современной платформе с управляемым кодом. Переписывать всё заново дорого и рискованно, поэтому разработчики учатся соединять два мира. Сделать это можно несколькими способами, и у каждого свои сильные стороны и подводные камни.

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

В чём сложность

Управляемый код выполняется под присмотром среды, которая сама отслеживает память, проверяет типы и убирает мусор. Нативный код живёт по другим правилам: указатели, ручное выделение памяти, собственные соглашения о вызовах. Когда два мира встречаются, возникают вопросы:

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

Ответы на эти вопросы определяют выбор технологии. Начнём с самой простой.

Вызов платформенных функций через P/Invoke

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

Пример нативной функции на C++:

extern "C" __declspec(dllexport)
int __stdcall AddNumbers(int a, int b)
{
    return a + b;
}

И её вызов из C#:

using System.Runtime.InteropServices;

static class Native
{
    [DllImport("mylib.dll", CallingConvention = CallingConvention.StdCall)]
    public static extern int AddNumbers(int a, int b);
}

int sum = Native.AddNumbers(2, 3);

Обратите внимание на extern "C": он отключает декорирование имён, иначе управляемая сторона не найдёт функцию по простому названию. Соглашение о вызовах тоже должно совпадать, иначе стек окажется испорченным, и программа упадёт в неожиданном месте.

Плюсы подхода: простота и отсутствие лишних слоёв. Минусы: подходит в основном для функций с простыми типами, а классы C++ так напрямую не передать.

Маршалинг: как данные пересекают границу

Маршалинг — это преобразование данных между представлениями. Простые числовые типы проходят без проблем, а вот со строками, структурами и массивами нужно быть внимательным.

Для строк важно решить, какая кодировка используется на нативной стороне — однобайтовая или двухбайтовая. Это задаётся атрибутом:

[DllImport("mylib.dll", CharSet = CharSet.Unicode)]
public static extern int PrintText(string text);

Для структур нужно гарантировать одинаковую раскладку полей:

[StructLayout(LayoutKind.Sequential)]
public struct Point
{
    public int X;
    public int Y;
}

Правила, которые экономят нервы:

  • всегда явно указывайте раскладку структур и кодировку строк, не полагаясь на значения по умолчанию;
  • для выходных буфферов используйте заранее выделенные объекты нужного размера;
  • не передавайте в нативный код ссылки, которые могут устареть к моменту использования;
  • проверяйте размеры структур с обеих сторон, особенно при выравнивании.

Функции обратного вызова

Иногда нативная библиотека должна позвонить обратно — например, сообщить о ходе операции. Для этого в управляемом коде объявляется делегат, и среда превращает его в указатель на функцию.

[UnmanagedFunctionPointer(CallingConvention.StdCall)]
public delegate void ProgressCallback(int percent);

[DllImport("mylib.dll")]
public static extern void StartWork(ProgressCallback cb);

ProgressCallback keep = p => Console.WriteLine(p);
StartWork(keep);

Здесь кроется классическая ловушка: если делегат нигде не хранится, сборщик мусора может его удалить, пока нативный код ещё собирается вызвать функцию. В результате получаем нарушение доступа через случайное время после запуска. Решение простое — держите ссылку на делегат в поле класса столько, сколько нативная сторона может им пользоваться.

C++/CLI: мост на смешанном языке

Когда нужно работать с классами C++ и сложными типами, удобнее второй подход: промежуточная библиотека на C++/CLI. Это диалект, умеющий одновременно оперировать нативными и управляемыми типами. Вы пишете обёртку, которая внутри хранит указатель на нативный объект, а снаружи предоставляет привычный управляемый класс.

public ref class CalculatorWrapper
{
    NativeCalculator* impl;
public:
    CalculatorWrapper() { impl = new NativeCalculator(); }
    ~CalculatorWrapper() { this->!CalculatorWrapper(); }
    !CalculatorWrapper() { delete impl; impl = nullptr; }

    int Add(int a, int b) { return impl->Add(a, b); }
};

В этом примере есть деструктор и финализатор. Первый позволяет освободить ресурсы детерминированно, при вызове освобождения объекта, второй страхует на случай, если про это забыли. Такая двойная защита — основа корректной работы с нативными ресурсами.

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

COM как универсальный контракт

Если нативные компоненты уже оформлены как COM-объекты или вы хотите получить языконезависимый интерфейс, подойдёт взаимодействие через COM. Управляемый код умеет подключать такие компоненты через сгенерированную обёртку и работать с ними почти как с обычными классами.

Особенности:

  • интерфейсы описываются отдельно и служат строгим контрактом между сторонами;
  • время жизни объектов контролируется подсчётом ссылок, и обёртка берёт эту работу на себя;
  • для освобождения COM-объектов раньше сборки мусора используются специальные вызовы освобождения;
  • потоковая модель — отдельная тема: многие компоненты требуют определённого типа апартамента.

COM тяжелее в настройке, но даёт чёткие границы и хорошо подходит для крупных систем, где разные модули пишутся независимо.

Время жизни, память и сборщик мусора

Самая коварная область — память. Управляемая куча может перемещать объекты при уплотнении. Если нативный код получил адрес объекта, а потом сборщик переместил его, указатель станет недействительным. Чтобы этого избежать, объект закрепляют на время использования либо копируют данные в неуправляемую память.

byte[] buffer = new byte[1024];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
    IntPtr ptr = handle.AddrOfPinnedObject();
    NativeFill(ptr, buffer.Length);
}
finally
{
    handle.Free();
}

Закреплять объекты надолго не стоит, иначе куча фрагментируется. Лучший вариант — закрепить на время одного вызова либо выделить неуправляемый буфер и копировать данные туда и обратно.

Отдельное правило: тот, кто выделил память, тот её и освобождает. Нельзя освобождать в управляемом коде память, выделенную нативным менеджером, и наоборот, если не договорились о едином аллокаторе. Иначе вы получите повреждение кучи, которое проявится совсем в другом месте.

Исключения и коды ошибок

Исключение нативной стороны не должно просачиваться через границу в управляемый код: поведение в таком случае зависит от платформы и часто кончается аварийным завершением. Надёжная практика — перехватывать все исключения внутри экспортируемых функций и возвращать код ошибки.

extern "C" __declspec(dllexport)
int __stdcall DoWork(const char* name)
{
    try
    {
        Process(name);
        return 0;
    }
    catch (const std::exception&)
    {
        return -1;
    }
    catch (...)
    {
        return -2;
    }
}

На управляемой стороне код ошибки преобразуется в осмысленное исключение, и дальше всё работает привычным образом. Так граница остаётся предсказуемой.

Как выбрать подход

ЗадачаПодходящий способ
Несколько простых функций, плоский интерфейсP/Invoke
Богатые классы C++, много типов данныхОбёртка на C++/CLI
Готовые COM-компоненты, независимые модулиВзаимодействие через COM
Нужен переносимый интерфейс для разных платформПлоский C-интерфейс и P/Invoke

Начинайте с самого простого варианта, который решает задачу. Усложнять архитектуру стоит только тогда, когда плоских функций становится слишком много или данные становятся слишком сложными.

Практические советы

  1. Делайте интерфейс границы узким: чем меньше функций и типов пересекают её, тем меньше точек отказа.
  2. Документируйте владельца каждого ресурса — кто создаёт, кто освобождает.
  3. Пишите отдельные тесты, которые прогоняют вызовы через границу, а не только модульные тесты каждой стороны.
  4. Следите за разрядностью: 32-битная и 64-битная версии библиотек несовместимы, и приложение должно подгружать подходящую.
  5. Включайте проверку параметров вызовов в отладочной сборке, чтобы ловить расхождения сигнатур раньше.

Заключение

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

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

СЛ
Сергей Лигин
Автор SeoLiga.ru. Пишет о практике: что работает на реальных проектах, а что лучше обойти стороной. Хотите предложить свою статью? Загляните в раздел «Авторам».