• Feed
  • Explore
  • Ranking
/
/
    C++

    [C++] 리플렉션(Reflection) 구현하기 #3

    변수 정보 생성하고 값 읽고 쓰기
    ReflectionC++
    뇽
    뇽
    2025.01.30
    ·
    12 min read

    3015

    직접 부딪히고 공부하며 작성한 코드라 많이 부족한 예제와 설명일 수 있습니다. 틀린 설명이 있거나 더 좋은 방법이 있다면 의견은 언제나 환영입니다!

    이번 포스팅의 목표는 멤버 변수 리스트 출력하기(변수 타입, 변수 이름, 변수 값)이다. ClassInfo 에 Property 의 정보를 자동으로 등록해주는 매크로는 아래와 같다.

    #define PROPERTY(PropertyName) \
    inline static struct Regist_##PropertyName \
    { \
    	Regist_##PropertyName() \
    	{ \
    		PropertyRegister<ClassType, decltype(ClassType::PropertyName)> propertyRegister_##PropertyName \
    		{ \
    			PropertyInitializer{ #PropertyName, typeid(decltype(ClassType::PropertyName)).name(), GetMemberOffset<ClassType, decltype(ClassType::PropertyName)>(&ClassType::PropertyName) } \
    		}; \
    	} \
    } regist_##PropertyName; \
    \

    실제로 생성되는 변수의 클래스는 다음과 같다.

    class Property
    {
    public:
    	virtual std::string GetName() = 0;
    	virtual std::string GetType() = 0;
    
    	virtual void GetValue(void* classInstance, void* outValue) = 0;
    	virtual void SetValue(void* classInstance, std::any value) = 0;
    
    	virtual void PrintSignature() = 0;
    };
    
    template <typename T>
    class PropertyHandler : public Property
    {
        // 아래에서 설명
    	using BytePtr = char*;
        using PropertyType = T;
    
    public:
        // 변수 정보를 초기화하는 생성자
    	Property(const PropertyInitializer& initializer)
    		: propertyName(initializer.name)
    		, propertyType(initializer.type)
    		, propertyOffset(initializer.offset)
    	{
    	}
        
        // 변수의 이름과 자료형을 반환하는 Getter
    	std::string GetName() override { return propertyName; }
    	std::string GetType() override { return propertyType; }
    
        // 변수의 값을 반환하는 함수
    	void GetValue(void* classInstance, void* outValue) override;
     
        // 변수의 값을 설정하는 함수
    	template <typename T>
    	void SetValue(void* classInstance,  std::any value) override;
    
        // 자신의 정보를 출력하는 함수
    	void PrintSignature() override;
    
    private:
    	std::string propertyName;   // 변수 이름
    	std::string propertyType;   // 변수 자료형
    	std::size_t propertyOffset; // 변수 오프셋
    };
    3016

    리플렉션이 완료된 후, PrintClassInfo() 함수를 호출해 클래스 정보를 호출한 결과물이다. 이번 포스팅에서는 화살표로 표시해둔 부분(멤버 변수 리스트)을 출력하는 것을 목표로 하고, 값을 읽고 쓰는 것까지 해보려고 한다.

    class MyClass : public SuperClass
    {
        GENERATED_BODY(MyClass)
    
    private:
        int level = 3;
        PROPERTY(level)
    };

    나는 변수 밑에 PROPERTY(변수명) 매크로를 넣어 변수 정보를 ClassInfo 에 자동으로 등록하는 방식을 사용했다.

    int level = 3;	
    inline static struct Regist_level
    {
    	Regist_level()
        {
    		PropertyRegister<ClassType, decltype(ClassType::level)>
            propertyRegister_level
            {
    			PropertyInitializer{
    				"level",
    				typeid(decltype(ClassType::level)).name(),
    				GetMemberOffset<ClassType, decltype(ClassType::level)>(&ClassType::level)
    				}
    		};
    	}
    } regist_level;

    PROPERTY 매크로를 확장해보면 다음과 같다.

    1. 변수명을 이용해 계속해서 새로운 구조체를 선언해주는 것을 볼 수 있다. 클래스에는 여러개의 변수가 있을 수 있고, 모든 변수를 자동으로 등록하고 관리해야 하기 때문에 이렇게 구현했다.

    2. inline static struct Regist_level : 변수를 등록하는 구조체를 정의한다. 프로그램 실행 시 단 하나의 객체만 생성되어야 하므로 static 키워드를 사용하고, 정적 변수를 헤더 파일에 포함할 수 있도록 하기 위해 inline 키워드를 사용했다. 정적 변수는 여러 번 정의되면 컴파일 오류가 나는데, inline 키워드를 붙이면 컴파일러가 한 번만 생성하도록 보장해준다고 한다.

    3. PropertyRegister<ClassType, decltype(ClassType::level)> propertyRegister_level{PropertyInitializer{ ... }}; : Regist_level 의 생성자에서 프로퍼티 등록기를 만들고, 초기화 구조체를 사용해 변수 정보를 초기화한 후, ClassInfo 에 변수를 등록해준다.

    4. regist_level; : 정적 구조체 변수를 생성하는 부분이다. regist_level 이 생성될 때 Regist_level 의 생성자가 불리며 클래스 정보에 변수가 등록되게 된다.

    변수 등록하기

    PropertyRegister

    template <typename OwnerClassType, typename PropertyType>
    class PropertyRegister
    {
    public:
    	PropertyRegister(const PropertyInitializer& initializer)
    	{
    		PropertyHandler<PropertyType>* property 
                   = new PropertyHandler<PropertyType>(initializer);
    		OwnerClassType::GetClass().AddProperty(initializer.name, property);
    	}
    };

    변수 등록기는 변수 초기화 구조체를 받아 변수의 메타데이터를 생성하고, 클래스에 변수를 등록하는 역할을 한다.

    	void AddProperty(std::string name, Property* property)
    	{
    		properties.insert(std::make_pair(name, property));
    	}

    ClassInfo 의 AddProperty 함수는 다음과 같다. 변수의 이름과 메타데이터의 주소를 받아 변수 리스트에 단순히 삽입해준다.

    PropertyInitializer

    struct PropertyInitializer
    {
    	PropertyInitializer(std::string name, std::string type, size_t offset)
    		: name(name)
    		, type(type)
    		, offset(offset)
    	{}
    	std::string name;
    	std::string type;
    	size_t offset;
    };
    PropertyInitializer {
    	"level", typeid(decltype(ClassType::level)).name(),
    	GetMemberOffset<ClassType, decltype(ClassType::level)>(&ClassType::level)
    }

    변수를 초기화해주는 초기화 구조체이다. 변수의 이름과 자료형, 오프셋 정보를 받는 것을 알 수 있는데, 하나씩 살펴보도록 하자.

    변수 자료형 받아오기

    typeid 는 C++의 런타임 타입 정보를 제공하는 기능인데, 객체의 실제 타입을 확인할 때 활용할 수 있다. decltype 은 컴파일 타임에 표현식의 타입을 가져오는 기능이며, 변수의 타입을 자동으로 추출할 때 유용하다. 나는 이 두 기능을 활용해 클래스의 멤버 변수 타입을 가져오도록 했다.

    1. decltype(ClassType::level) : 클래스의 level 변수를 가져와 해당 변수의 타입을 추출

    2. typeid(decltype(ClassType::level)).name() : 추출한 변수 타입을 typeid 를 이용해 문자열로 변환

    나는 변수의 타입을 문자열로 저장하도록 구현했지만, using 을 활용해 Property 클래스 내부에 타입을 가지고 있도록 해도 될 것 같다.

    클래스 멤버 변수 오프셋 계산하기

    template <typename ClassType, typename MemberType>
    constexpr std::size_t GetMemberOffset(MemberType ClassType::* member)
    {
    	return reinterpret_cast<std::size_t>(&((ClassType*)nullptr->*member));
    }

    어떻게 보면 변수 리플렉션에서 이부분이 가장 중요하다고 볼 수 있을 것 같다. 객체의 특정 멤버 변수에 직접 접근하려면, 멤버 변수가 클래스 객체 내에서 얼마나 떨어져 있는지 알아야 하기 때문이다.

    1. &((ClassType*)nullptr->*member) : 클래스 객체를 실제로 생성하지 않고 멤버 변수의 주소를 가져오기 위해 nullptr 을 ClassType* 로 변환해서 가짜 객체를 만든다. ->* 연산자는 객체 포인터에서 멤버 변수를 가리킬 때 사용하는 연산자이다. 해당 연산자를 사용해 멤버 변수의 상대적인 오프셋을 가져온 뒤, & 를 사용해 멤버 변수의 주소를 얻는다.

    2. reinterpret_cast<std::size_t>(...) : 주소를 정수로 변환하여 반환한다.

    level 변수는 클래스의 첫 번째 멤버 변수이므로, 오프셋은 0이다.

    변수 값 읽고 쓰기

    이제 변수에 접근해서 값을 읽고 쓰는 부분을 보자.

    	Property* FindPropertyByName(std::string name)
    	{
    		const auto& iter = properties.find(name);
    		if (iter != properties.end())
    		{
    			return iter->second;
    		}
    		else
    		{
    			return nullptr;
    		}
    	}

    ClassInfo 클래스에 FindPropertyByName 함수를 만들어 주었다. 문자열을 받아 변수의 메타데이터를 찾고 반환해주는 함수이다.

    GetValue

        Property* level = MyClass::GetClass().FindPropertyByName("level");
        if (level)
        {
    		int value;
    		level->GetValue(&myclass, &value);
        }

    값을 받아올 변수를 만들고, GetValue 함수에 클래스 인스턴스와 함께 전달해주면 된다. 타입 안정성은 고려하지 않았다.

    void GetValue(void* classInstance, void* outValue) override
    {
    	*reinterpret_cast<PropertyType*>(outValue) = *reinterpret_cast<PropertyType*>(reinterpret_cast<BytePtr>(classInstance) + propertyOffset);
    }

    PropertyHandler 클래스의 GetValue 함수이다.

    1. reinterpret_cast<BytePtr>(classInstance) : 먼저 클래스 인스턴스를 BytePtr 로 변환해준다. BytePtr 은 char* 를 별칭으로 지정해둔 것인데, 포인터 연산을 1바이트 크기로 동작하게 하기 위해서이다.

    2. + propertyOffset : 아까 계산해둔 멤버 변수의 오프셋이다. 객체의 시작 주소에서 오프셋만큼 이동하면 해당 멤버 변수의 주소를 얻을 수 있다.

    3. *reinterpret_cast<PropertyType*> : 이제 원래 프로퍼티의 타입으로 변환하고 * 를 통해 실제 변수 값에 접근해준다.

    4. void* 인 outValue 를 reinterpret_cast 를 통해 프로퍼티 타입의 포인터로 변환해주고, 역참조한 곳에 방금 가져온 값을 대입해준다.

    SetValue

    level->SetValue(&myclass, 6);

    SetValue 도 동작 방식은 똑같다. 다만 Set 할 값을 std::any 로 설정해 두었다.

    void SetValue(void* classInstance, std::any value) override
    {
    	*reinterpret_cast<PropertyType*>(reinterpret_cast<BytePtr>(classInstance) + propertyOffset) = std::any_cast<PropertyType>(value);
    }

    변수의 위치를 찾고, 그 위치에 방금 받아온 value 를 넣어주면 끝!

    구현 결과

    class MyClass : public SuperClass
    {
        GENERATED_BODY(MyClass)
    
    private:
        int level = 3;
        PROPERTY(level)
    
    	int height = 3643;
    	PROPERTY(height)
    
        float speed = 4.5f;
        PROPERTY(speed)
    };

    MyClass 에 여러개의 변수를 선언 및 초기화 해주고,

    int main()
    {
        MyClass myclass;
    
        Property* level = MyClass::GetClass().FindPropertyByName("level");
        if (level)
        {
    		int value;
    		level->SetValue(&myclass, 6);
    		level->GetValue(&myclass, &value);
    		level->PrintSignature();
    		std::cout << " = " << value << std::endl;
        }
    
    	Property* height = MyClass::GetClass().FindPropertyByName("height");
    	if (height)
    	{
    		int value;
    		height->GetValue(&myclass, &value);
    		height->PrintSignature();
    		std::cout << " = " << value << std::endl;
    	}
    
    	Property* speed = MyClass::GetClass().FindPropertyByName("speed");
    	if (speed)
    	{
    		float value;
    		speed->GetValue(&myclass, &value);
    		speed->PrintSignature();
    		std::cout << " = " << value << std::endl;
    
    		speed->SetValue(&myclass, 3.21f);
    		speed->GetValue(&myclass, &value);
    		speed->PrintSignature();
    		std::cout << " = " << value << std::endl;
    	}
    }

    콘솔로 출력해보면

    3018

    Get / Set 이 잘 되는 모습을 볼 수 있다 ᵔᴥᵔ







    - 컬렉션 아티클