27 3월 2025

C# 코드를 C++로 포팅하기: SmartPtr 구현

초기부터 이 작업은 최대 수백만 줄의 코드를 포함하는 여러 프로젝트를 포팅하는 것을 포함했습니다. 본질적으로 번역기에 대한 기술 사양은 "이 모든 것이 C에서 올바르게 포팅되고 실행되도록 보장하라"는 문구로 요약되었습니다. C 제품 출시 담당자의 작업에는 코드 번역, 테스트 실행, 릴리스 패키지 준비 등이 포함됩니다. 일반적으로 발생하는 문제는 여러 범주 중 하나로 분류됩니다:

  1. 코드가 C++로 번역되지 않음 - 번역기가 오류를 내며 종료됩니다.
  2. 코드가 C++로 번역되지만 컴파일되지 않습니다.
  3. 코드가 컴파일되지만 링크되지 않습니다.
  4. 코드가 링크되고 실행되지만 테스트가 실패하거나 런타임 충돌이 발생합니다.
  5. 테스트는 통과하지만, 제품 기능과 직접 관련 없는 문제가 실행 중에 발생합니다. 예: 메모리 누수, 낮은 성능 등.

이 목록의 진행은 위에서 아래로 이루어집니다. 예를 들어, 번역된 코드의 컴파일 문제를 해결하지 않고는 기능과 성능을 검증할 수 없습니다. 결과적으로, CodePorting.Translator Cs2Cpp 프로젝트 작업의 후반 단계에서야 오랫동안 존재했던 많은 문제가 발견되었습니다.

처음에는 객체 간의 순환 종속성으로 인해 발생하는 간단한 메모리 누수를 수정할 때 필드에 CppWeakPtr 애트리뷰트를 적용하여 WeakPtr 타입의 필드를 만들었습니다. WeakPtrlock() 메서드를 호출하거나 암시적으로(구문상 더 편리함) SharedPtr로 변환될 수 있는 한, 이는 문제를 일으키지 않았습니다. 그러나 나중에는 CppWeakPtr 애트리뷰트에 대한 특수 구문을 사용하여 컨테이너 내에 포함된 참조도 약하게 만들어야 했고, 이로 인해 몇 가지 불쾌한 문제가 발생했습니다.

우리가 채택한 접근 방식의 첫 번째 문제 징후는 C++ 관점에서 MyContainer<SharedPtr<MyClass>>MyContainer<WeakPtr<MyClass>>가 두 개의 다른 타입이라는 것이었습니다. 결과적으로 이들은 동일한 변수에 저장하거나, 동일한 메서드에 전달하거나, 반환하는 등의 작업을 할 수 없습니다. 원래 객체 필드에 참조를 저장하는 방법을 관리하기 위한 목적으로만 사용되었던 애트리뷰트가 반환 값, 인수, 지역 변수 등에 영향을 미치며 점점 더 이상한 컨텍스트에 나타나기 시작했습니다. 이를 처리하는 번역기 코드는 날이 갈수록 복잡해졌습니다.

두 번째 문제 역시 예상하지 못했던 것이었습니다. C# 프로그래머에게는 객체당 하나의 연관 컬렉션을 가지는 것이 자연스러운 것으로 판명되었습니다. 이 컬렉션에는 현재 객체가 소유하고 다른 방법으로는 접근할 수 없는 객체에 대한 고유 참조와 부모 객체에 대한 참조가 모두 포함될 수 있었습니다. 이는 특정 파일 형식에서의 읽기 작업을 최적화하기 위해 수행되었지만, 우리에게는 동일한 컬렉션에 강한 참조와 약한 참조가 모두 포함될 수 있음을 의미했습니다. 포인터 타입은 더 이상 작동 모드의 최종 결정 요인이 아니게 되었습니다.

포인터 상태의 일부로서의 참조 타입

분명히 이 두 가지 문제는 기존 패러다임 내에서 해결할 수 없었고, 포인터 타입이 재고되었습니다. 이 수정된 접근 방식의 결과는 SmartPtr 클래스였으며, 이 클래스는 SmartPtrMode::SharedSmartPtrMode::Weak 두 값 중 하나를 받는 set_Mode() 메서드를 특징으로 합니다. 모든 SmartPtr 생성자는 동일한 값을 받습니다. 결과적으로 각 포인터 인스턴스는 다음 두 상태 중 하나로 존재할 수 있습니다:

  1. 강한 참조: 참조 카운터가 객체 내부에 캡슐화됩니다.
  2. 약한 참조: 참조 카운터가 객체 외부에 있습니다.

모드 간 전환은 런타임 중 언제든지 발생할 수 있습니다. 약한 참조 카운터는 객체에 대한 약한 참조가 하나 이상 존재할 때까지 생성되지 않습니다.

우리 포인터가 지원하는 전체 기능 목록은 다음과 같습니다:

  1. 강한 참조 저장: 참조 카운팅을 통한 객체 수명 관리.
  2. 객체에 대한 약한 참조 저장.
  3. intrusive_ptr 시맨틱: 동일한 객체에 대해 생성된 임의 개수의 포인터가 단일 참조 카운터를 공유합니다.
  4. 역참조 및 화살표 연산자 (->): 가리키는 객체에 접근하기 위함.
  5. 생성자 및 대입 연산자의 전체 세트.
  6. 가리키는 객체와 참조 카운팅되는 객체의 분리 (에일리어싱 생성자): 클라이언트 라이브러리가 문서를 다루기 때문에 문서 요소에 대한 포인터가 전체 문서를 유지해야 하는 경우가 많습니다.
  7. 캐스트의 전체 세트.
  8. 비교 연산의 전체 세트.
  9. 포인터의 대입 및 삭제: 불완전한 타입에 대해 작동합니다.
  10. 포인터 상태 확인 및 변경을 위한 메서드 세트: 에일리어싱 모드, 참조 저장 모드, 객체 참조 카운트 등.

