Как мы автоматизировали конвертирование проектов C# в C++: Часть 1

Большинство продуктов Aspose, позволяющих манипулировать протоколами и файлами популярных форматов, изначально были разработаны для .NET. В то же время бизнес-приложения для файловых форматов работают в разных средах. В этой статье будет описано, как нам удалось наладить выпуск продуктов Aspose для C++, создав среду для перевода кода с C#. Сохранение функциональности версий .NET для этих продуктов было технически сложной задачей.

Необходимую инфраструктуру, обеспечивающую трансляцию кода между языками и эмуляцию функций библиотеки .NET, мы написали сами, решив, таким образом, задачу, которая обычно считается академической. Это позволило начать выпуск ежемесячных релизов .NET продуктов для языка C++, получая код каждого релиза из соответствующей версии кода C#. При этом тесты, которыми был покрыт оригинальный C# код, конвертируются вместе с ним, и позволяют контролировать работоспособность получившегося решения, наравне со специально написанными тестами на C++.

Предыстория

Успех транслятора кода с C# на C++ основан на успешном опыте команды CodePorting в реализации автоматизированного перевода кода с C# в Java. Созданная платформа преобразовывала классы C# в классы Java, одновременно корректно заменяя вызовы системных библиотек.

Были рассмотрены различные подходы к разработке платформы. Создание чистых Java-версий с нуля потребовало бы слишком много ресурсов. Одним из вариантов было маршалинг вызовов из Java-кода в среду .NET, но это ограничило бы набор программных платформ, которые мы могли бы поддерживать в будущем. Тогда .NET был доступен только на Windows. Маршалинг вызовов удобен при редких вызовах с широко используемыми типами данных. Однако он становится обременительным при работе с большим количеством объектов и пользовательскими типами данных.

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

Было решено разбить решение на две части:

  • Транслятор — осуществлял бы преобразование синтаксиса исходного кода C# в Java, попутно заменяя типы и методы .NET их аналогами из библиотек Java.
  • Библиотека — эмулировала бы работу тех частей библиотеки .NET, для которых установить прямое соответствие с Java затруднительно или невозможно, привлекая для этого доступные сторонние компоненты.

В пользу принципиальной реализуемости подобного плана говорило следующее:

  1. Идеологически языки C# и Java достаточно похожи – как минимум, структурой типов и организацией работы с памятью.
  2. Речь шла о конвертировании библиотек, необходимости в переносе GUI не было.
  3. Данные библиотеки содержали, в основном, бизнес-логику и низкоуровневые файловые операции, а самыми сложными их зависимостями зачастую были System.Net и System.Drawing.
  4. Библиотеки изначально разрабатывались так, чтобы работать под максимально широким спектром версий .NET (как Framework, так и Standard и даже Xamarin), так что различия платформ можно было в большой степени игнорировать.

Мы не будем углубляться в детали реализации транслятора C# в Java, так как это потребует отдельной статьи. Подводя итог, можно сказать, что благодаря созданному транслятору кода, конвертация продуктов с C# на Java стала обычной практикой компании. Транслятор же прошёл эволюцию от простой утилиты, преобразующей текст по установленным правилам, до сложного кодогенератора, работающего с AST-представлением исходного кода.

Успех транслятора C# на Java обусловил желание проводить дальнейшую экспансию на новые для себя рынки. Поэтому был поставлен вопрос о выпуске продуктов для языка C++ по аналогичному сценарию.

Постановка задачи

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

  1. Эмуляция окружения .NET в той мере, в какой это нужно для работы транслированного кода.
  2. Адаптация транслированного кода C# к реалиям C++ (структура типов, управление памятью, прочий сервисный код).
  3. Сглаживание различий между «переписанным C#» и собственно C++, чтобы упростить использование транслированного кода программистами, не знакомыми с парадигмами .NET.

Многие читатели сразу спросят о том, почему было не использовать существующие реализации вроде Mono. К тому были свои причины:

  1. Привлечением такой готовой библиотеки удалось бы удовлетворить только первому требованию, но не второму и не третьему.
  2. Реализация Mono написана на C# и, стало быть, полагается на рантайм, от которого мы как раз и отказались.
  3. Адаптация стороннего кода под наши реалии (API, система типов, система управления памятью, оптимизации кода C++, и так далее) заняла бы время, сопоставимое с разработкой собственного компонента.
  4. Потребности наших продуктов существенно уже, чем полная реализация .NET, однако отделить нужные классы и методы от ненужных при прямом переносе кода во многих случаях было бы сложно. Таким образом, пришлось бы потратить существенное время на поддержку тех классов и вызовов, которые не используются в коде продуктов.

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

В итоге было принято решение о разработке библиотеки как набора адаптеров, предоставляющих доступ к функциям, уже реализованным в сторонних библиотеках, но через .NET-подобный API (по аналогии с Java). Это позволило бы сократить работу и использовать готовые, уже оптимизированные, компоненты C++.

Важное требование к фреймворку заключалось в том, что транслированный код должен был быть способен работать в составе пользовательских приложений (поскольку речь шла о библиотеках). Это означало, что модель управления памятью должна была быть приведена к понятной программистам C++, так как мы не можем заставить произвольный клиентский код выполняться в среде с уборкой мусора. В качестве компромиссной модели было выбрано использование умных указателей. О том, каким образом нам удалось обеспечить такой переход, в частности, решить проблему циклических ссылок, будет рассказано в отдельной статье.

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

При этом учитывался опыт работы с Java-транслятором: первоначально, когда тот был лишь простым инструментом для преобразования синтаксисов, естественным образом возникла практика ручной доработки транслированного кода. В краткосрочной перспективе, ориентированной на скорейший выпуск продуктов, это было актуально, поскольку позволяло ускорить процесс разработки, однако в долгосрочном разрезе это существенно увеличило издержки по подготовке каждой версии к выпуску из-за необходимости править каждую ошибку конвертации каждый раз, когда она возникает.

Разумеется, эта сложность была управляемой – как минимум, путём переноса в итоговый Java-код только патчей, вычисляемых как разность между выводом транслятора за две последующие ревизии кода C#. Такой подход позволял исправлять каждую транслированную строку лишь один раз, и в дальнейшем использовать уже доработанный код там, где изменений не вносилось. Тем не менее, при разработке С++ транслятора была поставлена цель избавиться от этапа исправления конвертированного кода, вместо этого исправляя сам фреймворк. Таким образом, каждая, сколь угодно редкая ошибка трансляции исправлялась бы один раз – в коде транслятора, и этот фикс относился бы ко всем будущим релизам всех конвертируемых продуктов.

Связанные статьи