Максим Пылаев, инженер-программист, C3D Labs, описывает этапы развития обертки C# для C3D Toolkit, объясняет, с какими сложностями сталкиваются разработчики и как справляются с вызовами, формируя оптимальные условия для работы пользователей.
Запросы на вхождение в продукт более удобным образом были актуальны всегда. Именно для этого существуют разнообразные обертки: они призваны снизить порог входа. Рассмотрим, как происходит разработка С#-обертки для ядра из набора SDK-инструментов C3D Toolkit.
Требования к обертке — классические, такие же, как и для любого другого продукта. Они включают поддержку, масштабируемость и кроссплатформенность. Сделаем небольшой экскурс в историю. На текущий момент у нас есть С#-обертки, сделанные по технологии CLI/C++. К сожалению, это влечет за собой ряд ограничений. Самый явный минус заключается в том, что мы не можем использовать эти обертки под Linux. Технология не позволяет их собрать. Развитие самого .NET Framework остановилось на версии 4.8.2 и дальше не планируется. Поэтому были инициированы работы для перехода на другую технологию, в том числе для того, чтобы сделать обертки кроссплатформенными.
Какие шаги были сделаны и какие этапы пройдены? Сковорода на иллюстрации служит яркой демонстрацией происходящего. Ядро базируется на C++, и для того, чтобы все гладко состыковать, мы должны «пожарить» эту «яичницу». Чтобы ее «пожарить», нам нужна обертка на языке Си, потому что именно при переходе на чистый Cи, впоследствии мы сможем воспользоваться технологиями P/Invoke и обернуть все это в C#. В 2022 году была начата обертка на Си, позже — в первом квартале 2023 года — шла работа над первичной оберткой на C#. Во второй половине 2023 года стартовали работы по финальной обертке. Рассмотрим каждый из этапов детальнее.
Глобально для того, чтобы сделать Си-обертку поверх нашего плюсового ядра, нужно было предпринять ряд шагов. Во-первых, необходимо было определить тот набор классов и структуру данных, которые подлежат обертке, и это было сделано. Так как Си тоже подразумевает ряд ограничений, нужно было брать в расчет и обработку шаблонных классов. По анализу API нашего ядра мы выбрали минимальный необходимый функционал, который должен был быть реализован. И этот функционал был реализован по шаблонам исключительно на Си.
Во-вторых, необходимо было пройти этап формирования самой Си-обертки по набору заголовков на С++. В результате мы получили сборки под разные операционные системы.
В-третьих, предстояло сделать из Си-обертки, которая в данном случае является промежуточным звеном, C#-обертку. Что требовалось? На вход необходим был набор Си-заголовков. Мы используем проект CppSharp от MonoProject, который выполняет первичную задачу по преобразованию Си-объявлений в объявления С#. На иллюстрации приведен пример первичного преобразования на одной функции. В данном случае это объявление mb_collection через параметры mb_mеsh. Cи-представление и объявление P/Invoke после формирования первичной обертки выглядят следующим образом. Можно сказать, что на первичном этапе все стыкуется. Формально все заработало, но следует проанализировать результаты.
Каковы были первые результаты? Признаться, результаты были неутешительными. Причиной тому выступают несколько факторов. Во-первых, все методы являются статическими. Во-вторых, полностью отсутствует управление unmanaged-памятью. Как следствие, Garbage collector не знает, что делать и как освобождать нативные ресурсы. В-третьих, потерялось представление об оригинальных классах и наследовании. Почему потерялось? Потому что мы сначала перешли на Си, потом из этого Си сделали первичную обертку. Очевидно, что будет мало желающих писать подобный метод, даже используя функцию автодополнения. Итог нечитаем. То есть теоретически это рабочий вариант, но на практике он достаточно сложен в использовании.
Были инициированы работы по подготовке финальной обертки. Что нужно было сделать? Изначально не хватало информации о тех классах, от которых мы избавились на этапе создания Си-обертки. Чтобы преодолеть это препятствие, было решено формировать дополнительную метаинформацию, которая находится рядом с Си-оберткой. Был выбран самый обычный удобочитаемый формат JSON, в него записываются все обернутые методы, все обернутые классы. Сделано это с единственной целью — для того, чтобы потом правильно восстановить эти классы. Также понадобилось написание парсера уже сформированной первичной обертки, чтобы его однозначно разметить. Важно было понимать, где P/Invoke-объявления, где объявления API, где имплементации самих методов. Генератор финальной обертки — это то, что аккумулирует в себе эти составные части и, суммируя информацию по метаданным и по первичной обертке, восстанавливает классы.
Разберемся, как функционирует генератор финальной обертки C#. Можно обозначить это в качестве некой финальной функции Process, которая использует первичную обертку и метаинформацию. На выходе получаем финальный C#-файл. Весь наш код разбираем на элементарные составляющие и в итоге получаем, что FileCreator занимается задачами формирования определенного шаблона выходного CS-файла, в котором есть имена восстановленных классов. Потом APICreator проходится по всему списку API для данного класса, которые были обернуты. В свою очередь, APICreator вызывает FunctionCreator для каждой функции. Соответственно, FunctionCreator по каждому параметру вызывает необходимое преобразование. Все это повлекло за собой ряд изменений.
Если мы рассмотрим первичную обертку, то обнаружить, что это конструктор, достаточно сложно и неочевидно. Об этом можно судить только по комментариям. Конструктор возвращает некий параметр типа IntPtr. Это очень неявно, и непонятно, как этим пользоваться. Финальная обертка преобразует это до такого состояния, что оно действительно становится классом с правильными параметрами. Не просто какой-то указатель на mesh, а полноценный .NET MbMesh объект c подготовкой для вызова объявленной P/Invoke-функции.
Еще одно сравнение. Иллюстрация наглядно показывает, что после первичной обертки по Си-заголовку формируется название класса просто по имени файла. Далее объявляются структуры, в которых, в свою очередь, объявлены P/Invoke-методы. При восстановлении мы получаем класс, который соответствует именованию оригинального С++ класса, и необходимые добавки из финальной обертки, которые помогают правильно управлять памятью, правильно реализовывать деструкторы для удаления нативных ресурсов.
Каких результатов удалось достичь? Сейчас C#-обертка собирается для разных ОС. Может, эта возможность не уникальна, но она дополнила функционал. Теперь мы собираем обертку как минимум под Windows и Linux. Она повторяет функционал оригинального кода, за исключением некоторых ограничений. В плане наследования есть нюансы: это можно сделать через функции доступа — можно преобразовать текущий объект как в родительский, так и в дочерний класс.
В процессе работы были выполнены многие дополнения, которые прежде всего связаны с управлением ресурсами, потому что была острая необходимость состыковать unmanaged-память с управляемым кодом на C#.
В целях отладки был проброшен нативный счетчик у восстанавливаемых классов-наследников классов со счетчиком ссылок. Для нативных классов-контейнеров был реализован интерфейс ICollection. Это сделано для большего комфорта.
Финальная версия C# намного более интуитивна и понятна, а пользоваться контейнерами, которые есть в ядре, с точки зрения C#-программистов, стало проще. Еще одно дополнение заключается в том, что любой объект, создаваемый внутри классового метода, имеет привязку к этому классу. Это сделано для сохранения данного объекта в ситуации, когда работает Garbage collector. Кроме того, реализован базовый .NET класс, сформированный с реализацией IDisposable-интерфейса, который позволяет правильно управлять памятью.
Коснемся планов. Они вполне объяснимы. Как минимум необходимо настроить качественный CI для того, чтобы все процессы работали автоматически. Предстоит масштабная работа по оптимизации тестового набора по C#: на сегодня тестов немного, они нуждаются в структуризации и должном оформлении. В планах — расширение покрытия функционала ядра. Недостаточно того, что генератор просто работает. Он реализован таким образом, чтобы работать автоматически. Важно, что изменения в ядре, изменения API — добавление или удаление — зеркально отображаются в обертках. Также существует область функциональности, которая по некоторым техническим причинам не подлежит обертке. Со временем мы намерены охватить и ее.
В качестве отдельного пункта из планов можно выделить сборки и под Astra Linux, и, я надеюсь, под большее количество специальных операционных систем. Уверен, что все это будет собираться точно так же, как сейчас собирается ядро.

Максим Пылаев,
инженер-программист,
C3D Labs