ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Protobuf 따라하기] 수신 데이터를 복사 없이 가공하기
    개발/C·C++ 2024. 11. 8. 17:28

    고정 패킷 이후에 등장하는 가변 데이터는 많고 이를 vector로 관리하게 되면 어쩔 수 없이 복사 비용이 발생한다. 개발이 비교적 편한 건 장점이지만 성능 면에서는 단점이다. 이 단점을 개선하기 위해 포인터를 활용하면 유지보수성이나 가독성이 조금 떨어질 수 있지만 성능에서는 이점이 있으므로 트레이드-오프를 잘 생각해자.

     

    고정 패킷을 읽는 건 쉽다. 그냥 형 변환을 하면 된다.

    // 패킷
    #pragma pack(push, 1)
    struct PacketTest
    {
    	struct BuffListItem
    	{
    		uint64 buff_id;
    		float remain_time;
    	};
    
    	uint16 packet_size; // 공용 헤더
    	uint16 packet_id;	// 공용 헤더
    	uint64 id;
    	uint32 hp;
    	uint16 attack;
    	uint16 buffs_offset;
    	uint16 buffs_count;
    }
    #pragma pack(pop)
    
    PacketHeader* header = reinterpret_cast<PacketTest*>(buffer);

     

    사실 그다음 데이터를 읽는 것도 쉽다. 패킷 헤더에 buffs_offset이라는 정보가 있기 때문에 정확히 그만큼 포인터를 이동시킨 다음에 가변 데이터 타입의 포인터로 형 변환을 해서 읽으면 된다. 이를 얼마나 우아아게 하느냐가 중요하다. 지금 이 결과물은 우아하다고 할 수 있다.

    	PacketTest::Buffs buffs = header->GetBuffs();	
    	for (int i = 0; i < buffs.GetCount(); ++i)
    	{
    		printf("buff id: %llu, remain: %f\\n", buffs[i].buff_id, buffs[i].remain_time);
    	}
    
    	for (auto it = buffs.begin(); it != buffs.end(); ++it)
    	{
    		printf("buff id: %llu, remain: %f\\n", it->buff_id, it->remain_time);
    	}
    
    	for (auto& buff : buffs)
    	{
    		printf("buff id: %llu, remain: %f\\n", buff.buff_id, buff.remain_time);
    	}

    인덱스 연산자를 재정의했고 iterator를 쓸 수 있게 관련한 api도 정의가 되어 있다. 그래서 마치 vector를 사용하듯 api를 사용한다. buffs 객체의 타입은 PacketTest::buffs인데 buffs는 using을 이용해 별칭을 붙인 것이고 풀네임은 PacketTest::PacketDataWrapper<BuffListItem>이다. PacketDataWrapper 클래스를 살펴보자. 참고로 이 클래스는 템플릿인데 굳이 템플릿으로 안 만들어도 상관은 없다.

    template<typename T>
    class PacketDataWrapper
    {	
    	class Iterator
    	{
    	public:
    		Iterator(T* data): _data(data) {}
    
    		T& operator*() { return *_data; }
    		T* operator->() { return _data; }
    		bool operator!=(const Iterator& rhs) { return _data != rhs._data; }
    		Iterator& operator++() { ++_data; return *this; }
    		Iterator operator++(int32_t) { Iterator temp(_data); ++_data; return temp; }
    
    	private:
    		T* _data;
    	};
    
    public:
    	PacketDataWrapper() : _data(nullptr), _count(0) {}
    	PacketDataWrapper(T* data, uint16 count) : _data(data), _count(count) {}
    
    	T& operator[](uint16 index)
    	{
    		ASSERT_CRASH(index < _count);
    		return _data[index];
    	}
    
    	uint16 GetCount() { return _count; }
    
    	Iterator begin() { return Iterator(_data); }
    	Iterator end() { return Iterator(_data + _count); }
    
    private:
    	uint16 _count;
    	T* _data;
    };

    외부에는 BYTE* 데이터인 buffer를 PacketTest*으로 변환한 패킷 객체가 있는 걸 기억하자. 그렇기 때문에 PacketTest 구조체 안에서 this 포인터로 스스로에게 접근하고 buffs_offset을 이용해 가변 데이터에 접근할 수 있다. 때문에 PacketDataWrapper의 인수 있는 생성자를 통해 포인터를 받아 적절하게 가공하면 우리가 원하는 걸 얻을 수 있다. 처음부터 구현하는 마음으로 인덱스 연산자 먼저 살펴보자.

    template<typename T>
    class PacketDataWrapper
    {	
    public:
    	PacketDataWrapper() : _data(nullptr), _count(0) {}
    	PacketDataWrapper(T* data, uint16 count) : _data(data), _count(count) {}
    
    	T& operator[](uint16 index)
    	{
    		ASSERT_CRASH(index < _count);
    		return _data[index];
    	}
    
    	uint16 GetCount() { return _count; }
    
    private:
    	uint16 _count;
    	T* _data;
    };

    _data를 초기화할 때는 포인터를 가변 데이터의 시작 위치로 세팅을 할 것이기 때문에 비교적 손쉽게 구현할 수 있다. 인덱스를 잘못 썼을 때는 크래시를 내도록 하자. 물론 구현만 가지고 사용할 수 있는 것은 아니며 PacketTest 객체에서 사용할 수 있도록 추가 조치를 해줘야 한다.

    #pragma pack(push, 1)
    struct PacketTest
    {
    	// ...
    	using Buffs = PacketDataWrapper<BuffListItem>;
    	Buffs GetBuffs()
    	{
    		BYTE* data = reinterpret_cast<BYTE*>(this);
    		data += buffs_offset;
    		return Buffs(reinterpret_cast<BuffListItem*>(data), buffs_count);
    	}
    };
    #pragma pack(pop)

     

    배열처럼 사용하는 부분이 해결된다.

    PacketTest::Buffs buffs = pkt->GetBuffs();
    
    for (int i = 0; i < buffs.GetCount(); ++i)
    {
    	printf("buff id: %llu, remain: %f\\n", buffs[i].buff_id, buffs[i].remain_time);
    }

     

     

    Iterator 구현은 생각보다 쉽다. 사용하는 쪽의 코드를 먼저 보자. vector와 동일하게 생각해도 된다.

    for (auto it = buffs.begin(); it != buffs.end(); ++it)
    {
    	printf("buff id: %llu, remain: %f\\n", it->buff_id, it->remain_time);
    }
    
    for (auto& buff : buffs)
    {
    	printf("buff id: %llu, remain: %f\\n", buff.buff_id, buff.remain_time);
    }
    

    Iterator를 정의할 때는, 동작에 필요한 api만 만들어주면 된다. Iterator 클래스는 vector와 동일하게 자료구조의 내부에 위치하겠다. 당장 눈에 보이는 api는 ++, ≠, →이며 범위 기반 반복문에서 필요한 건 [값]이기 때문에 *도 재정의해야 한다. 참조 변수는 값으로 초기화해야 하기 때문이다.

    begin(), end() 함수는 PacketDataWrapper에서 만들어주면 된다. 차근차근 해보자. Iterator 클래스는 원본 데이터의 포인터를 받아야 하는데, 순회하면서 데이터에 접근할 수 있어야 하기 때문이다. 생성자로 받으면 된다. 참고로 Iterator를 구현할 때 인덱스는 쓰는 방법도 있지만, 개인적으로 직접 포인터 연산하는 게 간편하다 생각해 포인터를 이용했다.

    template<typename T>
    class PacketDataWrapper
    {	
    	class Iterator
    	{
    	public:
    		Iterator(T* data): _data(data) {}		
    	private:		
    		T* _data;
    	};
    

    *, → 먼저 해보자

    T& operator*() { return *_data; }
    T* operator->() { return _data; }
    

    *는 역참조 연산자이기 때문에 포인터가 가리키는 대상을 반환하면 되므로 참조 반환을 한다. →는 멤버 접근 연산자이고 포인터에 접근하기 때문에 포인터 반환을 한다. 그다음도 어렵지 않으니 한 번에 이어서 구현하자.

    bool operator!=(const Iterator& rhs) { return _data != rhs._data; }
    Iterator& operator++() { ++_data; return *this; }
    Iterator operator++(int32_t) { Iterator temp(_data); ++_data; return temp; }
    

    여기까지 했으면 begin(), end() 함수는 매우 간단하다.

    Iterator begin() { return Iterator(_data); }
    Iterator end() { return Iterator(_data + _count); }
    

    end()는 마지막 요소 다음을 가리키므로 _count를 더하면 된다.

     

    이제 buffs 객체를 배열처럼 쓸 수 있고 iterator로 쓸 수 있게 됐다.

    댓글

Designed by Tistory.