directx 팀 프로젝트를 준비하는 과정에서 에디터를 담당하게 됐고, 에디터를 구현해보고자 리플렉션을 공부하기 시작했다.
필수적으로 구현 할 에디터 기능
1. 오브젝트 생성
2. 컴포넌트 부착
3. 오브젝트와 컴포넌트의 속성 변경
4. 씬 저장, 불러오기 및 병합 (팀원들이 각자의 씬에서 컨텐츠를 개발하고 씬들을 병합시킬 계획)
컴포넌트를 추가하거나 삭제해서 만들어진 씬을 저장하고 불러오며 컴포넌트의 속성을 GUI에서 조작할 수 있게 만드는 것이다.
리플렉션은 프로그램이 런타임중에 자기 자신의 구조를 탐색하고 이해하는 능력을 의미한다. 이는 주로 클래스나 구조체와 같은 데이터 타입의 정보를 동적으로 가져오고 조작하는 기술을 말한다. 주로 컴포넌트 기반 아키텍처에서 사용되며, 런타임 중에 객체의 속성들에 접근하거나 호출하여 작업을 수행할 수 있다. 주로 직렬화와 역직렬화 과정에서 객체의 상태를 저장하고 불러오는 데 활용된다.
처음에는 리플렉션을 사용하지 않고도 맵 에디터를 하드코딩으로 구현할 수 있지 않을까라는 의문을 가졌다. 그러나 맵 에디터를 사용하기 위해서는 객체의 구조를 동적으로 변경하고 객체 정보를 유연하게 다뤄야 한다. 수동으로 모든 인터페이스에 맞춰 코드를 작성하면 구조 변경이나 새로운 요소 추가 시 수정해야 할 코드가 급격히 늘어난다. 반면, 리플렉션을 제대로 구현하면 새로운 객체나 속성도 추가 작업 없이 자동으로 처리할 수 있어 개발 효율성이 크게 향상된다.
(처음에는 모든 인터페이스를 직접 코딩하여 에디터를 구현했는데, 엔진과 콘텐츠 확장을 고려할 때 대응해야 할 코드가 너무 많았다.)
#include <vector>
#include "Reflect.h"
struct Node {
std::string key;
int value;
std::vector<Node> children;
REFLECT() // Enable reflection for this type
};
int main() {
// Create an object of type Node
Node node = {"apple", 3, {{"banana", 7, {}}, {"cherry", 11, {}}}};
// Find Node's type descriptor
reflect::TypeDescriptor* typeDesc = reflect::TypeResolver<Node>::get();
// Dump a description of the Node object to the console
typeDesc->dump(&node);
return 0;
}
// Define Node's type descriptor
REFLECT_STRUCT_BEGIN(Node)
REFLECT_STRUCT_MEMBER(key)
REFLECT_STRUCT_MEMBER(value)
REFLECT_STRUCT_MEMBER(children)
REFLECT_STRUCT_END()
템플릿과 매크로로 덕지덕지 붙어있어서 코드 분석이 어렵기에 매크로를 벗겨서 차근차근 분석해보고자 한다.
#include <vector>
#include "Reflect.h"
// REFECT() 매크로 해석
struct Node {
std::string key;
int value;
std::vector<Node> children;
// Declare the struct's type descriptor:
static reflect::TypeDescriptor_Struct Reflection;
// Declare a function to initialize it:
static void initReflection(reflect::TypeDescriptor_Struct*);
};
int main() {
// Create an object of type Node
Node node = {"apple", 3, {{"banana", 7, {}}, {"cherry", 11, {}}}};
// Find Node's type descriptor
reflect::TypeDescriptor* typeDesc = reflect::TypeResolver<Node>::get();
// Dump a description of the Node object to the console
typeDesc->dump(&node);
return 0;
}
// Definition of the struct's type descriptor:
reflect::TypeDescriptor_Struct Node::Reflection{Node::initReflection};
// Definition of the function that initializes it:
void Node::initReflection(reflect::TypeDescriptor_Struct* typeDesc) {
using T = Node;
typeDesc->name = "Node";
typeDesc->size = sizeof(T);
typeDesc->members = {
{"key", offsetof(T, key), reflect::TypeResolver<decltype(T::key)>::get()},
{"value", offsetof(T, value), reflect::TypeResolver<decltype(T::value)>::get()},
{"children", offsetof(T, children), reflect::TypeResolver<decltype(T::children)>::get()},
};
}
우선 main함수가 실행하기에 앞서서 정적변수 초기화가 선언된
reflect::TypeDescriptor_Struct Node::Reflection{Node::initReflection};
부분이 가장 먼저 실행된다. Node 구조체에 있는 Reflection 생성자가 실행되고, 매개변수로 Node::initReflection 함수포인터를 보내게 된다.
TypeDescriptor_Struct(void (*init)(TypeDescriptor_Struct*)) : TypeDescriptor{ nullptr, 0 }
{
init(this);
}
매개변수로 TypeDescriptor_Struct타입을 받는 함수포인터를 init으로 받게되고 init에 this를 넣음으로써
void Node::initReflection(reflect::TypeDescriptor_Struct* typeDesc) {
using T = Node;
typeDesc->name = "Node";
typeDesc->size = sizeof(T);
typeDesc->members = {
{"key", offsetof(T, key), reflect::TypeResolver<decltype(T::key)>::get()},
{"value", offsetof(T, value), reflect::TypeResolver<decltype(T::value)>::get()},
{"children", offsetof(T, children), reflect::TypeResolver<decltype(T::children)>::get()},
};
}
해당 코드가 실행된다.
이 부분은 즉 Node 구조체에 있는 TypeDescriptor_Struct 타입 Reflection을 초기화하는 역할을 맡고 있다.
이제 main 함수로 넘어가서 보자.
// Create an object of type Node
Node node = { "apple", 3, {{"banana", 7, {}}, {"cherry", 11, {}}} };
노드 객체를 생성하고 초기화 하고 있다.
// Find Node's type descriptor
reflect::TypeDescriptor* typeDesc = reflect::TypeResolver<Node>::get();
TypeResolver 템플릿 함수를 사용하여 Node 타입에 대한 TypeDescriptor를 얻어온다.
get 함수까지 자세히 들여다 보자.
// This is the primary class template for finding all TypeDescriptors:
template <typename T>
struct TypeResolver
{
static TypeDescriptor* get()
{
return DefaultResolver::get<T>();
}
};
static으로 선언된 TypeDescriptor* get() 함수를 호출하고, (static으로 선언해서 템플릿의 각 인스턴스마다 객체를 생성하지 않고도 멤버 함수를 호출 할 수 있다.)
// A helper class to find TypeDescriptors in different ways:
struct DefaultResolver
{
template <typename T> static char func(decltype(&T::Reflection));
template <typename T> static int func(...);
template <typename T>
struct IsReflected
{
enum { value = (sizeof(func<T>(nullptr)) == sizeof(char)) };
};
// This version is called if T has a static member named "Reflection":
template <typename T, typename std::enable_if<IsReflected<T>::value, int>::type = 0>
static TypeDescriptor* get()
{
return &T::Reflection;
}
// This version is called otherwise:
template <typename T, typename std::enable_if<!IsReflected<T>::value, int>::type = 0>
static TypeDescriptor* get()
{
return getPrimitiveDescriptor<T>();
}
};
enable_if 에서 첫 번째 매개변수로 들어간 IsReflected함수에서 value 값을 통하여 true/false 를 꺼내온다. 작동원리는
template <typename T> static char func(decltype(&T::Reflection));
template <typename T> static int func(...);
위 코드를 통하여 T 구조체 또는 클래스 내부에 Reflection 변수가 있다면
template <typename T> static char func(decltype(&T::Reflection));
코드가 골라지는데 func(nullptr)을 보면 매개변수로 nullptr이 들어가있는데 이는 c++에서 함수 포인터의 크기가 일반적으로 포인터의 크기와 동일하다는 특징이 있기에 &T::Reflection 에서 정적 멤버의 주소를 갖는 함수 포인터를 생성하는 역할이 된다. 하지만 여기서 중요한 점은 템플릿 인자 치환 과정에서 실패가 발생하더라도 컴파일 에러로 간주하지 않고 다른 함수나 템플릿이 선택되도록 하여 value 값에 sizeof(char), sizeof(int)값이 들어가게끔 하여 == 연산자를 사용하여 reflection 변수가 있는지 없는지를 체크해낸다.
enable_if ?
이 부분에서 살짝 머리가 아팠는데 모두의 코드를 보고 공부했다. 씹어먹는 C ++ 토막글 3 - SFINAE 와 enable_if
C++ 11 표준의 14.8.2 조항에 따르면 템플릿 인자 치환에 실패할 경우 (위 같은 경우) 컴파일러는 이 오류를 무시하고, 그냥 오버로딩 후보에서 제외하면 된다 라고 명시되어 있다.
C++ 에선 흔히 이를 치환 실패는 오류가 아니다 - Substitution Failure Is Not An Error 혹은 줄여서 SFINAE 라고 한다.
즉 enable_if 를 활용하여 오버로딩 후보에서 제외시켜서 적합한 템플릿 함수를 실행시키게된다.
template<bool B, class T = void> struct enable_if {};
template<class T> struct enable_if<true, T> { typedef T type; };
enable_if 의 템플릿 첫번째 인자로 true가 오면, enable_if 구조체 안에는 type이라는 자료형이 생긴다. 따라서, enable_if의 템플릿 첫번째 인자로 bool값을 반환하는 구문을 넣어두고 해당 구문에서 판별을 요청하면, 결과값에 따라 type이 생기거나 생기지 않는 것을 이용할 수 있다.
template <typename T, typename std::enable_if<IsReflected<T>::value, int>::type = 0>
해당 코드에서 enable_if에 두번째 매개변수로 int 를 넣은것은 true일 경우 T값을 얻어내서 typedef T type 을 하기위함으로 double이나 float으로 사용하여도 무방하다.
// Dump a description of the Node object to the console
typeDesc->dump(&node);
노드 객체에 대한 설명을 콘솔에 띄운다.
reflect::TypeDescriptor* typeDesc = reflect::TypeResolver<Node>::get();
get 함수를 사용하여 Node에 대한 TypeDescriptor를 얻어왔고 이를 사용하여 dump함수를 호출하여서 virtual 키워드를 통해 가상함수가 실행된다.
virtual void dump(const void* obj, int indentLevel /* = 0 */) const override
{
std::cout << name << " {" << std::endl;
for (const Member& member : members)
{
std::cout << std::string(4 * (indentLevel + 1), ' ') << member.name << " = ";
member.type->dump((char*)obj + member.offset, indentLevel + 1);
std::cout << std::endl;
}
std::cout << std::string(4 * indentLevel, ' ') << "}";
}
데이터 메모리 주소 offset을 활용하여 void*obj로 들어온 구조체/클래스에서 메모리 접근 -> 데이터를 가져오는데, 저장해둔 멤버들을 범위기반 for 루프를 통해서 접근한다.
노드의 이름과 멤버변수들을 차례대로 dump함수를통해 실행시켜서 콘솔창에 출력시킨다.
Node {
key = std::string{"apple"}
value = int{3}
children = std::vector<Node>{
[0] Node {
key = std::string{"banana"}
value = int{7}
children = std::vector<Node>{}
}
[1] Node {
key = std::string{"cherry"}
value = int{11}
children = std::vector<Node>{}
}
}
}
Github Reflection
A Flexible Reflection System in C++: Part 1
잘 읽었읍니다.