rjk::duck: C++26 Reflection을 통한 타입 소거(Type Erasure)

rjk::duck: C++26 Reflection을 통한 타입 소거(Type Erasure)

rjk::duck 라이브러리는 C++26 reflection을 사용하여 타입 소거를 구현하며, 이를 통해 개발자가 트레이트 인터페이스를 한 번 정의하면 해당 인터페이스를 만족하는 모든 타입에 자동으로 적용할 수 있게 합니다. 이 접근 방식은 전통적인 vtable 디스패치 성능을 유지하면서도, 수백 줄의 수동 보일러플레이트 코드나 Boost.TypeErasure 또는 Folly.Poly와 같은 무거운 의존성을 제거합니다.

C++26 Reflection을 이용한 자동 인터페이스 생성

rjk::duck은 사용자가 [[=rjk::trait]]로 주석 처리된 struct를 사용하여 인터페이스를 정의할 수 있게 합니다. 그런 다음 라이브러리는 C++26 reflection을 사용하여 이 트레이트를 검사하고 컴파일 타임에 타입 소거에 필요한 메커니즘을 생성합니다.

트레이트 검사 및 태깅

라이브러리는 annotations_of와 reflection 연산자 ^^를 사용하여 [[=rjk::trait]] 주석을 확인하여 트레이트를 식별합니다. 트레이트가 식별되면, members_to_tags 함수는 struct의 멤버를 검사하여 식 identifiers로 선언된 사용자 정의 함수를 필터링합니다. 이들은 내부 "tags" (예: has_fn<"name", signature>)로 변환되며, 이는 생성된 vtable의 청사진 역할을 합니다.

Vtable 코드 생성

consteval 블록과 define_aggregate를 사용하여, rjk::duck은 트레이트의 모든 멤버에 대한 함수 포인터를 포함하는 vtable struct를 생성합니다. 특정 타입 T에 대해, 라이브러리는 각 슬롯이 래퍼 함수로 채워지는 정적 vtable을 생성합니다. 이 래퍼 함수는 void*에서 T*static_cast를 수행하고 원래의 멤버 함수를 호출합니다.

고급 오버로드 해소(Overload Resolution)

C++ 오버로드 해소 기능을 수동으로 재구현하는 것을 피하기 위해, rjk::duckoverload_setcandidate_wrapper를 사용하는 기술을 사용합니다.

정확한 시그니처 일치 여부를 검색하는 대신, 라이브러리는 특정 식별자와 일치하는 모든 멤버 함수에 대한 래퍼를 포함하는 호출 가능한 overload_set을 생성합니다. vtable이 채워질 때, 컴파일러 자체의 오버로드 해소 메커니즘이 erased_call 함수 내에서 사용되어 기본 타입에 가장 적합한 오버로드를 선택합니다.

포인터 상호 변환 가능성(Pointer-Interconvertibility)을 통한 메모리 최적화

타입 소거의 단순한 구현 방식은 각 멤버 함수 래퍼가 vtable과 기본 데이터를 액세스하기 위해 부모 객체에 대한 백포인터를 저장해야 하는 경우가 많습니다. 이는 duck 객체의 크기가 트레이트의 함수 개수에 따라 선형적으로 증가하게 만듭니다.

rjk::duck은 이를 포인터 상호 변환 가능성을 사용하여 해결합니다. 라이브러리가 standard layout 타입을 사용하기 때문에, 멤버 함수 래퍼에 대한 포인터를 reinterpret_cast를 통해 부모 duck 객체로 다시 변환할 수 있습니다. 이를 통해 vtable_function 객체들을 빈 클래스(using [[no_unique_address]])로 만들 수 있으며, 이는 트레이트에 정의된 함수의 개수와 관계없이 duck 인스턴스의 크기가 일정하게 유지됨을 보장합니다.

성능 튜닝 및 인라이닝(Inlining)

표준 vtable 디스패치는 효율적이지만, rjk::duck은 간접 참조를 줄이기 위한 선택적 성능 최적화 기능을 제공합니다.

[[=rjk::perf_options]] 트레이트를 정의함으로써, 사용자는 어떤 함수를 "inlined"할지 지정할 수 있습니다 있습니다. 인라이닝된 함수는 외부 vtable 내부가 아닌 duck 객체 자체에 직접 함수 포인터로 저장됩니다. 이는 호출 시 포인터 간접 참조를 한 번 제거하는 대신 약간의 추가 메모리(인라이닝된 함수당 8바이트)를 사용하며, 이는 vtable이 cold memory에 저장될 때 특히 유용합니다.

기술적 요구사항 및 제한 사항

현재 rjk::duck은 다음과 같은 제약 사항이 있는 최첨단(bleeding-edge) 구현체입니다:

  • 컴파일러 지원: -std=c++26 -freflection 옵션이 있는 GCC에서만 사용 가능합니다.
  • 컴파일 타임 실행: P2738을 통해 constexpr 컨텍스트에서 void* 캐스팅이 가능해지고 있지만, 포인터 상호 변환 가능성을 위해 사용되는 reinterpret_cast는 현재 컴파일 타임에 허용되지 않으므로 완전한 constexpr 지원에는 한계가 있습니다.

커뮤니티 관점

라이브러리에 대한 논의는 현대적 C++ reflection의 양면성을 강조합니다:

"저는 매일 C++를 사용하지만, 이것은 완전히 다른 언어이자 철학처럼 느껴집니다."

비평가들은 정적 reflection이 디버깅하기 어렵고, 단일 문자가 잘못 배치될 경우 매우 장황한 컴파일러 에러 메시지를 유발할 수 있다고 지적했습니다. 다른 이들은 컴파일 시간의 영향을 질문하며, 아직 개발 중인 컴파일러 기능을 사용하기 때문에 확정적인 벤들치마킹을 수행하기 어렵다고 언급했습니다.

Sources