20 2월 2025
C#에서 Java로의 코드 변환기를 개발할 때, 사용하지 않는 객체를 삭제하는 데는 문제가 없습니다. Java는 C#과 충분히 유사한 가비지 컬렉션 메커니즘을 제공하며, 클래스를 사용하는 변환된 코드는 단순히 컴파일되고 작동합니다.
그러나 C++은 사정이 다릅니다. 참조를 원시 포인터(raw pointer)에 매핑하는 것은 원하는 결과를 얻지 못할 것이며, 이렇게 변환된 코드는 아무것도 삭제하지 않을 것입니다. 한편, GC 환경에서 작업하는 데 익숙한 C# 개발자들은 많은 임시 객체를 생성하는 코드를 계속 작성할 것입니다.
변환된 코드에서 객체가 적시에 삭제되도록 하기 위해 우리는 세 가지 옵션 중 하나를 선택해야 했습니다:
세 번째 옵션은 즉시 제외되었습니다. 변환된 라이브러리 내 알고리즘의 복잡성이 너무 컸고, 정적 분석은 이러한 변환된 라이브러리를 사용하는 클라이언트 코드까지 확장되어야 했습니다.
두 번째 옵션 또한 비실용적이었습니다. 우리는 애플리케이션이 아닌 라이브러리를 포팅하고 있었으므로, 가비지 수집기를 도입하면 해당 라이브러리를 사용하는 클라이언트 코드에 제약이 생길 수 있었습니다. 이 방향으로의 실험은 성공하지 못했습니다.
따라서 우리는 C++에서 일반적으로 사용되는 마지막 옵션인 참조 카운팅을 활용한 스마트 포인터 사용에 도달했습니다. 이는 순환 참조 문제를 해결하기 위해 강한 참조 외에도 약한 참조를 사용해야 함을 의미했습니다.
잘 알려진 스마트 포인터에는 여러 가지가 있습니다:
shared_ptr
은 가장 명백한 선택처럼 보일 수 있지만, 중요한 단점이 있습니다. enable_shared_from_this
를 사용하더라도 참조 카운터는 객체와 별개로 힙에 저장됩니다. 참조 카운터를 위한 메모리를 할당하고 해제하는 작업은 상대적으로 비싼 연산입니다.intrusive_ptr
는 이 점에서 더 나은데, 구조체 내에 사용되지 않은 4/8바이트 필드를 두는 것이 각 임시 객체마다 추가적인 할당 오버헤드를 발생시키는 것보다 덜 나쁜 선택입니다.다음 C# 코드를 고려해 봅시다:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
intrusive_ptr
을 사용할 경우, 이 코드는 다음과 같이 변환될 수 있습니다:
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
document = d;
d->Prepare(this);
}
};
여기에서 세 가지 문제가 즉각적으로 드러납니다:
Node::document
를 약한 참조로 만들어야 합니다.this
를 intrusive_ptr
로 변환할 방법이 있어야 합니다 (shared_from_this
와 유사). 대신 메서드 시그니처를 변경하여 (예: Document::Prepare
가 Node*
대신 intrusive_ptr<Node>
를 받도록), 이미 생성된 객체를 관리하거나 객체 수명을 처리할 때 문제가 발생할 수 있습니다.this
를 intrusive_ptr
로 변환한 후 참조 카운트를 0으로 감소시키더라도 (예: Node
생성자에서 Document::Prepare
를 빠져나올 때), 아직 외부 참조가 없는 부분적으로 생성된 객체를 즉시 삭제해서는 안 됩니다.첫 번째 문제는 수동으로 해결되었는데, 여러 참조 중 어느 것이 약한 참조여야 하는지를 식별하는 데 있어서 인간도 자주 어려움을 겪기 때문입니다. 일부 경우에는 명확한 답이 없으며, 이는 C# 코드를 수정해야 함을 의미합니다.
예를 들어, 한 프로젝트에서는 "프린트 동작"과 "프린트 동작 매개변수"라는 두 클래스가 있었습니다. 각 생성자는 서로 짝을 이루는 객체를 생성하고 양방향 참조를 설정했습니다. 이러한 참조 중 하나를 약한 참조로 전환하면 사용 시나리오가 깨질 수 있습니다. 결국, [CppWeakPtr]
속성을 사용하여 해당 필드가 강한 참조 대신 약한 참조를 포함해야 한다는 것을 변환기에 지시하기로 결정했습니다.
두 번째 문제는 intrusive_ptr
이 원시 포인터(즉, this
)로부터 변환을 허용할 경우 쉽게 해결됩니다. Boost 구현은 이 기능을 제공합니다.
마지막으로, 세 번째 문제는 생성자에서 로컬 RAII guard 변수를 도입함으로써 해결되었습니다. 이 가드는 현재 객체의 참조 카운트를 생성 시 증가시키고 소멸 시 감소시킵니다. 중요한 점은, 가드 내에서 참조 카운트를 0으로 감소시키더라도 보호된 객체를 삭제하지 않는다는 것입니다.
이러한 변경으로 인해 변환 전후의 코드는 대략 다음과 같습니다:
class Document
{
private Node node;
public Document()
{
node = new Node(this);
}
public void Prepare(Node n) { ... }
}
class Node
{
[CppWeakPtr] private Document document;
public Node(Document d)
{
document = d;
d.Prepare(this);
}
}
class Document : public virtual System::Object
{
intrusive_ptr<Node> node;
public:
Document()
{
System::Details::ThisProtector guard(this);
node = make_shared_intrusive<Node>(this);
}
void Prepare(intrusive_ptr<Node> n) { ... }
};
class Node : public virtual System::Object
{
weak_intrusive_ptr<Document> document;
public:
Node(intrusive_ptr<Document> d)
{
System::Details::ThisProtector guard(this);
document = d;
d->Prepare(this);
}
};
따라서, intrusive_ptr
의 어떤 구현이든 우리의 요구 사항을 충족하고 weak_intrusive_ptr
클래스와 쌍을 이루어 보완한다면 충분합니다. 후자는 객체 외부의 힙에 위치한 참조 카운터에 의존해야 합니다. 약한 참조를 생성하는 것은 임시 객체를 생성하는 것에 비해 비교적 드문 작업이므로, 참조 카운터를 강한 참조(객체 내부)와 약한 참조(객체 외부)로 분리하는 것이 실제 코드에서 성능 향상을 가져왔습니다.
상황은 우리가 제네릭 클래스 및 메서드의 코드를 변환해야 하기 때문에 훨씬 더 복잡해집니다. 여기서 타입 매개변수는 값 형식 또는 참조 형식이 될 수 있습니다. 다음 C# 코드를 고려해 봅시다:
class MyContainer<T>
{
public T field;
public void Set(T val)
{
field = val;
}
}
class MyClass {}
struct MyStruct {}
var a = new MyContainer<MyClass>();
var b = new MyContainer<MyStruct>();
간단한 포팅 접근 방식은 다음과 같은 결과를 제공합니다:
template <typename T> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
class MyClass : public virtual System::Object {};
class MyStruct : public System::Object {};
auto a = make_shared_intrusive<MyContainer<MyClass>>();
auto b = make_shared_intrusive<MyContainer<MyStruct>>();
명확히, 이 코드는 원본과 동일하게 동작하지 않습니다. MyContainer<MyClass>
를 인스턴스화할 때, field
객체는 힙에서 MyContainer
필드로 이동하여 참조 복사 의미론을 깨뜨립니다. 반면, MyStruct
구조체를 필드에 배치하는 것은 C# 동작과 일치하며 완전히 올바릅니다.
이 문제는 두 가지 방법으로 해결할 수 있습니다:
MyContainer<T>
의 의미를 MyContainer<intrusive_ptr<T>>
로 전환합니다:auto a = make_shared_intrusive<MyContainer<intrusive_ptr<MyClass>>>();
template <typename T, bool is_T_reference_type = is_reference_type_v<T>> class MyContainer : public virtual System::Object
{
public:
T field;
void Set(T val)
{
field = val;
}
};
template <typename T> class MyContainer<T, std::enable_if_t<!std::is_class_v<T>>> : public virtual System::Object
{
public:
intrusive_ptr<T> field;
void Set(intrusive_ptr<T> val)
{
field = val;
}
};
추가적인 타입 매개변수마다 급격히 늘어나는 장황함 외에도, 두 번째 접근 방식은 MyContainer<T>
를 사용하는 모든 문맥에서 T
가 값 형식인지 참조 형식인지 알아야 한다는 단점이 있습니다. 이는 특정 내부 타입에 대한 정보를 숨기거나 포함된 헤더 파일의 수를 최소화하려는 경우 바람직하지 않습니다.
또한, 참조 형식(강한 참조 또는 약한 참조)의 선택은 컨테이너당 한 번만 가능합니다. 이는 변환된 제품의 코드가 두 가지 모두를 필요로 한다 하더라도 강한 참조 목록(List
)과 약한 참조 목록을 동시에 가질 수 없음을 의미합니다.
이러한 요인들을 고려하여, MyContainer<T>
를 MyContainer<intrusive_ptr<T>>
또는 약한 참조의 경우 MyContainer<weak_intrusive_ptr<T>>
의 의미로 포팅하기로 결정했습니다. 가장 인기 있는 라이브러리들은 필요한 특성을 가진 포인터를 제공하지 않으므로, 우리는 자체 구현을 개발했습니다. 이를 System::SharedPtr
(객체 내 참조 카운터를 사용하는 강한 참조)와 System::WeakPtr
(외부 참조 카운터를 사용하는 약한 참조)라고 명명했습니다. System::MakeObject
함수는 std::make_shared
스타일로 객체를 생성하는 역할을 합니다.