Правила трансляции кода с C# на C++: создание объектов и вызовы методов

Иногда, поведение кода, написанного на C# и C++, может различаться. Давайте подробнее рассмотрим, как CodePorting.Translator Cs2Cpp справляется с такими различиями и обеспечивает корректность перевода кода. Также мы узнаем, как происходит конвертация модульных тестов.

Создание объектов и инициализация

Для создания объектов ссылочных типов мы используем функцию MakeObject (аналог std::make_shared), которая создаёт объект оператором new и сразу оборачивает его в SharedPtr. Использование этой функции позволило избежать проблем, привносимых голыми указателями, однако породило проблему прав доступа: поскольку она находится вне всех классов, она не имела доступа к закрытым конструкторам. Объявление этой функции в качестве друга классов с непубличными конструкторами сделало бы возможным создание таких классов во всех контекстах. В результате внешняя версия этой функции была ограничена использованием с публичными конструкторами, а для непубличных конструкторов были добавлены статические методы MakeObject, имеющие тот же уровень доступа и те же аргументы, что и проксируемый конструктор.

Программисты 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)
    {
        CODEPORTING_UNUSED(s);
    }
    void Bar(System::String s)
    {
        CODEPORTING_UNUSED(s);
    }
    void Bar(bool b)
    {
        CODEPORTING_UNUSED(b);
    }
    void Call()
    {
        Foo(u"abc");
        Bar(System::String(u"def"));
    }
};

Обратите внимание: вызовы методов Foo и Bar внутри метода Call записаны по-разному. Это связано с тем, что без явного вызова конструктора System::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)
    {
        CODEPORTING_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# в аспекте работы с исключениями является весьма нетривиальной. Дело в том, что в 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 предотвращает утечку памяти.

Тесты

Одной из сильных сторон нашего фреймворка является способность транслировать не только исходный код, но и модульные тесты к нему.

Программисты 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

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