C++26에서 검토 중인 정적 리플렉션의 개념과, 오늘날 매크로와 템플릿 기법으로 이를 에뮬레이션하는 방법, 그리고 향후 표준 지원으로 가능해질 예제를 살펴본다.
C++의 정적 리플렉션은 C++26에서 검토되고 있다. Wu Yongwei는 지금 당장 어떻게 리플렉션을 구현할 수 있는지 보여주고, C++26이 무엇을 가능하게 할지 몇 가지 예를 통해 설명한다.
정적 리플렉션은 C++ 컴파일 타임 프로그래밍의 중요한 부분이 될 것이며, 이는 내가 Overload 10월호에서 논의한 바 있다 [Wu24]. 이번에는 표준에 추가되기 전에, 지금 당장 이를 어떻게 에뮬레이션할 수 있는지를 포함하여 정적 리플렉션을 자세히 다루겠다.
많은 프로그래밍 언어가 리플렉션을 지원한다. 예를 들어 Python과 Java가 그렇다. C++는 이 부분에서 뒤처져 있다.
현재는 그렇지만, 상황은 아마도 C++26에서 바뀔 것이다. 또한 C++에서 제공될 것은 Java나 Python 같은 언어에서 제공되는 것과 매우 다를 것이다. 핵심 키워드는 ‘정적’이다.
Andrew Sutton은 ‘정적 리플렉션’을 다음과 같이 정의했다 [Sutton21]:
정적 리플렉션은 메타프로그램이 자기 자신의 코드를 관찰하고, 제한된 범위에서 컴파일 타임에 새로운 코드를 생성할 수 있게 하는 내재적 능력이다.
C++에서 ‘컴파일 타임’은 특별한 비법이며, 이것은 다른 언어에서는 불가능한 일을 가능하게 해 준다.
정적 리플렉션을 이야기할 때, 우리가 정말 원하는 것은 무엇일까? 우리가 정말 원하는 것은 컴파일러가 볼 수 있는 것을 보는 것이고, 그 관련 정보를 코드에서 사용할 수 있게 하는 것이다. 가장 대표적인 경우는 enum과 struct다. 모든 열거자를 순회할 수 있고, 그 이름과 값을 알 수 있기를 원한다. struct의 모든 데이터 멤버를 순회할 수 있고, 그 이름과 타입을 알 수 있기를 원한다. 물론 데이터 멤버가 aggregate라면, 리플렉션 도중 그 내부로 재귀적으로 들어갈 수도 있어야 한다. 그 외에도 여러 가지가 있다.
안타깝게도 오늘날 우리는 ‘표준’ 정의만으로는 이 모든 일을 할 수 없다. 물론 일부 구현에서는 여러 가지 꼼수로 일부 정보를 뽑아낼 수 있다. 하지만 나는 같은 목적을 달성하기 위해 매크로와 템플릿 기법을 사용하는 편을 선호한다. 그렇게 하면 비표준 정의 문법을 사용해야 하는 대가가 있긴 하지만, 코드는 좀 더 깔끔하고, 이식성이 좋고, 유지보수하기 쉬워진다. 물론 미래의 C++ 표준이 직접 지원해 주는 것만 한 것은 없다.
나는 수년에 걸쳐 Netcan의 작업 [Netcan]에서 시작한 여러 매크로 코드를 축적해 왔다. 핵심 기능은 다음과 같다.
GET_ARG_COUNT: 가변 인자의 개수를 구한다. 즉 GET_ARG_COUNT(a, b, c)는 3이 된다.REPEAT_ON: 가변 인자를 메인 함수 매크로에 개수와 함께 적용한다. 즉 REPEAT_ON(func, a, b, c)는 func(0, a) func(1, b) func(2, c)가 된다.PAIR: 인자에서 첫 번째 괄호 쌍을 제거한다. 즉 PAIR((long)v1)는 long v1이 된다.STRIP: 괄호 안의 첫 번째 부분을 제거한다. 즉 STRIP((long)v1)는 v1이 된다.이 아이디어들 중 일부는 적어도 2012년 [Fultz12a]만큼 이른 시점부터 존재했지만, Paul Fultz의 코드는 실제 소프트웨어 프로젝트에 적합하지 않았다. 현재 내 코드는 실전 사용이 가능한 수준으로 봐야 하며, 그 변형은 이미 몇몇 대형 애플리케이션에서 사용되었다. 또한 모든 주류 컴파일러에서 테스트되었고, 표준 이전의 MSVC도 포함된다. (구형 MSVC 지원에는 실제로 꽤 많은 노력이 들었다.) 내 정의는 오픈소스 프로젝트 Mozi [mozi]에서 찾을 수 있다.
일부 사람들은 매크로를 악으로 여기며, 더 나은 대안을 찾을 수 있다면 정말 피해야 한다. 하지만 개인적으로 나는 몇몇 템플릿 해킹보다 매크로가 이해하고 유지보수하기 더 쉽다고 느낀다.
우리는 종종 어떤 열거형에 몇 개의 열거자가 정의되어 있는지, 그 기저 값이 무엇인지, 문자열 형태는 무엇인지 알고 싶다. 마지막 요구는 디버깅/로깅 목적으로 특히 중요하다.
이런 기능을 제공하는 기존 라이브러리로는 Magic Enum C++ [magic_enum]와 Better Enums [better-enums]가 있다.
Magic Enum C++는 최신의 C++17 적합 컴파일러를 요구하며, 표준 형태의 열거형 정의와 함께 동작한다. 하지만 열거자 값을 알아내기 위해 컴파일 타임 카운팅 기법을 사용하기 때문에, 열거자 값의 범위에 제한이 있다. 또한 열거형 정의에 선언되지 않은 열거형 값과는 잘 어울리지 않는다. 예를 들어 Color{100} 같은 경우다. 이런 값에 대해 magic_enum::enum_name을 호출하면 비어 있는 string_view를 얻게 된다. 그렇다 하더라도 필요를 충족한다면 나는 이것의 사용을 권한다.
Better Enums는 사실상 어떤 컴파일러에서도, 심지어 오래된 C++98 컴파일러에서도 동작한다. 하지만 열거형 정의에 특수한 형태를 사용해야 한다. 그것만으로도 보기 좋지는 않지만 받아들일 수는 있다. 더 보기 좋지 않은 점은 그 결과가 _enum이 아니라는 것_이며, 열거형 정의에 선언되지 않은 값과는 전혀 공존할 수 없다는 점이다. 그런 값을 문자열화하려고 하면 segmentation fault가 발생한다…
주로 문제를 더 잘 이해하기 위해, 나는 enum 리플렉션을 직접 시도해 보았다. 기본적으로 나는 다음과 같은 일을 했다.
enum이 되도록 보장한다to_string 같은 함수 오버로드를 사용해 필요한 연산을 지원한다enum class 정의의 예는 다음과 같다.
DEFINE_ENUM_CLASS(Color, int, red = 1, green, blue); 그 다음에는 다음과 같이 사용할 수 있다.
cout << to_string(Color::red) << '\n'; cout << to_string(Color{9}) << '\n'; 그러면 다음과 같은 출력을 얻는다.
red (Color)9
Mozi 프로젝트에서 구현 세부 사항을 확인할 수 있지만, 여기서는 DEFINE_ENUM_CLASS가 무엇을 하는지 개요를 설명하고 싶다. 그 정의는 Listing 1에 있다.
명확히 보이듯이, 이것은 세 가지 일을 한다.
enum class를 정의한다ENUM_ITEM 매크로를 적용하여 생성된다enum 타입을 위한 유틸리티 함수를 선언한다위의 Color 정의를 사용하면 이것은 Listing 2로 확장된다. (첫 번째 수준에서.) 전체 확장 결과는 Listing 3과 비슷한 형태가 된다.
이 정도면 기본 아이디어를 보기에는 충분할 것이다. 관심이 있다면 Mozi 프로젝트에서 구현 세부 사항을 확인할 수 있다.
Listing 4의 코드는 현재 C++26 정적 리플렉션 제안인 P2996 [P2996r7]에 따르면 동작해야 한다.
이 코드는 다음과 같은 리플렉션 기능을 사용한다.
^E는 enum 타입 E에 대한 리플렉션 정보를 생성한다.[:e:]는 리플렉션 객체를 다시 소스 엔터티에 ‘splice’하는데, 여기서는 열거자다.template for 루프(확장 구문)는 컴파일 타임에 이종 객체를 순회할 수 있게 한다.std::meta::enumerators_of는 열거형의 모든 열거자를 얻는다.std::meta::identifier_of는 리플렉션된 객체의 식별자/이름을 얻는다. 여기서는 한 번은 열거자 이름을 위해, 또 한 번은 열거형 이름을 위해 사용한다.이것은 수작업으로 만든 to_string과 같은 일을 하면서도 수동 보일러플레이트가 없다. 이제 더 이상 매크로가 필요하지 않다.
Compiler Explorer에서 사용할 수 있는 초기 제안 P2320 [P2320r0]의 온라인 구현은 데모 목적으로 편리하다. P2996r7과 P2320의 명백한 차이는 함수 이름이다. enumerators_of는 이전에는 members_of였고, identifier_of는 name_of였다. 리플렉션을 지원하는 다른 Godbolt 컴파일러들도 몇 가지 있지만, 아직은 충분히 성숙하지 않았고, 주된 이유는 확장 구문 지원이 부족하기 때문이다. 나는 P2320에서 동작하는 enum 리플렉션 코드의 두 가지 버전을 작성했다.
보다시피 전체 로직을 구현하는 일이 여전히 간단하다고 할 수는 없지만, 가장 큰 장점은 이제 Magic Enum C++의 현재 제한 없이 표준 enum 정의 형태를 사용할 수 있다는 점이다. 리플렉션 정보는 컴파일 타임에 접근할 수 있고, 나중에 런타임에서 접근할 수 있도록 저장할 수도 있다.
struct의 리플렉션 필요성은 enum보다도 더 강하다. 리플렉션은 디버깅/로깅에 매우 유용하고, 리플렉션이 가능하면 직렬화와 역직렬화도 쉬워진다.
리플렉션 목적의 기존 구현으로 나는 두 가지를 알고 있다.
Boost.PFR [pfr]은 다음과 같다.
…매우 기초적인 리플렉션을 위한 C++14 라이브러리로, 매크로나 보일러플레이트 코드 없이 사용자 정의 타입에 대해 인덱스로 구조체 원소에 접근하고, 다른std::tuple유사 메서드를 제공한다.
사용하기 쉽다. 순회, 비교, 출력 같은 일반적인 연산을 지원한다. 하지만 정적 리플렉션이 없기 때문에 필드의 이름에 접근할 방법은 없다.
Struct_pack [struct_pack]은 “매우 사용하기 쉽고, 고성능인 직렬화 라이브러리”다. C++17을 요구하며 직렬화/역직렬화에 초점을 맞춘다. 이것은 일반적인 리플렉션 목적을 위해 설계된 것이 아니며, 상당한 해킹 없이는 자신만의 직렬화 시나리오에 실제로 활용하기 어렵다.
실제 구현은 아니지만, 내가 아는 한 struct 리플렉션에 대한 가장 이른 코드는 Paul Fultz의 것 [Fultz12b]이다. 2012년에는 현대적인 컴파일 타임 기법이 준비되지 않았기 때문에, 기본 아이디어는 비슷했지만 Netcan과 나는 그의 코드를 많이 차용하지는 않았다.
나는 Boost.PFR의 한계는 없지만 내부적으로는 매크로 사용을 요구하는 자체 struct 리플렉션 방식을 갖고 있다. 하지만 정적 리플렉션이 표준화되면, 코드와 기법의 상당 부분을 표준 C++에 맞게 적응시킬 수 있다.
기본 접근법은 다음과 같다.
다음은 예시다. 다음과 같은 정의가 있다고 하자.
DEFINE_STRUCT(
Point,
(double)x,
(double)y
);
DEFINE_STRUCT(
Rect,
(Point)p1,
(Point)p2,
(uint32_t)color
);
그러면 이런 struct를 평소처럼 초기화할 수 있다.
Rect rect{ {1.2, 3.4}, {5.6, 7.8}, 12345678 }; 그리고 쉽게 출력할 수 있다.
print(data); 그러면 다음을 얻게 된다.
{ p1: { x: 1.2, y: 3.4 }, p2: { x: 5.6, y: 7.8 }, color: 12345678 }
구현 세부 사항 자체는 그리 흥미롭지 않을 수 있지만, 실제로는 더 흥미로운 사용 시나리오가 있다. 내가 구현한 것 중 하나는 관심 있는 필드를 복사하는 기능이다.
다음 정의를 가정해 보자. (S1과 S2에서 v2와 v4의 타입이 서로 다르다는 점에 주목해 달라.)
DEFINE_STRUCT(S1, (uint16_t)v1, (uint16_t)v2, (uint32_t)v3, (uint32_t)v4, (string)msg );
DEFINE_STRUCT(S2, (int)v2, (long)v4 );
S1 s1{…}; … S2 s2; 그러면 다음 문장은 알아서 올바르게 동작한다.
copy_same_name_fields(s1, s2);
그리고 이것은 가능한 한 가장 높은 효율로 수행되며, s2.v2 = s1.v2; s2.v4 = s1.v4;와 동등하다. 나는 컴파일러가 생성한 x86-64 어셈블리 코드를 확인해 보았고, 그 결과는 다음과 같다.
movzx eax, WORD PTR s1[rip+2] mov DWORD PTR s2[rip], eax mov eax, DWORD PTR s1[rip+8] mov QWORD PTR s2[rip+8], rax Java나 Python이 이런 비슷한 일을 할 수 있을 거라고는 생각하지 않는다!
이것이 별로 유용해 보이지 않는다면, 큰 데이터베이스 레코드를 떠올려 보라. 큰 BookInfo 객체들을 담고 있는 컨테이너가 있고, SQL의 SELECT name, publish_year WHERE``author_id = … 같은 일을 하고 싶다고 상상해 보자. 코드는 Listing 5와 같을 것이다.
필요한 필드를 수동으로 복사하는 것만큼 효율적이면서도, 코드가 훨씬 더 단순하지 않은가? 특히 그런 필드가 많을 때 장점이 더욱 분명하다.
나는 실제 코드에서 수십 개의 필드를 복사하는 경우를 본 적이 있고, 그 뒤에 직렬화가 이어지는 경우도 많았다. (정보를 네트워크로 보내기 위해서다.) 이 주제는 나중에 따로 다루겠다.
DEFINE_STRUCT는 다음과 같이 정의된다.
#define DEFINE_STRUCT(st, ...)
struct st {
using is_reflected = void;
template <typename, size_t>
struct _field;
static constexpr size_t _size =
GET_ARG_COUNT(VA_ARGS);
REPEAT_ON(FIELD, VA_ARGS)
}
위의 S2는 먼저 대략 다음과 같이 확장된다.
struct S2 {
using is_reflected = void;
template <typename, size_t>
struct _field;
static constexpr size_t _size = 2;
FIELD(0, (int)v2)
FIELD(1, (long)v4)
};
그리고 FIELD(0, (int)v2)는 다음과 같이 확장된다.
int v2;
template <typename T>
struct field<T, 0> {
using type = decltype(decay_t<T>::v2);
static constexpr auto name = CTS_STRING(v2);
constexpr explicit field(T&& obj)
: obj(std::forward<T>(obj)) {}
constexpr decltype(auto) value()
{ return (std::forward<T>(obj).v2); }
T&& obj_;
};
CTS_STRING(v2)는 환경에 따라 두 가지 가능한 정의가 있으므로 여기서는 확장하지 않겠다 [Wu22]. 지금은 약간의 추가 마법이 들어간 단순한 "v2"라고 생각해도 된다. (copy_same_name_fields에는 그 추가 기능이 필요하다.)
S2 타입의 obj가 있다면, 필드 번호를 사용해 멤버에 접근할 수 있다. _field<S2&, 0>(obj).value()는 정확히 obj.v2와 같고, 값 카테고리도 올바르다. 그리고 S2::_field<S2&,0>::type은 obj.v2의 타입이며, 즉 int다. fold expression의 도움을 받으면, 이제 Listing 6에서 보인 것처럼 컴파일 타임 필드 순회 같은 더 복잡한 일도 가능해진다.
이제 for_each(obj, f) 같은 함수 호출은 다음과 동등하게 된다.
f(0, S2::_field<S2&, 0>::name, get<0>(obj));
f(1, S2::_field<S2&, 1>::name, get<1>(obj));
for_each 같은 기능은 print나 직렬화 같은 사용자 가시 도구를 구현하는 데 필수적이다.
enum 리플렉션의 경우와 마찬가지로, C++26 정적 리플렉션이 도입되면 매크로 사용을 없앨 수 있게 된다. Listing 7은 print의 데모 구현이다. (업데이트된 P2996 버전에 맞추기 위해 [Wu24]에서 약간 수정했다.)
^와 [:…:]에 대해 우리가 이미 알고 있는 것을 고려하면, 코드는 꽤 직관적이다.
우리는 이것이 실제로 P2320 (https://cppx.godbolt.org/z/G3EcvhKxK)과 P2996에서, 확장 구문 우회법과 함께 동작함을 확인할 수 있다 (https://godbolt.org/z/77PYjzcW8).
Mozi는 내가 2023년 말에 시작한 오픈소스 프로젝트로, 주로 매크로 기반 정적 리플렉션을 실험하기 위한 목적이었다. 나는 일반적인 비교, 복사, 출력, 직렬화/역직렬화를 구현했다. net_pack이라는 직렬화 시나리오도 구현했는데, 여기에는 완전 자동 바이트 순서 교환이 포함되며 네트워크 데이터그램을 다루기에 적합하다. 네트워크 상의 비트 필드 지원을 위해 특별한 bit_field 타입도 제공한다.
나는 이것을 정적 리플렉션으로 가능한 흥미로운 일들을 보여주는 데모라고 생각한다. 현재 매크로 기법으로 가능한 것은 C++26 정적 리플렉션으로도 가능해질 것이고, 다만 구현자와 사용자 모두에게 더 단순해질 뿐이다.
[better-enums] https://github.com/aantron/better-enums
[Fultz12a] Paul Fultz II, ‘Is the C preprocessor Turing complete?’, May 2012, https://pfultz2.com/blog/2012/05/10/turing
[Fultz12b] Paul. Fultz II, ‘C++ Reflection in under 100 lines of code’, July 2012, https://pfultz2.com/blog/2012/07/31/reflection-in-under-100-lines
[magic_enum] https://github.com/Neargye/magic_enum
[mozi] https://github.com/adah1972/mozi
[Netcan] https://github.com/netcan/recipes/tree/master/cpp/metaproggramming
[P2320r0] Andrew Sutton et al., ‘The Syntax of Static Reflection’, 2021, http://wg21.link/p2320r0
[P2996r7] Wyatt Childers et al., ‘Reflection for C++26’ (revision 7), October 2024, http://wg21.link/p2996r7
[pfr] https://github.com/boostorg/pfr
[struct_pack] https://github.com/alibaba/yalantinglibs
[Sutton21] Andrew Sutton, ‘Reflection: Compile-Time Introspection of C++’, ACCU 2021, https://www.youtube.com/watch?v=60ECEc-URP8
[Wu22] Yongwei Wu, ‘Compile-Time Strings’, Overload, 30(172):4-7, December 2022, https://accu.org/journals/overload/30/172/wu/
[Wu24] Yongwei Wu, ‘C++ Compile-Time Programming’, Overload, 32(183):7-13, October 2022, https://accu.org/journals/overload/32/183/wu/>
Wu Yongwei는 프로그래머이자 소프트웨어 아키텍트로 일해 왔으며, 현재는 현대 C++에 관한 컨설턴트이자 트레이너다. 그는 C와 C++의 시스템 프로그래밍과 아키텍처 분야에서 거의 30년의 경험을 가지고 있다. 그의 관심 분야는 C++ 언어, 소프트웨어 아키텍처, 성능 튜닝, 디자인 패턴, 코드 재사용이다. 그는 http://wyw.dcweb.cn/에 프로그래밍 페이지를 운영하고 있다.