C#에서 C++로 코드를 번역하는 규칙: 객체 생성 및 메소드 호출

C#과 C++로 작성된 코드의 동작이 다를 때가 있습니다. CodePorting.Translator Cs2Cpp가 이러한 차이를 어떻게 처리하고 코드 번역의 정확성을 보장하는지 자세히 살펴보겠습니다. 또한 단위 테스트의 변환 방법도 알아보겠습니다.

객체 생성 및 초기화

참조 타입 객체를 생성하기 위해 new 연산자를 사용하여 객체를 생성하고 즉시 SharedPtr로 래핑하는 MakeObject 함수( std::make_shared에 상응)를 사용합니다. 이 함수를 사용함으로써 원시 포인터로 인해 도입된 문제를 피할 수 있었지만, 모든 클래스 외부에 있기 때문에 비공개 생성자에 대한 접근 권한 문제가 발생했습니다. 비공개 생성자가 있는 클래스의 친구로 이 함수를 선언하면 모든 상황에서 그러한 클래스를 생성할 수 있었을 것입니다. 결과적으로, 이 함수의 외부 버전은 공개 생성자와 함께 사용하는 것으로 제한되었고, 비공개 생성자에 대해서는 동일한 접근 수준과 프록시된 생성자와 동일한 인수를 가진 정적 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"));
    }
};

참고: Call 메소드 내부의 FooBar에 대한 메소드 호출은 다르게 작성됩니다. 이는 System::String 생성자에 대한 명시적인 호출이 없으면 C++ 규칙에 따라 bool을 받아들이는 Bar 오버로드가 호출되기 때문입니다. 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");
}

여기서 중요한 것은 FooBar를 호출할 때 템플릿 인수를 명시적으로 지정하는 것입니다. 첫 번째 경우에는 그렇지 않으면 T=System::String 버전을 인스턴스화할 때 C# 동작과 다른 비템플릿 버전이 호출될 것이기 때문에 필요합니다. 두 번째 경우에는 그렇지 않으면 문자열 리터럴의 유형에 기반하여 추론될 것이므로 인수가 필요합니다. 일반적으로 번역기는 예기치 않은 동작을 피하기 위해 거의 항상 템플릿 인수를 명시적으로 지정해야 합니다.

많은 경우에 번역기는 C#에 없는 명시적 호출을 생성해야 합니다. 이는 주로 속성 및 인덱서에 대한 액세스 메소드와 관련이 있습니다. 참조 유형의 생성자 호출은 위에 표시된 것처럼 MakeObject로 래핑됩니다.

.NET에는 params 구문을 통해 인수의 수와 유형에 따라 오버로딩을 지원하는 메소드가 있으며, 인수 유형으로 object를 지정하거나 둘 다 한 번에 지정합니다. 예를 들어, StringBuilder.Append()Console.WriteLine()에 대한 그러한 오버로드가 있습니다. 이러한 구조의 직접 전송은 박싱과 임시 배열의 생성으로 인해 성능이 저하됩니다. 이러한 경우에는 가변 템플릿을 사용하여 임의 유형의 인수를 여러 개 받아들이는 오버로드를 추가하고 인수를 그대로 번역하며, 타입 캐스트와 배열로의 병합 없이 번역합니다. 그 결과, 이러한 호출의 성능이 향상됩니다.

대리자는 내부에 std::function 인스턴스의 컨테이너를 일반적으로 포함하는 MulticastDelegate 템플릿의 특수화로 번역됩니다. 그들의 호출, 저장 및 할당은 단순하게 수행됩니다. 익명 메소드는 람다 함수로 변환됩니다.

람다 함수를 생성할 때는 캡처된 변수와 인수의 수명을 연장해야 하며, 이는 코드를 복잡하게 만들기 때문에 번역기는 람다 함수가 주변 컨텍스트를 초과하여 살아남을 가능성이 있는 경우에만 이 작업을 수행합니다. 이러한 동작(변수의 수명 연장, 참조 또는 값에 의한 캡처)은 더 최적의 코드를 얻기 위해 수동으로 제어할 수도 있습니다.

예외

예외 처리와 관련하여 C#의 동작을 에뮬레이트하는 것은 결코 사소한 일이 아닙니다. 이는 C#과 C++에서 예외의 동작이 다르기 때문입니다:

  • C#에서는 예외가 힙에 생성되고 가비지 컬렉터에 의해 삭제됩니다.
  • C++에서는 예외가 스택과 전용 메모리 영역 사이에서 다른 시간에 복사됩니다.

이는 모순을 제시합니다. C# 예외 유형이 참조 유형으로 번역되고 원시 포인터(throw new ArgumentException)를 통해 작업하는 경우, 메모리 누수 또는 삭제 지점을 결정하는 데 심각한 문제가 발생할 수 있습니다. 참조 유형으로 번역되지만 스마트 포인터(throw SharedPtr<ArgumentException>(MakeObject<ArgumentException>()))를 통해 소유되는 경우, SharedPtr<ArgumentException>SharedPtr<Exception>에서 상속되지 않기 때문에 기본 유형으로 예외를 잡을 수 없습니다. 그러나 예외 객체가 스택에 배치되면 기본 유형으로 올바르게 잡히지만, 기본 유형의 변수에 저장되면 최종 유형에 대한 정보가 잘립니다.

이 문제를 해결하기 위해 ExceptionWrapper라는 특별한 유형의 스마트 포인터를 만들었습니다. 그 주요 기능은 클래스 ArgumentExceptionException에서 상속되면, ExceptionWrapper<ArgumentException>ExceptionWrapper<Exception>에서 상속된다는 것입니다. ExceptionWrapper 인스턴스는 예외 클래스 인스턴스의 수명을 관리하는 데 사용되며, ExceptionWrapper 유형의 잘림은 관련 Exception 유형의 잘림으로 이어지지 않습니다. 예외를 던지는 것은 최종 예외 유형으로 매개변수화된 ExceptionWrapper를 생성하고 던지는 Exception의 후손에 의해 재정의된 가상 메소드에 의해 처리됩니다. 가상의 특성은 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

관련 기사