SmartPtr 클래스는 템플릿화되어 있으며 가상 메서드를 포함하지 않습니다. 참조 카운터 저장을 처리하는 System::Object 클래스와 밀접하게 결합되어 있으며, 해당 파생 클래스에서만 작동합니다.

일반적인 포인터 동작과의 차이점은 다음과 같습니다:

  1. 이동(이동 생성자, 이동 대입 연산자)은 전체 상태를 변경하지 않으며, 참조 타입(약함/강함)을 보존합니다.
  2. 약한 참조를 통한 객체 접근은 잠금(임시 강한 참조 생성)을 요구하지 않습니다. 이는 화살표 연산자가 임시 객체를 반환하는 접근 방식이 강한 참조의 성능을 심각하게 저하시키기 때문입니다.

이전 코드와의 호환성을 유지하기 위해 SharedPtr 타입은 SmartPtr의 별칭이 되었습니다. WeakPtr 클래스는 이제 SmartPtr를 상속하며, 필드를 추가하지 않고 생성자를 재정의하여 항상 약한 참조를 생성하도록 합니다.

컨테이너는 이제 항상 MyContainer<SmartPtr<MyClass>> 시맨틱으로 포팅되며, 저장된 참조의 타입은 런타임에 선택됩니다. STL 데이터 구조를 기반으로 수동으로 작성된 컨테이너(주로 System 네임스페이스의 컨테이너)의 경우, 기본 참조 타입은 사용자 지정 할당자를 사용하여 설정되지만, 여전히 개별 컨테이너 요소에 대해 모드를 변경할 수 있습니다. 번역된 컨테이너의 경우, 참조 저장 모드를 전환하는 데 필요한 코드가 번역기에 의해 생성됩니다.

이 솔루션의 단점은 주로 포인터 생성, 복사 및 삭제 작업 중 성능 저하를 포함합니다. 이는 일반적인 참조 카운팅에 참조 타입 확인이 필수로 추가되기 때문입니다. 구체적인 수치는 테스트 구조에 크게 의존합니다. 현재 포인터 타입이 변경되지 않음이 보장되는 곳에서 더 최적화된 코드를 생성하는 것에 대한 논의가 진행 중입니다.

번역을 위한 코드 준비

우리의 포팅 방법은 참조가 약해야 하는 위치를 표시하기 위해 소스 C# 코드에 수동으로 애트리뷰트를 배치해야 합니다. 이러한 애트리뷰트가 올바르게 배치되지 않은 코드는 번역 후 메모리 누수 및 경우에 따라 다른 오류를 유발합니다. 애트리뷰트가 있는 코드는 다음과 같습니다:

struct S {
    MyClass s; // 객체에 대한 강한 참조

    [CppWeakPtr]
    MyClass w; // 객체에 대한 약한 참조

    MyContainer<MyClass> s_s; // 강한 참조 컨테이너에 대한 강한 참조

    [CppWeakPtr]
    MyContainer<MyClass> w_s; // 강한 참조 컨테이너에 대한 약한 참조

    [CppWeakPtr(0)]
    MyContainer<MyClass> s_w; // 약한 참조 컨테이너에 대한 강한 참조

    [CppWeakPtr(1)]
    Dictionary<MyClass, MyClass> s_s_w; // 키는 강한 참조로, 값은 약한 참조로 저장되는 컨테이너에 대한 강한 참조

    [CppWeakPtr, CppWeakPtr(0)]
    Dictionary<MyClass, MyClass> w_w_s; // 키는 약한 참조로, 값은 강한 참조로 저장되는 컨테이너에 대한 약한 참조
}

경우에 따라 SmartPtr 클래스의 에일리어싱 생성자나 저장된 참조 타입을 설정하는 메서드를 수동으로 호출해야 합니다. 우리는 포팅된 코드를 편집하는 것을 피하려고 노력합니다. 이러한 변경 사항은 번역기 실행 후 매번 다시 적용해야 하기 때문입니다. 대신, 이러한 코드를 C# 소스 내에 유지하는 것을 목표로 합니다. 이를 위한 두 가지 방법이 있습니다:

  1. C# 코드에서 아무 작업도 수행하지 않는 서비스 메서드를 선언하고, 번역 중에 이를 필요한 작업을 수행하는 수동 작성된 해당 코드로 교체할 수 있습니다:
class Service {
    public static void SetWeak<T>(T arg) {}
}
class Service {
public:
    template <typename T> static void SetWeak(SmartPtr<T> &arg)
    {
        arg.set_Mode(SmartPtrMode::Weak);
    }
};
  1. C# 코드에 특수 형식의 주석을 배치하여 번역기가 이를 C++ 코드로 변환하도록 할 수 있습니다:
class MyClass {
    private Dictionary<string, object> data;
    public void Add(string key, object value)
    {
        data.Add(key, value);
        //CPPCODE: if (key == u"Parent") data->data()[key].set_Mode(SmartPtrMode::Weak);
    }
}

여기서 System::Collections::Generic::Dictionarydata() 메서드는 이 컨테이너의 기본 std::unordered_map에 대한 참조를 반환합니다. ```

관련 뉴스

관련 동영상

관련 기사