22 ноября 2024

Проблемы конвертации C# в C++ и наши планы по улучшению транслятора

Создание эффективного транслятора кода между такими языками, как C# и C++, представляет собой сложную задачу. При разработке инструмента CodePorting.Translator Cs2Cpp возникло множество проблем, связанных с различиями в синтаксисе, семантике и парадигмах программирования этих двух языков. В этой статье будут рассмотрены ключевые трудности, с которыми мы столкнулись, а также возможные способы их преодоления.

Проблемы при трансляции кода и способы их преодоления

  1. Синтаксис C# не имеет прямых аналогов на C++.

Это относится, например, к операторам using и yield:

using (var resource = new Resource())
{
    // Использование ресурса
}
public IEnumerable<int> GetAllNumbers()
{
    for (int i = 0; i < int.MaxValue; i++)
    {
        yield return i;
    }
}

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

  1. Конструкции C# не переводятся на C++ в рамках принятых нами правил конвертации.

Например, в исходном коде присутствуют виртуальные обобщённые методы, или конструкторы, использующие виртуальные функции:

public class A
{
    public virtual T GenericMethod<T>(T param)
    {
        return param;
    }
}
public class A
{
    public A()
    {
        VirtualMethod();
    }

    public virtual void VirtualMethod()
    {
    }
}

public class B : A
{
    public override void VirtualMethod()
    {
    }
}

В подобных случаях нам не остаётся ничего, кроме как переписывать такой проблемный код в терминах, допускающих конвертацию на C#. К счастью, такие случаи встречаются редко и касаются небольших фрагментов кода.

  1. Работа кода C# зависит от окружения, специфичного для .NET.

Это включает ресурсы, рефлексию, динамическое подключение сборок и импорт функций:

static void Main()
{
    var rm = new ResourceManager("MyApp.Resources", typeof(Program).Assembly);
    var value = rm.GetString("MyResource");
}
static void Main()
{
    var type = typeof(MyClass);
    var method = type.GetMethod("MyMethod");
    var result = method.Invoke(null, null);
    Console.WriteLine(result);
}

public class MyClass
{
    public static string MyMethod()
    {
        return "Hello, World!";
    }
}
static void Main()
{
    var assembly = Assembly.Load("MyDynamicAssembly");
    var type = assembly.GetType("MyDynamicAssembly.MyClass");
    var instance = Activator.CreateInstance(type);
    var method = type.GetMethod("MyMethod");
    method.Invoke(instance, null);
}

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

  1. Работа кода полагается на классы и методы .NET, которые не поддержаны в нашей библиотеке.

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

  1. Работа библиотечного кода отличается от работы оригинальных классов из .NET.

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

Например, многие наши библиотеки активно используют классы из библиотеки System.Drawing, построенной на GDI+. Версии этих классов, разработанных нами для C++, используют Skia в качестве графического движка. Поведение Skia зачастую отличается от такового в GDI+, особенно под Linux, и на то, чтобы добиться одинаковой отрисовки, нам приходится тратить значительные ресурсы. Аналогично, libxml2, на которой построена наша реализация System::Xml, ведёт себя в иных случаях не так, и нам приходится патчить её или усложнять свои обёртки.

  1. Транслированный код порой работает медленнее оригинала.

Программисты на C# оптимизируют свой код под те условия, в которых он выполняется. Однако многие структуры начинают работать медленнее в необычном для себя окружении.

Например, создание большого количества мелких объектов в C# обычно работает быстрее, чем в C++, из-за иной схемы работы кучи (даже с учётом сборки мусора). Динамическое приведение типов в C++ также несколько медленнее. Подсчёт ссылок при копировании указателей — ещё один источник накладных расходов, которых нет в C#. Наконец, использование вместо встроенных, оптимизированных концепций C++ (итераторы) переводных с C# (перечислители) также замедляет работу кода.

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

  1. Транслированный код не соответствует духу C++.

Например, в публичном API присутствуют методы, принимающие SharedPtr<Object>, у контейнеров отсутствуют итераторы, методы для работы с потоками принимают System::IO::Stream вместо istream, ostream или iostream, и так далее.

Мы последовательно расширяем транслятор и библиотеку, чтобы нашим кодом было удобно пользоваться программистам C++. Например, транслятор уже умеет генерировать методы begin-end и перегрузки, работающие со стандартными потоками.

  1. Транслированный код обнажает наши алгоритмы.

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

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

Планы развития проекта

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

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

Помимо решения текущих проблем и плановых улучшений, мы работаем над переводом транслятора на современный синтаксический анализатор Roslyn. До недавнего времени мы использовали анализатор NRefactory, который был ограничен поддержкой версий языка C# до 5.0. Переход на Roslyn позволит нам поддерживать современные конструкции языка C#, такие как:

  • сопоставление с образцом (pattern matching)
  • члены с телом-выражением (expression-bodied members)
  • типы ссылочных значений, допускающие null (nullable reference types)
  • и многие другие.

Наконец, мы планируем расширить число поддерживаемых языков — как целевых, так и исходных. Адаптировать решения, основанные на Roslyn, для чтения кода VB будет относительно легко, особенно учитывая, что библиотеки для C++ и Java уже готовы. С другой стороны, подход, который мы применили для поддержки Python, во многом проще, и по аналогии можно поддержать и другие скриптовые языки, такие как PHP.

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

Связанные видео

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