Привет, Хабр. Некоторое время назад я рассказывал о том, как нам удалось наладить ежемесячный выпуск релизов для платформы C++ (на Windows и Linux) библиотек, исходный код которых получается путём автоматической трансляции кода оригинальных продуктов, написанных на C#. Также я писал о том, как мы заставили такой транспилированный код выполняться в рамках нативного C++ без сборки мусора.
В сегодняшней статье я расскажу о том, как устроено сердце этой системы — Портер. Я опишу его архитектуру и режимы работы, покажу, как выглядит портированный код на типовых примерах, расскажу о проблемах, с которыми мы столкнулись, и о планах развития проекта. Всем заинтересованным — добро пожаловать под кат.
Поколения фреймворка
Прежде чем начать говорить о деталях реализации, мне придётся сказать несколько слов об истории проекта, чтобы стали ясны некоторые отсылки. Как я уже рассказывал в первой статье, наша компания занимается, в основном, разработкой библиотек для платформы .Net на C#. Наша команда разрабатывает и поддерживает решения, предназначенные для выпуска данного кода под другие платформы. При нашем объёме кодовой базы автоматическая трансляция полного кода продукта на целевой язык оказывается существенно дешевле, чем параллельная разработка на нескольких языках. Запуск библиотеки .Net внутри плюсовой оболочки — другой возможный подход, но он также является трудоёмким из-за необходимости корректного проброса всех API, да и работало это не на всех целевых платформах, когда мы начинали.
На сегодняшний день наш стек технологий включает в себя следующие продукты:
-
Самый первый портер с C# на Java на основе текстового процессора — устарел, более не используется.
-
Портер с C# на Java на основе синтаксического анализатора Metaspec — актуален.
-
Портер с C# на C++ на основе синтаксического анализатора NRefactory — актуален.
-
Построенный на Roslyn и рефлексии генератор модулей Python, являющихся обёртками над машиной .Net, в которой выполняются оригинальные продукты на C# — актуален.
-
Портеры с C# на Java и C++ на основе общего фреймворка, построенного на Roslyn — в разработке.
Кроме перечисленных продуктов, наша экосистема включает в себя дополнительные утилиты, помогающие достичь следующих целей:
-
Подготовка кода C# к портированию существующими портерами — например, понижение версии языка до той, которая поддерживается синтаксическими анализаторами Metaspec (3.0) и/или NRefactory (5.0).
-
Анализ кода C# на удовлетворение требованиям, накладываемым процедурами портирования.
-
Трансляция аспектов кода C#, плохо покрываемых существующими портерами (документация, примеры использования и т. д.).
Архитектура
По мере развития проекта его архитектура менялась, особенно при переходе к следующему поколению.
Первой разработкой в направлении трансляции кода стала утилита, переводящая синтаксис C# в эквивалентные конструкции Java. Это было сделано простейшим способом — заменой подстрок, выполняемой с использованием регулярных выражений. Плюсы и минусы такого решения очевидны, и я не буду на них останавливаться.
Когда такого подхода перестало хватать, была начата разработка первого портера на базе синтаксического анализатора Metaspec. В этой реализации код исходного проекта на C# загружается анализатором и преобразуется в AST-представление (отдельное дерево для каждого файла с исходным кодом). Кроме того, Metaspec строит семантическую модель, позволяющую разрешать имена типов и обращения к членам. Используя эти две модели, портер выполняет две стадии: анализ кода C# и генерацию кода Java. Стадия анализа нужна для поиска нетранслируемых конструкций и вывода соответствующих ошибок и предупреждений, а также для сбора дополнительной информации, влияющей на кодогенерацию. На стадии кодогенерации происходит непосредственное формирование выходного кода.
Каждая стадия предполагает один или несколько проходов по синтаксическому дереву. На этапе анализа нас, как правило, интересуют конкретные синтаксические конструкции — их поиск проще всего осуществляется с использованием шаблона «Посетитель». При этом поиск различных конструкций легко инкапсулируется в отдельные классы посетителей и выполнятся в виде отдельных проходов по дереву. Кодогенерация, напротив, осуществляется в один проход, при котором вложенные узлы обходятся рекурсивно, а соседние — последовательно, поскольку трансляция конкретного узла зачастую сильно зависит от состава родительских узлов. На обоих этапах дерево кода C# остаётся неизменным.
Архитектура портера с C# на C++, построенного на базе синтаксического анализатора NRefactory несколькими годами позже, во многом подобна описанной выше. После загрузки кода в AST-представление и построения семантической модели по дереву совершается несколько проходов посетителями для сбора предварительной информации, после чего генерируется код C++ — опять же, в один проход. Дерево кода C# остаётся неизменным и в этой модели. Отличия касаются, прежде всего, декомпозиции кода и разделения обязанностей на этапе кодогенерации, хотя полностью изолировать алгоритмы и избавиться от божественных объектов не удалось и на этой итерации.
Недостатком подобного однопроходного подхода к кодогенерации является сильная связность кода, осуществляющего разбор синтаксических конструкций и собственно создание текста программы на целевом языке, поскольку на обработку одного и того же узла может влиять огромное количество факторов. Так, способ генерации кода класса C++ на основе класса C# определяется следующим:
-
Был ли данный класс исключён из портирования атрибутом или указанием его имени в соответствующем разделе конфигурационного файла.
-
Является ли данный класс обобщённым типом.
-
Если да, существуют ли другие классы с тем же полным именем, но другим набором параметров типа (перегрузка по числу аргументов шаблона в C++ не поддерживается, в отличие от C#).
-
Заданы ли для класса или его членов атрибуты, влияющие на порядок членов класса в генерируемом коде.
-
Заданы ли для класса или его членов атрибуты, либо заданы ли глобальные опции, влекущие изменение области видимости членов класса.
-
Является ли хоть один из классов, внешних по отношению к текущему, обобщённым.
-
Заданы ли глобальные опции, приводящие к исключению непубличных типов и членов из трансляции, удалению определений членов и/или замене их заглушками.
-
Является ли класс коллекцией тестов (TestFixture или Theory).
-
Является ли класс абстрактным.
-
Заданы ли для класса атрибуты, влекущие его переименование в выходном коде.
-
Какие базовые типы есть у класса, какие из них удалены или добавлены атрибутами, влияющими на поведение портера.
-
Заданы ли для обобщённых параметров класса ограничения.
-
Является ли класс наследником System.Exception.
-
Удовлетворены ли условия для добавления к классу конструкторов или деструктора, отсутствующих в исходном коде.
-
Есть ли в базовом классе члены, которые становятся определениями для членов реализуемых данным классом интерфейсов.
-
Относится ли класс к цепочкам наследования, для которых в коде присутствуют вызовы Clone() или MemberwiseClone(), которые нужно эмулировать отдельно.
-
Существуют ли условия для добавления к методам выходного класса перегрузок, отсутствующих в исходном классе.
-
Зависят ли инициализаторы констант класса друг от друга.
-
Включена ли для данного класса (или для всех классов) поддержка рефлексии.
-
Есть ли у класса комментарий, является ли он описанием в формате XML, и какие опции заданы для его обработки.
-
Прочие условия.
Проверки из данного списка влияют только на трансляцию класса как целого (код класса, помещаемого в заголовочный файл; состав, порядок и область видимости членов и т. д.), а не на трансляцию отдельных членов. Кроме того, список регулярно дополняется по мере выявления дополнительных требований, то есть, разделение обязанностей не может быть проведено лишь однажды.
Архитектура общего движка, на базе которого в настоящее время пишутся портеры с C# на Java и на C++, была пересмотрена с учётом данной проблемы, а также дополнительных возможностей, которые даёт Roslyn. Дерево кода в этой структуре более не является неизменным и последовательно модифицируется различными алгоритмами на новой стадии — стадии трансформации. Данные алгоритмы пишутся в соответствии с правилом единственной обязанности. На стадии трансформации выполняется столько работы, сколько возможно: синтаксический сахар C# заменяется конструкциями, доступными в C++ и Java, производится переименование типов и членов, удаляются сущности, исключённые из портирования, изменяются области видимости, модифицируются списки базовых типов, и так далее. В итоге логика кодогенерации существенно упрощается. С другой стороны, появляются дополнительные накладные расходы по управлению доступными алгоритмами модификации дерева и очерёдностью их выполнения.
Стадия анализа слилась со стадией трансформации. Алгоритмы стадии кодогенерации подверглись дополнительной декомпозиции: теперь за обработку отдельного типа узла отвечает, как правило, отдельный класс. Кроме того, было сделано большое количество других полезных изменений: улучшена подсистема конфигурирования, пересмотрен механизм замены типов C# типами целевых языков, поддержана работа не только в виде приложения (командной строки или оконного), но и в виде плагина для Visual Studio, работающего непосредственно с загруженным решением и встроенными средствами диагностики, и так далее.
Операции над исходным кодом
На первый взгляд может показаться, что у портера может быть лишь один способ использования: подав ему на вход код C#, мы ожидаем получить на выходе эквивалентный код C++. Действительно, такой способ является наиболее распространённым, однако далеко не единственным. Ниже перечислены другие режимы, предоставляемые фреймворками для портирования кода и связанными утилитами.
-
Анализ кода C# на портируемость.
Продукты разрабатываются программистами, редко знающими в подробностях процедуру портирования кода на другие языки и связанные с ней ограничения. В результате возникают ситуации, когда корректные с точки зрения C# изменения, сделанные продуктовыми разработчиками, ломают процедуру выпуска релизов для других языков. Например, на сегодняшний день ни один из наших портеров не имеет поддержки оператора yield, и его использование в коде C# приведёт к генерации некорректного кода Java или C++.
За время развития проекта нами были испробованы несколько способов автоматизации обнаружения таких проблем.-
Проблема может быть обнаружена на этапе портирования кода на целевой язык, сборки портированного кода или прогона тестов. Это худший случай из возможных, поскольку обнаружение проблем происходит уже после слияния кода в основную ветку, зачастую — перед релизом. К тому же, решением проблемы занимаются люди, которые не в курсе изменений, вызвавших её, что увеличивает время на её устранение. С другой стороны, такой подход не требует разработки и настройки специализированных утилит для раннего обнаружения проблем.
-
Проблема может быть обнаружена в среде CI (мы используем Jenkins и SonarQube). Таким образом, о проблеме узнают разработчики C# перед слиянием в общую ветку или после такого слияния, в зависимости от принятых конкретной командой практик. Это увеличивает оперативность исправления проблем, но требует программирования дополнительных проверок в инфраструктуре портера или в сторонних утилитах.
-
Проблема может быть обнаружена локально разработчиком C# при запуске специализированных инструментов — например, портера в режиме анализатора. Это удобно, но требует разработки дополнительных утилит и дисциплины. Кроме того, это скрывает информацию о том, была ли проверка на самом деле запущена и пройдена.
-
Проблема может быть обнаружена локально при работе в IDE. Установка плагина к Visual Studio позволяет разработчику C# обнаруживать проблемы в реальном времени. Это по-прежнему требует дополнительных затрат на разработку экосистемы, зато предоставляет наиболее оперативный способ обнаружения проблем. В этом смысле интеграция Roslyn в современные версии Visual Studio особенно удобна, так как позволяет использовать одни и те же анализаторы как в контексте загруженного в данный момент решения, так и в ином окружении — например, в среде CI.
-
-
Понижение версии языка C#.
Как уже говорилось выше, мы ограничены в использовании версий языка C#: 3.0 для портирования на Java и 5.0 для портирования на C++. Это требует дисциплины от программистов C# и во многих случаях неудобно. Чтобы обойти эти ограничения, портирование можно провести в два этапа: сначала заменить конструкции современных версий языка C# поддерживаемыми аналогами из прошлых стандартов, затем приступить непосредственно к портированию.
При использовании портеров, основанных на устаревших синтаксических анализаторах, понижение может быть выполнено только путём использования внешних инструментов (например, утилит, написанных на базе Roslyn). С другой стороны, портеры, основанные на Roslyn, выполняют оба этапа последовательно, что позволяет использовать один и тот же код как при портировании кода ими, так и при подготовке кода к портированию более старыми инструментами. -
Подготовка примеров использования портированных библиотек.
Это похоже на портирование кода продуктов, однако подразумевает несколько иные требования. При портировании библиотеки на десятки миллионов строк важно, прежде всего, максимально строгое следование поведению оригинального кода даже в ущерб читаемости: более простой, но отличающийся по эффектам код отлаживать придётся дольше. С другой стороны, примеры использования нашего портированного кода должны выглядеть максимально просто, давая понять, как пользоваться нашим кодом в C++, даже если это не соответствует поведению оригинальных примеров, написанных на C#.
Так, при создании временных объектов программисты C# часто пользуются using statement, чтобы избежать утечки ресурсов и строго задать момент их высвобождения, не полагаясь на GC. Строгое портирование using даёт достаточно сложный код C++ (см. ниже) из-за множества нюансов вида «если в блоке using statement вылетает исключение и из Dispose тоже вылетает исключение, какое из них попадёт в перехватывающий контекст?». Такой код лишь введёт в заблуждение программиста C++, создав впечатление, что использовать библиотеку сложно, однако на самом деле умного указателя на стеке, в нужный момент удаляющего объект и высвобождающего ресурсы, вполне достаточно. -
Подготовка документации к коду.
Наши библиотеки предоставляют богатый API, задокументированный через XML-комментарии в соответствии с практиками C#. Перенос комментариев в C++ (мы используем Doxygen) — задача отнюдь не тривиальная: помимо разметки, необходимо заменить ссылки на типы (в C# полные имена записываются через точку, в C++ — через пару двоеточий) и их члены (а в случае использования свойств — ещё и понять, идёт ли речь о геттере или сеттере), а также оттранслировать фрагменты кода (которые лишены семантики и могут быть неполными).
Эта задача решается как средствами самого портера, так и внешними утилитами — например, анализирующими сгенерированную XML-документацию и дополнительно подготовленные фрагменты вроде примеров использования методов.
Правила трансляции кода с C# на C++
Поговорим о том, каким образом синтаксические конструкции языка C# отображаются на C++. Эти правила не зависят от поколения продукта, поскольку иное существенно затруднило бы процесс миграции. Данный список не будет исчерпывающим ввиду ограниченного объёма статьи, но я постараюсь уделить внимание хотя бы основным моментам.
Проекты и единицы компиляции
Трансляция производится попроектно. Если продукт состоит из более чем одного проекта, они транслируются по очереди, причём зависимый проект может быть обработан только после всех своих зависимостей.
Один проект C# преобразуется в один или два проекта C++. Первый проект (приложение или библиотека) аналогичен проекту C#, второй представляет собой googletest-приложение для запуска тестов (если они присутствуют в исходном проекте). Тип выходной библиотеки (статическая или динамическая) задаётся опциями портера. Для каждого входного проекта портер генерирует файл CMakeLists.txt, который позволяет создавать проекты для большинства сборочных систем. Зависимости между оттранслированными проектами настраиваются вручную в конфигурации портера или скриптах Cmake.
В большинстве случаев одному файлу .cs соответствует один файл .h и один файл .cpp. Имена файлов по возможности сохраняются (хотя из-за особенностей некоторых сборочных систем для C++ портер старается не допускать присутствия файлов с одинаковыми именами, пусть и в разных каталогах). Обычно определения типов попадают в заголовочный файл, а определения методов — в файл исходного кода, но это не так для шаблонных типов, весь код которых остаётся в заголовочных файлах. Файлы .cpp, в которые не попадает никакого кода, опускаются за ненадобностью.
Заголовочные файлы, в которых присутствует, по крайней мере, одно публичное определение, попадают в каталог включаемых файлов, доступных зависимым проектам (и конечным пользователям). Заголовочные файлы, включающие лишь непубличные (internal) определения, попадают в каталог с исходниками. Публичные заголовки зачастую генерируются в двух модификациях: при сборке проекта используется полная версия, тогда как внешним пользователям достаются файлы, в которых скрыта ненужная им информация: имена и типы закрытых полей, непубличные методы (кроме виртуальных), и так далее. Помимо чистоты кода, такая обфускация преследует своей целью затруднить реверс-инжиниринг.
Кроме файлов с кодом, полученных трансляцией оригинального кода C#, портер создаёт дополнительные файлы, содержащие некоторый сервисный код. Это включает в себя, в частности, проверку того, что версия и опции компиляции системной библиотеки, использованной при сборке выходного проекта, совпадают с таковыми у библиотеки, фактически доступной в момент выполнения, что позволяет избежать некоторых трудноотлаживаемых багов. Другие проверки касаются, например, того, что при удалении информации из публичных заголовочных файлов у нас не изменились размеры классов.
Наконец, портер помещает в выходной каталог некоторые дополнительные файлы — в частности, конфигурационные файлы с записями о том, в каких заголовочных файлах искать типы из данного проекта. Эта информация требуется при портировании зависимых сборок. Также в выходную директорию ложится полный лог портирования.
Общая структура исходного кода
Пространства имён C# отображаются в пространства имён C++. Операторы использования пространств имён превращаются в аналоги из C++, по умолчанию попадая лишь в файлы .cpp (если опциями портирования не задано иное). Комментарии переносятся как есть, кроме документации к типам и методам, обрабатываемой отдельно. Форматирование сохраняется частично. Директивы препроцессора не переносятся (максимум — добавляются соответствующие комментарии), поскольку при построении синтаксического дерева необходимо уже задать все константы.
В начале каждого файла находится список включаемых файлов, а после него — список форвардных деклараций типов. Данные списки формируются на основании того, какие типы упоминаются в текущем файле, с тем расчётом, чтобы список включений был по возможности минимальным. Например, базовые типы, значимые типы экземплярных полей, а также те, к членам которых осуществляется доступ в заголовочных файлах (например, из шаблонных методов), приводят к генерации инклюдов. Типы аргументов и возвращаемых значений, ссылочных полей (оборачиваемых в умные указатели) и подобные им упоминаются в виде форвардных определений. Списки включений файлов исходного кода обычно шире, чем в одноимённых заголовочных файлах, и поддерживаются отдельно от них.
Метаданные к типам генерируются портером в виде специальных структур данных, доступных во время выполнения. Поскольку безусловная генерация метаданных существенно увеличивает объём скомпилированных библиотек, она обычно включается вручную для отдельных типов по мере необходимости.
Определения типов
Псевдонимы типов транслируются с использованием синтаксиса «using <typename> = …». Перечисления C# транслируются в перечисления C++14 (синтаксис enum class).
Делегаты преобразуются в псевдонимы для специализаций класса System::MulticastDelegate:
public delegate int IntIntDlg(int n);
using IntIntDlg = System::MulticastDelegate<int32_t(int32_t)>;
Классы и структуры C# отображаются на классы C++. Интерфейсы превращаются в абстрактные классы. Структура наследования соответствует таковой в C# (неявное наследование от System.Object становится явным), если атрибутами не задано иное (например, для создания компактной структуры данных без лишних наследований и виртуальных функций). Свойства и индексаторы разбиваются на геттеры и сеттеры, представленные отдельными методами.
Виртуальные функции C# отображаются на виртуальные функции C++. Реализация интерфейсов также производится с использованием механизма виртуальных функций. Обобщённые (generic) типы и методы превращаются в шаблоны C++. Финализаторы переходят в деструкторы. Всё это вместе задаёт несколько ограничений:
-
Трансляция виртуальных обобщённых методов не поддерживается.
-
Реализация интерфейсных методов виртуальна, даже если в исходном коде это не так.
-
Введение новых (new) методов с именами и сигнатурами, повторяющими имена и сигнатуры существующих виртуальных и/или интерфейсных методов, невозможно (но портер позволяет переименовывать такие методы).
-
Если методы базового класса используются для реализации интерфейсов дочернего класса, в дочернем классе появляются дополнительные определения, которых нет в C#.
-
Вызов виртуальных методов на стадиях конструирования и финализации ведёт себя иначе после портирования, и его нужно избегать.
Понятно, что строгая имитация поведения C# требовала бы несколько иного подхода, и, если бы речь шла о трансляции приложений, это было бы оправдано. Тем не менее, мы предпочли следовать именно такой логике, поскольку в этом случае API портированных библиотек в наиболее полной мере соответствует парадигмам C++. Приведённый ниже пример демонстрирует эти особенности.
Код C#:
using System; public class Base { public virtual void Foo1() { } public void Bar() { } } public interface IFoo { void Foo1(); void Foo2(); void Foo3(); } public interface IBar { void Bar(); } public class Child : Base, IFoo, IBar { public void Foo2() { } public virtual void Foo3() { } public T Bazz<T>(object o) where T : class { if (o is T) return (T)o; else return default(T); } }
Заголовочный файл C++:
#pragma once #include <system/object_ext.h> #include <system/exceptions.h> #include <system/default.h> #include <system/constraints.h> class Base : public virtual System::Object { typedef Base ThisType; typedef System::Object BaseType; typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo; RTTI_INFO_DECL(); public: virtual void Foo1(); void Bar(); }; class IFoo : public virtual System::Object { typedef IFoo ThisType; typedef System::Object BaseType; typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo; RTTI_INFO_DECL(); public: virtual void Foo1() = 0; virtual void Foo2() = 0; virtual void Foo3() = 0; }; class IBar : public virtual System::Object { typedef IBar ThisType; typedef System::Object BaseType; typedef ::System::BaseTypesInfo<BaseType> ThisTypeBaseTypesInfo; RTTI_INFO_DECL(); public: virtual void Bar() = 0; }; class Child : public Base, public IFoo, public IBar { typedef Child ThisType; typedef Base BaseType; typedef IFoo BaseType1; typedef IBar BaseType2; typedef ::System::BaseTypesInfo<BaseType, BaseType1, BaseType2> ThisTypeBaseTypesInfo; RTTI_INFO_DECL(); public: void Foo1() override; void Bar() override; void Foo2() override; void Foo3() override; template <typename T> T Bazz(System::SharedPtr<System::Object> o) { assert_is_cs_class(T); if (System::ObjectExt::Is<T>(o)) { return System::StaticCast<typename T::Pointee_>(o); } else { return System::Default<T>(); } } };
Исходный код C++:
#include "Class1.h" RTTI_INFO_IMPL_HASH(788057553u, ::Base, ThisTypeBaseTypesInfo); void Base::Foo1() { } void Base::Bar() { } RTTI_INFO_IMPL_HASH(1733877629u, ::IFoo, ThisTypeBaseTypesInfo); RTTI_INFO_IMPL_HASH(1699913226u, ::IBar, ThisTypeBaseTypesInfo); RTTI_INFO_IMPL_HASH(3787596220u, ::Child, ThisTypeBaseTypesInfo); void Child::Foo1() { Base::Foo1(); } void Child::Bar() { Base::Bar(); } void Child::Foo2() { } void Child::Foo3() { }
Серия псевдонимов и макросов в начале каждого портированного класса нужна для эмуляции некоторых механизмов C# (прежде всего, GetType, typeof и is). Хэш-коды из файла .cpp используются для быстрого сравнения типов. Все функции, реализующие интерфейсы, виртуальны, хотя в C# это не так.
Члены классов
Как было показано выше, методы классов ложатся на C++ напрямую. Это также касается статических методов и конструкторов. В некоторых случаях в них может появляться дополнительный код — например, чтобы эмулировать вызовы статических конструкторов или чтобы избежать обнуления счётчика ссылок на объект до завершения его конструирования. Впрочем, явный вызов статических конструкторов затратен и потому используется редко; чаще мы переносим код статического конструктора в конструктор закрытого статического поля.
Экземплярные поля C# становятся экземплярными полями C++. Статические поля также остаются без изменений (кроме случаев, когда важен порядок инициализации — это исправляется портированием таких полей в виде синглтонов).
Свойства разбиваются на метод-геттер и метод-сеттер (или что-то одно, если второй метод отсутствует). Для автоматических свойств к ним добавляется также закрытое поле. Статические свойства распадаются на статические геттер и сеттер. Индексаторы обрабатываются по той же логике.
События транслируются в поля (экземплярные или статические), тип которых соответствует нужной специализации System::Event. Трансляция в виде трёх методов (add, remove и invoke) была бы более правильной и, к тому же, позволила бы поддержать абстрактные и виртуальные события. Возможно, в будущем мы придём к такой модели, однако на данный момент вариант с классом Event полностью покрывает потребности нашего кода.
Методы расширения и операторы транслируются в статические методы и вызываются явно. Финализаторы становятся деструкторами.
Следующий пример иллюстрирует описанные выше правила. Как обычно, я удалил незначащую часть кода C++.
public abstract class Generic<T> { private T m_value; public Generic(T value) { m_value = value; } ~Generic() { m_value = default(T); } public string Property { get; set; } public abstract int Property2 { get; } public T this[int index] { get { return index == 0 ? m_value : default(T); } set { if (index == 0) m_value = value; else throw new ArgumentException(); } } public event Action<int, int> IntIntEvent; }
template<typename T> class Generic : public System::Object { public: System::String get_Property() { return pr_Property; } void set_Property(System::String value) { pr_Property = value; } virtual int32_t get_Property2() = 0; Generic(T value) : m_value(T()) { m_value = value; } T idx_get(int32_t index) { return index == 0 ? m_value : System::Default<T>(); } void idx_set(int32_t index, T value) { if (index == 0) { m_value = value; } else { throw System::ArgumentException(); } } System::Event<void(int32_t, int32_t)> IntIntEvent; virtual ~Generic() { m_value = System::Default<T>(); } private: T m_value; System::String pr_Property; };
Переменные и поля
Константные и статические поля транслируются в статические поля, статические константы (в некоторых случаях — constexpr) либо в статические методы (дающие доступ к синглтону). Экземплярные поля C# преобразуются в экземплярные поля C++, при этом все сколько-нибудь сложные инициализаторы переносятся в конструкторы (иногда для этого приходится явно добавлять конструкторы по умолчанию там, где их не было в C#). Переменные на стеке переносятся как есть. Аргументы методов — тоже, за исключением того, что и ref-, и out-аргументы становятся ссылочными (благо, IL их всё равно не различает, и потому перегрузка по ним запрещена).
Типы полей и переменных заменяются их аналогами из C++. В большинстве случаев такие аналоги генерируются самим портером. Библиотечные (дотнетовские и некоторые другие) типы написаны нами на C++ в составе библиотеки, поставляемой вместе с портированными продуктами. var портируется в auto, кроме случаев, когда явное указание типа нужно, чтобы сгладить разницу в поведении.
Кроме того, ссылочные типы оборачиваются в SmartPtr (ранее я писал о том, что он по большей части следует семантике intrusive_ptr, но позволяет переключать режим ссылки — слабая или сильная — во время выполнения). Значимые типы подставляются как есть. Поскольку аргументы-типы могут быть как значимыми, так и ссылочными, они также подставляются как есть, но при инстанциировании ссылочные аргументы оборачиваются в SharedPtr (таким образом, List<int>
транслируется как List<int32_t>
, но List<Object>
становится List<SmartPtr<Object>>
. В некоторых исключительных случаях ссылочные типы портируются как значимые (например, наша реализация System::String написана на базе типа UnicodeString из ICU и оптимизирована для хранения на стеке).
Для примера портируем следующий класс:
public class Variables { public int m_int; private string m_string = new StringBuilder().Append("foobazz").ToString(); private Regex m_regex = new Regex("foo|bar"); public object Foo(int a, out int b) { b = a + m_int; return m_regex.Match(m_string); } }
После портирования он принимает следующий вид (я удалил код, не относящийся к делу):
class Variables : public System::Object { public: int32_t m_int; System::SharedPtr<System::Object> Foo(int32_t a, int32_t& b); Variables(); private: System::String m_string; System::SharedPtr<System::Text::RegularExpressions::Regex> m_regex; }; System::SharedPtr<System::Object> Variables::Foo(int32_t a, int32_t& b) { b = a + m_int; return m_regex->Match(m_string); } Variables::Variables() : m_int(0) , m_regex(System::MakeObject<System::Text::RegularExpressions::Regex>(u"foo|bar")) { this->m_string = System::MakeObject<System::Text::StringBuilder>()-> Append(u"foobazz")->ToString(); }
Управляющие структуры
Подобие основных управляющих структур сыграло нам на руку. Такие операторы, как if, else, switch, while, do-while, for, try-catch, return, break и continue в большинстве случаев переносятся как есть. Исключением в данном списке является разве что switch, требующий пары специальных обработок. Во-первых, C# допускает его использование со строковым типом — в C++ мы в этом случае генерируем последовательность if-else if. Во-вторых, относительно недавно добавилась возможность сопоставлять проверяемое выражение шаблону типа — что, впрочем, также легко разворачивается в последовательность ifов.
Интерес представляют конструкции, которых нет в C++. Так, оператор using даёт гарантию вызова метода Dispose() при выходе из контекста — в C++ мы эмулируем это поведение, создавая объект-часового на стеке, который вызывает нужный метод в своём деструкторе. Перед этим, правда, нужно перехватить исключение, вылетевшее из кода, бывшего телом using, и сохранить exception_ptr в поле часового — если Dispose() не бросит своё исключение, будет переброшено то, которое мы сохранили. Это как раз тот редкий случай, когда вылет исключения из деструктора оправдан и не является ошибкой. Блок finally транслируется по похожей схеме, только вместо метода Dispose() вызывается лямбда-функция, в которую портер обернул его тело.
Ещё один оператор, которого нет в C# и который мы вынуждены эмулировать, — это foreach. Изначально мы портировали его в эквивалентный while(), вызывающий метод MoveNext() у перечислителя, что универсально, но довольно медленно. Поскольку в большинстве своём плюсовые реализации контейнеров из .Net используют структуры данных STL, мы пришли к тому, чтобы там, где это возможно, использовать их оригинальные итераторы, конвертируя foreach в range-based for. В тех случаях, когда оригинальные итераторы недоступны (например, контейнер реализован на чистом C#), используются итераторы-обёртки, внутри себя работающие с перечислителями. Раньше за выбор нужного способа итерации отвечала внешняя функция, написанная с использованием техники SFINAE, сейчас мы близки к тому, чтобы иметь правильные версии методов begin-end во всех контейнерах (в т. ч. портированных).
Операторы unsafe и unchecked в нашем коде практически не используются, так что их портер попросту игнорирует.
Операторы
Как и в случае с управляющими структурами, большинство операторов (по крайней мере, арифметических, логических и присваивания) не требуют особой обработки. Тут, правда, есть тонкий момент: в C# порядок вычисления частей выражения детерминирован, тогда как в C++ в некоторых случаях возникает неопределённое поведение. Например, следующий портированный код ведёт себя неодинаково после компиляции разными инструментами:
auto offset32 = block[i++] + block[i++] * 256 + block[i++] * 256 * 256 + block[i++] * 256 * 256 * 256;
К счастью, подобные проблемы возникают достаточно редко. У нас есть планы научить портер бороться с такими моментами, но из-за сложности анализа, выявляющего выражения с побочными эффектами, это пока не было реализовано.
Впрочем, даже простейшие операторы требуют специальной обработки, когда они применяются к свойствам. Как было показано выше, свойства разбиваются на геттеры и сеттеры, и портеру приходится вставлять нужные вызовы в зависимости от контекста:
obj1.Property = obj2.Property; string s = GetObj().Property += "suffix";
obj1->set_Property(obj2->get_Property()); System::String s = System::setter_add_wrap(static_cast<MyClass*>(GetObj().GetPointer()), &MyClass::get_Property, &MyClass::set_Property, u"suffix")
В первой строке замена оказалась тривиальной. Во второй пришлось использовать обёртку setter_add_wrap, гарантирующую, что функция GetObj() будет вызвана всего один раз, а результат конкатенации вызова get_Property() и строкового литерала будет передан не только в метод set_Property() (который возвращает void), но и далее для использования в выражении. Тот же подход применяются при обращении к индексаторам.
Операторы C#, которых нет в C++ (as, is, typeof, default, ??, ?., и так далее), эмулируются при помощи библиотечных функций. В тех случаях, когда требуется избежать двойного вычисления аргументов (например, чтобы не разворачивать «GetObj()?.Invoke()» в «GetObj() ? GetObj().Invoke() : nullptr)», используется подход, подобный показанному выше.
Оператор доступа к члену (.
) в зависимости от контекста может заменяться на аналог из C++: на оператор разрешения области видимости (::
) или на «стрелку» (->
). При доступе к членам структур такая замена не требуется.
Исключения
Эмуляция поведения C# в аспекте работы с исключениями является весьма нетривиальной. Дело в том, что в C# и в C++ исключения ведут себя по-разному:
-
В C# исключения создаются на куче и удаляются сборщиком мусора.
-
В C++ исключения в разные моменты копируются между стеком и выделенной для них областью памяти.
Здесь возникает противоречие. Если транслировать типы исключений C# как ссылочные, работая с ними по голым указателям (throw new ArgumentException
), это приведёт к утечкам памяти (или большим проблемам с определением точек их удаления). Если транслировать их как ссылочные, но владеть ими по умному указателю (throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>())
), исключение будет невозможно перехватить по его базовому типу (потому что SharedPtr<ArgumentException> не наследует SharedPtr<Exception>). Если же размещать объекты исключений на стеке, они будут корректно перехватываться по базовому типу, но при сохранении в переменную базового типа информация о конечном типе будет усекаться (к сожалению, у нас есть даже код, хранящий коллекции исключений, так что это не пустая тревога).
Для решения этой проблемы мы создали специальный тип умных указателей ExceptionWrapper. Его ключевая особенность заключается в том, что, если класс ArgumentException наследуется от Exception, то и ExceptionWrapper<ArgumentException> наследуется от ExceptionWrapper<Exception>. Экземпляры ExceptionWrapper используются для управления временем жизни экземпляров классов исключений, при этом усечение типа ExceptionWrapper не приводит к усечению типа связанного Exception. За выброс исключений отвечает виртуальный метод, переопределяемый наследниками Exception, который создаёт ExceptionWrapper, параметризованный конечным типом исключения, и выбрасывает его. Виртуальность позволяет выбросить правильный тип исключения, даже если тип ExceptionWrapper был усечён ранее, а связь между объектом исключения и ExceptionWrapper предотвращает утечку памяти.
Создание объектов и инициализация
Для создания объектов ссылочных типов, кроме нескольких специальных случаев, мы используем функцию MakeObject (аналог std::make_shared), которая создаёт объект оператором new и сразу оборачивает его в SharedPtr. Кроме того, MakeObject инкапсулирует некую сервисную логику. Использование этой функции позволило избежать проблем, привносимых голыми указателями, однако породило проблему прав доступа: поскольку она находится вне всех классов, она не имела доступа к закрытым конструкторам, даже будучи вызванной из самих классов или их друзей. Объявление этой функции в качестве друга классов с непубличными конструкторами эффективно открывало эти конструкторы для всех контекстов. В результате внешняя версия этой функции была ограничена использованием с публичными конструкторами, а для непубличных конструкторов были добавлены статические методы MakeObject, имеющие тот же уровень доступа и те же аргументы, что и проксируемый конструктор.
Литералы часто приходится менять при портировании: так, @"C:\Users"
превращается в u"C:\\Users"
, а 15L
— в INT64_C(15)
.
Программисты C# часто используют инициализаторы свойств в составе выражения создания объекта. Соответствующий синтаксис приходится оборачивать в лямбда-функции, поскольку в противном случае записать инициализаторы в составе одного выражения не получается:
Foo(new MyClass() { Property1 = "abc", Property2 = 1, Field1 = 3.14 });
Foo([&]{ auto tmp_0 = System::MakeObject<MyClass>(); tmp_0->set_Property1(u"abc"); tmp_0->set_Property2(1); tmp_0->Field1 = 3.14; return tmp_0; }());
Вызовы, делегаты и анонимные методы
Вызовы методов переносятся как есть. При наличии перегруженных методов иногда приходится явно приводить типы аргументов, поскольку правила разрешения перегрузок в C++ отличаются от таковых в C#. Рассмотрим, например, следующий код:
class MyClass<T> { public void Foo(string s) { } public void Bar(string s) { } public void Bar(bool b) { } public void Call() { Foo("abc"); Bar("def"); } }
После портирования он выглядит следующим образом:
template<typename T> class MyClass : public System::Object { public: void Foo(System::String s) { ASPOSE_UNUSED(s); } void Bar(System::String s) { ASPOSE_UNUSED(s); } void Bar(bool b) { ASPOSE_UNUSED(b); } void Call() { Foo(u"abc"); Bar(System::String(u"def")); } };
Обратите внимание: вызовы методов Foo и Bar внутри метода Call записаны по-разному. Это связано с тем, что без явного вызова конструктора String была бы вызвана перегрузка Bar, принимающая bool, т. к. такое приведение типа имеет более высокий приоритет по правилам C++. В случае метода Foo такой неоднозначности нет, и портер генерирует более простой код.
Ещё один пример, когда C# и C++ ведут себя по-разному — это разворачивание шаблонов. В C# подстановка типов-параметров производится уже в рантайме и не влияют на разрешение вызовов внутри обобщённых методов. В C++ подстановка аргументов шаблонов происходит в момент вызова, так что поведение C# приходится эмулировать. Например, рассмотрим следующий код:
class GenericMethods { public void Foo<T>(T value) { } public void Foo(string s) { } public void Bar<T>(T value) { Foo(value); } public void Call() { Bar("abc"); } }
class GenericMethods : public System::Object { public: template <typename T> void Foo(T value) { ASPOSE_UNUSED(value); } void Foo(System::String s); template <typename T> void Bar(T value) { Foo<T>(value); } void Call(); }; void GenericMethods::Foo(System::String s) { } void GenericMethods::Call() { Bar<System::String>(u"abc"); }
Здесь стоит обратить внимание на явное указание аргументов шаблона при вызове Foo и Bar. В первом случае это необходимо, потому что иначе при инстанциировании версии для T=System::String будет вызвана нешаблонная версия, что отличается от поведения C#. Во втором случае аргумент нужен, поскольку в противном случае он будет выведен на основе типа строкового литерала. Вообще, явно указывать аргументы шаблона портеру приходится почти всегда, чтобы избежать неожиданного поведения.
Во многих случаях портеру приходится генерировать явные вызовы там, где их нет в C# — это касается, прежде всего, методов доступа к свойствам и индексаторам. Вызовы конструкторов ссылочных типов оборачиваются в MakeObject, как было показано выше.
В .Net встречаются методы, которые поддерживают перегрузку по числу и типу аргументов через синтаксис params, через указание object в качестве типа аргумента, либо через то и другое сразу — например, подобные перегрузки есть у StringBuilder.Append() и у Console.WriteLine(). Прямой перенос таких конструкций показывает плохую производительность из-за боксирования и создания временных массивов. В таких случаях мы добавляем перегрузку, принимающую переменное число аргументов произвольных типов с использованием вариативных шаблонов, и заставляем портер транслировать аргументы как есть, без приведений типов и объединений в массивы. В результате удаётся поднять производительность таких вызовов.
Делегаты транслируются в специализации шаблона MulticastDelegate, который, как правило, содержит внутри себя контейнер экземпляров std::function. Их вызов, хранение и присваивание осуществляются тривиально. Анонимные методы превращаются в лямбда-функции.
При создании лямбда-функций требуется продлить время жизни захваченных ими переменных и аргументов, что усложняет код, поэтому портер делает это лишь там, где у лямбда-функции есть шансы пережить окружающий контекст. Это поведение (продление времени жизни переменных, захват по ссылке или по значению) также может контролироваться вручную для получения более оптимального кода.
Тесты
Одной из сильных сторон нашего фреймворка является способность транслировать не только исходный код, но и модульные тесты к нему. Поскольку исходные продукты обладают хорошим покрытием, мы имеем возможность тестировать транслированный код «почти бесплатно».
Программисты C# используют фреймворки NUnit и xUnit. Портер переводит соответствующие тестовые примеры на GoogleTest, заменяя синтаксис проверок и вызывая методы, помеченные флагом Test или Fact, из соответствующих тестовых функций. Поддерживаются как тесты без аргументов, так и входные данные вроде TestCase или TestCaseData. Пример портирования тестового класса приведён ниже.
[TestFixture] class MyTestCase { [Test] public void Test1() { Assert.AreEqual(2*2, 4); } [TestCase("123")] [TestCase("abc")] public void Test2(string s) { Assert.NotNull(s); } }
class MyTestCase : public System::Object { public: void Test1(); void Test2(System::String s); }; namespace gtest_test { class MyTestCase : public ::testing::Test { protected: static System::SharedPtr<::ClassLibrary1::MyTestCase> s_instance; public: static void SetUpTestCase() { s_instance = System::MakeObject<::ClassLibrary1::MyTestCase>(); }; static void TearDownTestCase() { s_instance = nullptr; }; }; System::SharedPtr<::ClassLibrary1::MyTestCase> MyTestCase::s_instance; } // namespace gtest_test void MyTestCase::Test1() { ASSERT_EQ(2 * 2, 4); } namespace gtest_test { TEST_F(MyTestCase, Test1) { s_instance->Test1(); } } // namespace gtest_test void MyTestCase::Test2(System::String s) { ASSERT_FALSE(System::TestTools::IsNull(s)); } namespace gtest_test { using MyTestCase_Test2_Args = System::MethodArgumentTuple<decltype( &ClassLibrary1::MyTestCase::Test2)>::type; struct MyTestCase_Test2 : public MyTestCase, public ClassLibrary1::MyTestCase, public ::testing::WithParamInterface<MyTestCase_Test2_Args> { static std::vector<ParamType> TestCases() { return { std::make_tuple(u"123"), std::make_tuple(u"abc"), }; } }; TEST_P(MyTestCase_Test2, Test) { const auto& params = GetParam(); ASSERT_NO_FATAL_FAILURE(s_instance->Test2(std::get<0>(params))); } INSTANTIATE_TEST_SUITE_P(, MyTestCase_Test2, ::testing::ValuesIn(MyTestCase_Test2::TestCases())); } // namespace gtest_test
Проблемы
При трансляции кода мы часто сталкиваемся с разного рода проблемами. Ниже я перечислю наиболее типичные из них и расскажу о способах их решения.
-
Синтаксис C# не имеет прямых аналогов на C++. Это относится, например, к операторам using и yeild.
В таких случаях нам приходится писать довольно сложный код для эмуляции поведения оригинального кода — как в портере, так и в библиотеке — либо отказываться от поддержки таких конструкций. -
Конструкции C# не переводятся на C++ в рамках принятых нами правил портирования. Например, в исходном коде присутствуют виртуальные обобщённые методы, или конструкторы, использующие виртуальные функции.
В подобных случаях нам не остаётся ничего, кроме как переписывать такой проблемный код в терминах, допускающих портирование на C#. К счастью, обычно подобные конструкции составляют относительно небольшой объём кода. -
Работа кода C# зависит от окружения, специфичного для .Net. Это включает, например, ресурсы, рефлексию, динамическое подключение сборок и импорт функций.
В таких случаях нам, как правило, приходится эмулировать соответствующие механизмы. Это включает в себя поддержку ресурсов (которые внедряются в сборку в виде статических массивов и затем читаются через специализированные реализации потоков) и рефлексию. С другой стороны, очевидно, что напрямую подключать сборки .Net к коду C++ или импортировать функции из динамических библиотек Windows при выполнении на другой платформе мы не можем — подобный код приходится урезать либо переписывать. -
Работа кода полагается на классы и методы .Net, которые не поддержаны в нашей библиотеке.
В этом случае мы имплементируем соответствующее поведение — как правило, используя реализации из сторонних библиотек, лицензии которых не запрещают использование в составе коммерческого продукта. -
Работа библиотечного кода отличается от работы оригинальных классов из .Net.
В каких-то случаях речь идёт о простых ошибках в имплементации — как правило, их несложно исправить. Гораздо хуже дело обстоит, когда разница в поведении лежит на уровне подсистем, используемых библиотечным кодом. Например, многие наши библиотеки активно используют классы из библиотеки System.Drawing, построенной на GDI+. Версии этих классов, разработанных нами для C++, используют Skia в качестве графического движка. Поведение Skia зачастую отличается от такового в GDI+, особенно под Linux, и на то, чтобы добиться одинаковой отрисовки, нам приходится тратить значительные ресурсы. Аналогично, libxml2, на которой построена наша реализация System::Xml, ведёт себя в иных случаях не так, и нам приходится патчить её или усложнять свои обёртки. -
Портированный код порой работает медленнее оригинала.
Программисты на C# оптимизируют свой код под те условия, в которых он выполняется. В то же время, многие структуры начинают работать медленнее в необычном для себя окружении. Например, создание большого количества мелких объектов в C# обычно работает быстрее, чем в C++, из-за иной схемы работы кучи (даже с учётом сборки мусора). Динамическое приведение типов в C++ также несколько медленнее. Подсчёт ссылок при копировании указателей — ещё один источник накладных расходов, которых нет в C#. Наконец, использование вместо встроенных, оптимизированных концепций C++ (итераторы) переводных с C# (перечислители) также замедляет работу кода.
Способ устранения бутылочных горлышек во многом зависит от ситуации. Если библиотечный код сравнительно легко оптимизировать, то сохранить поведение портированных концепций и в то же время оптимизировать их работу в чуждом окружении порой не так-то просто. -
Портированный код не соответствует духу C++. Например, в публичном API присутствуют методы, принимающие SharedPtr<Object>, у контейнеров отсутствуют итераторы, методы для работы с потоками принимают System::IO::Stream вместо istream, ostream или iostream, и так далее.
Мы последовательно расширяем портер и библиотеку таким образом, чтобы нашим кодом было удобно пользоваться программистам C++. Например, портер уже умеет генерировать методы begin-end и перегрузки, работающие со стандартными потоками. -
Портированный код обнажает наши алгоритмы. В заголовочные файлы попадают типы и имена закрытых полей, а также полный код шаблонных методов. Эта информация обычно обфусцируется при выпуске релизов для .Net.
Мы стараемся исключить лишнюю информацию при помощи сторонних утилит, а также специальными режимами работы самого портера, однако это не всегда возможно. Например, удаление закрытых статических полей и невиртуальных методов не влияет на работу клиентского кода, однако удалить или переименовать виртуальные методы без потери функциональности невозможно. Поля могут быть переименованы, а их тип — заменён на заглушку того же размера, при условии, что конструкторы и деструкторы экспортированы из кода, собранного с полными заголовочными файлами. В то же время, как-либо скрыть код публичных шаблонных методов не представляется возможным.
Планы развития проекта
Релизы для языка C++, полученные с использованием нашего фреймворка, успешно выпускаются уже несколько лет. Если в начале мы выпускали урезанные версии продуктов, то в настоящее время удаётся поддерживать куда более полную функциональность.
В то же время, у нас остаётся достаточно много пространства для исправлений и улучшений: трекер забит задачами на несколько лет вперёд. Это касается как поддержки ранее пропущенных синтаксических конструкций и частей библиотеки, так и повышения удобства работы с портером.
Помимо решения текущих проблем и плановых улучшений, мы заняты переводом портеров с C# на Java и C++ на современный синтаксический анализатор (Roslyn). Это небыстрый процесс, ведь количество случаев, которые продукт должен обрабатывать, весьма велико. Мы начинаем с поддержки наиболее общих структур, а затем переходим ко всё более редким случаям. Для этого у нас есть большое количество тестов: тесты на вывод портера, тесты на вывод портированного приложения, тесты в портированных проектах. В какой-то момент происходит переход от специально подготовленных тестов к тестированию на реальных продуктах, содержащих сотни тысяч и даже десятки миллионов строк кода, что неизбежно вскрывает какие-то недоработки.
Кроме того, у нас есть некоторые планы по развитию ресурсов и сообществ, посвящённых трансляции кода между языками высокого уровня. Это довольно интересная тема с относительно небольшим количеством полнофункциональных решений, и мы обладаем опытом, которым готовы делиться. Один из возможных сценариев предполагает перевод наших фреймворков в опенсорс. Пользователями подобных решений могут стать, прежде всего, разработчики библиотек для .Net, которые, как и мы, хотят выпускаться под другие платформы с минимальными затратами.
Наконец, мы думаем о том, чтобы замахнуться на расширение числа поддерживаемых языков — как целевых, так и исходных. Адаптировать решения, основанные на Roslyn, для чтения кода VB будет относительно легко — тем более, что библиотеки для C++ и Java уже готовы. С другой стороны, подход, который мы применили для поддержки Python, во многом проще, и по аналогии можно поддержать иные скриптовые языки — например, PHP.
ссылка на оригинал статьи https://habr.com/ru/post/550286/
Добавить комментарий