22 марта 2024
В этой статье мы рассмотрим, как наш транслятор конвертирует члены классов, переменные, поля, операторы и управляющие структуры C#. Также коснемся вопроса использования библиотеки поддержки транслятора для корректной конвертации типов .NET Framework в С++.
Методы классов ложатся на C++ напрямую. Это также касается статических методов и конструкторов. В некоторых случаях может появляться дополнительный код – например, чтобы эмулировать вызовы статических конструкторов. Методы расширения и операторы транслируются в статические методы и вызываются явно. Финализаторы становятся деструкторами.
Экземплярные поля C# становятся экземплярными полями C++. Статические поля также остаются без изменений, за исключением случаев, когда важен порядок инициализации – это реализуется трансляцией таких полей в виде синглтонов.
Свойства разбиваются на метод-геттер и метод-сеттер, или что-то одно, если второй метод отсутствует. Для автоматических свойств к ним добавляется также закрытое поле значения. Статические свойства распадаются на статический геттер и сеттер. Индексаторы обрабатываются по той же логике.
События транслируются в поля, тип которых соответствует нужной специализации System::Event
. Трансляция в виде трёх методов (add
, remove
и invoke
) была бы более правильной и, к тому же, позволила бы поддержать абстрактные и виртуальные события. Возможно, в будущем мы придём к такой модели, однако на данный момент вариант с классом Event
полностью покрывает наши потребности.
Следующий пример иллюстрирует описанные выше правила:
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;
}
Результат трансляции в C++ (незначащая часть кода удалена):
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
аргументы становятся ссылочными (благо, перегрузка по ним запрещена).
Типы полей и переменных заменяются их аналогами из C++. В большинстве случаев, такие аналоги генерируются самим транслятором из исходного кода C#. Библиотечные типы, включающие типы .NET Framework и некоторые другие, написаны нами на C++ и входят в состав библиотеки поддержки транслятора, поставляемой вместе с конвертированными продуктами. var
транслируется в auto
, кроме случаев, когда явное указание типа нужно, чтобы сгладить разницу в поведении.
Кроме того, ссылочные типы оборачиваются в SmartPtr
. Значимые типы подставляются как есть. Поскольку аргументы-типы могут быть как значимыми, так и ссылочными, они также подставляются как есть, но при инстанциировании ссылочные аргументы оборачиваются в 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()
у перечислителя, что универсально, но довольно медленно. Поскольку в большинстве своём C++ реализации контейнеров из .NET используют структуры данных STL, мы пришли к тому, чтобы там где это возможно, использовать их оригинальные итераторы, конвертируя foreach
в range-based for
. В тех случаях, когда оригинальные итераторы недоступны (например, контейнер реализован на чистом C#), используются итераторы-обёртки, внутри себя работающие с перечислителями. Раньше за выбор нужного способа итерации отвечала внешняя функция, написанная с использованием техники SFINAE, сейчас мы близки к тому, чтобы иметь правильные версии методов begin
-end
во всех контейнерах, в том числе транслированных.
Как и в случае с управляющими структурами, большинство операторов (по крайней мере, арифметических, логических и присваивания) не требуют особой обработки. Хотя, тут есть тонкий момент: в 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++: на оператор разрешения области видимости (::
) или на “стрелку” (->
). При доступе к членам структур такая замена не требуется.