안녕하세요. 저에요.

오늘은 C++에서 버추얼 테이블이 왜 중요한지에 대해 이야기해보려고 해요.

버추얼 테이블은 C++의 큰 특징 중 하나인 다형성을 가능하게 해주는 친구인데요.

모른다니... 그럼 아셔야 합니다.

바로 시작해 볼게요.

 


버추얼 테이블이란?

버추얼 테이블은 C++에서 다형성을 지원하는 중요한 구조예요. 객체 지향 프로그래밍에서 다형성을 구현하기 위해서는 가상 함수를 사용해야 하는데, 이 가상 함수들이 어떻게 메모리에서 구성되는지를 관리하는 것이 바로 버추얼 테이블이에요.

이미지 출처: 다형성에 관하여 2 - 가상 함수 테이블 - - Yoo’study

 

다형성에 관하여 2 - 가상 함수 테이블 -

요약

yoojaejun.github.io


C++에서의 다형성

C++에서 인터페이스라는 개념을 많이 들어보셨나요?

모르면 코드로 같이 볼까요?

// 인터페이스 정의 (순수 가상 함수만 가진 클래스)
class IAnimal {
public:
    virtual void Speak() = 0; // 순수 가상 함수
    virtual ~IAnimal() = default; // 가상 소멸자
};

// 인터페이스 구현 클래스 (Dog과 Cat)
class Dog : public IAnimal {
public:
    void Speak() override {
        std::cout << "월!" << std::endl;
    }
};

class Cat : public IAnimㅁal {
public:
    void Speak() override {
        std::cout << "냥!" << std::endl;
    }
};

// 실행 예제
int main() {
    IAnimal* dog = new Dog();
    IAnimal* cat = new Cat();

    dog->Speak(); // 월!
    cat->Speak(); // 냥!

    delete dog;
    delete cat;

    return 0;
}

 

분명 dog와 cat은 IAnimal타입입니다.

하지만 Speak을 호출한 순간, 다형성에 맞게 원래 생성했던 클래스에 맞는 메서드가 호출되었는데요.

다형성은 서로 다른 객체를 같은 인터페이스로 다룰 수 있게 해주는 개념이에요. C++에서는 이를 위해 가상 함수를 사용하고, 이 가상 함수들은 버추얼 테이블에 저장되어 있어요. 이 덕분에 프로그램 실행 중에 어떤 함수가 호출될지를 동적으로 결정할 수 있게 되죠.


버추얼 테이블의 성능

 

그럼 버추얼 테이블은 무적 아님? 개사긴데? 마구마구 써주마!!!

절!!! 대!!! 아닙니다요!!!!

버추얼 테이블은 다형성을 구현하는 데 필수적이지만, 성능에 영향을 미칠 수 있어요.

가상 함수를 호출할 때마다 현재 객체 -> 최종 객체를 판정하기 위해 테이블을 참조해야 하므로, 일반 함수 호출보다 약간의 오버헤드가 발생해요. 하지만 쓸 수밖에 없는 상황이 오기도 해서, 남용만 안 하면 이 정도 오버헤드는 감수하고 쓰는 편이긴 합니다.

이미지 출처: 씹어먹는 C++ - <6 - 3. 가상함수와 상속에 관련한 잡다한 내용들>

 

씹어먹는 C++ - <6 - 3. 가상함수와 상속에 관련한 잡다한 내용들>

모두의 코드 씹어먹는 C++ - <6 - 3. 가상함수와 상속에 관련한 잡다한 내용들> 작성일 : 2014-04-13 이 글은 54763 번 읽혔습니다. 에 대해서 배웁니다. 안녕하세요 여러분. 지난 강좌에서는 놀라움의 연

modoocode.com


마치며

버추얼 테이블은 C++의 다형성에서 매우 중요한 요소다요.

이를 통해 객체 지향 프로그래밍의 강력한 기능을 활용할 수 있게 되죠.

사실 매우 중요한 요소라 다음에 심화적으로 한번 더 포스팅할 계획입니다요.

다음에 또 만나용.

 

Good Bye

안녕하세요. 저에요.

오늘은 여유가 생겨서 나중에 메모리 풀을 구현해보고 싶다는 로망을 실현해 보기로 했어요.

차후 자체 엔진에 도입할 수도 있기 때문이지요.

 

결론부터 말하면 제가 꿈에 그리던 메모리 풀은 사실상 쓰레기였어요. ㄱ-

 

그렇기에 적어보는 글이다요.

삽질에 대한 내용을 적는 일기 개념이기 때문에 메모리 풀이 뭔지는 설명 안 하겠다요.

 

본론으로 바로 들어가자면,

가장 유명하고도 성능 좋은 메모리 풀은 하나의 객체에 대한 타입만을 메모리 풀링해주는 방식이에요.

왜냐하면, 하나의 객체에 대한 메모리만을 풀링 해주면 객체의 사이즈가 고정이기 때문에 메모리 단편화 현상을 아예 없앨 수 있기 때문이에요. 단편화 현상이 없으면 조각화 현상에 대해서 코스트를 투자하지 않아도 되기 때문에 가장 빠르다고 알려진 메모리 풀 패턴이에요.

제가 짜본 코드로 같이 봐볼까요?

더보기
/// 메모리 풀에서 관리되는 모든 객체는 이 클래스를 상속해야 함
class Instance
{
public:
	Instance() = default;
	virtual ~Instance() = default;
	size_t mMemoryIndex = (size_t)-1;
};

/// 고정 크기 메모리 풀
template<typename T>
class MemoryPool
{
	friend class Instance;
public:
	MemoryPool(size_t elementSize, size_t capacity) : mElementSize(elementSize), mCapacity(capacity)
	{
		mMemory = static_cast<char*>(malloc(elementSize * capacity));
		assert(mMemory);

		// 초기 free index 테이블 구성
		for (size_t i = 0; i < capacity; ++i)
		{
			mFreeIndices.push(i);
		}
	}

	~MemoryPool()
	{
		free(mMemory);
	}

	template<typename... Args>
	T* Alloc(Args&&... args)
	{
		if (sizeof(T) > mElementSize) {
			printf("Error: Object size exceeds pool element size.\n");
			return nullptr;
		}

		if (mFreeIndices.empty()) {
			printf("Error: MemoryPool is full.\n");
			return nullptr;
		}

		size_t index = mFreeIndices.top();
		mFreeIndices.pop();
		void* mem = mMemory + (index * mElementSize);
		T* obj = new (mem) T(std::forward<Args>(args)...);
		obj->mMemoryIndex = index;
		return obj;
	}

	void Free(T* instance)
	{
		if (!instance) return;

		size_t index = instance->mMemoryIndex;
		assert(index < mCapacity);
		instance->~T();
		mFreeIndices.push(index);
	}

private:
	size_t mElementSize;
	size_t mCapacity;
	char* mMemory;
	std::stack<size_t> mFreeIndices;
};

사실 이 코드도 특이한 방식이긴 해요.

Instance객체를 상속해야 하는 방식으로 작성되었는데, 이는 추후 자체 엔진에 Instance Manager를 만들어서 풀링 하려고 했기 때문이에요.

하지만 저는 이게 너무 싫었어요. (홍대병)

이 방식은 풀링할 객체 타입이 많아질수록 메모리 풀을 해당 객체 수만큼 만들어줘야 했기 때문인데요?

예를 들어, Transform객체와 Object객체...등 메모리 풀링을 해야 할 객체가 만들어질 때마다 Transform 메모리 풀, Object 메모리 풀... 전부 만들어줘야 하기 때문이에요.

그래서 모든 타입을 풀링할 수 있도록 설계해 봤습니다.

이름하야 Custom Dynamic Memory Pool ! ! !

하지만 모든 타입을 풀링 하려면 필연적으로 조각화 현상이 생기는데요.

때문에 객체를 삭제할 때마다 해당 객체의 뒤에 위치한 메모리를 memmove로 당겨오는 방식(vector의 erase처럼)을 채택했습니다.

코드로 볼까요?

더보기
// 내부 관리용 객체: 메모리 위치, 사이즈, 실제 객체 포인터 등을 담는다
class Instance
{
public:
    void* mPtr = nullptr;
    int    mID = -1;
    size_t mOffset = 0;
    size_t mSize = 0;
};

class MemoryPool
{
public:
    MemoryPool(size_t capacity)
        : mCapacity(capacity), mUsedMemory(0)
    {
        mMemory = static_cast<char*>(malloc(capacity));
        assert(mMemory && "Memory allocation failed");
    }

    ~MemoryPool()
    {
        free(mMemory);
    }

    // 객체 할당
    template <typename T, typename... Args>
    int Alloc(Args&&... args)
    {
        size_t size = sizeof(T);
        size_t start = mUsedMemory;
        size_t end = mUsedMemory + size;

        if (end > mCapacity) {
            std::cerr << "MemoryPool Full!\n";
            return -1;
        }

        void* mem = mMemory + start;
        T* obj = new (mem) T(std::forward<Args>(args)...);

        Instance meta;
        meta.mID = ++UID;
        meta.mOffset = start;
        meta.mSize = size;
        meta.mPtr = obj;

        mInstanceMap[meta.mID] = meta;
        mInstanceList.push_back(&mInstanceMap[meta.mID]);

        mUsedMemory += size;
        return meta.mID;
    }

    // 객체 접근
    template <typename T>
    T* Get(int handle) {
        auto it = mInstanceMap.find(handle);
        if (it == mInstanceMap.end()) return nullptr;
        return reinterpret_cast<T*>(mMemory + it->second.mOffset);
    }

    // 객체 해제 + 조각화 제거
    void Free(int handle) {
        auto it = mInstanceMap.find(handle);
        if (it == mInstanceMap.end()) return;

        Instance& instance = it->second;
        size_t offset = instance.mOffset;
        size_t size = instance.mSize;
        size_t tail = offset + size;
        size_t remain = mUsedMemory - tail;

        // 메모리 이동
        memmove(mMemory + offset, mMemory + tail, remain);

        // 모든 Instance의 offset 수정 및 포인터 갱신
        for (auto& meta : mInstanceList) {
            if (meta->mOffset > offset) {
                meta->mOffset -= size;
                meta->mPtr = mMemory + meta->mOffset;
            }
        }

        // 리스트에서 제거
        mInstanceList.erase(
            std::remove_if(mInstanceList.begin(), mInstanceList.end(), [&](const Instance* i) {
                return i->mID == instance.mID;
                }),
            mInstanceList.end()
        );
        mInstanceMap.erase(it);

        mUsedMemory -= size;
    }

private:
    size_t mCapacity;
    size_t mUsedMemory;
    char* mMemory;

    std::vector<Instance*> mInstanceList;
    std::unordered_map<int, Instance> mInstanceMap;
};

급하게 짜느라 조잡하긴 하다...

짜고 보니까 문제가 생각보다 많았습니다.


1. memmove가 기대 이하의 퍼포먼스를 보여줬다.

일단 memmove를 제대로 공부하지 않고 멋대로 쓴 게 패착이었습니다.

당연히 저수준 메모리 이동은 비용이 적을 줄 알았는데, 생각보다 느렸습니다. ㅋㅋ

근데 아직도 왜 이렇게 느린지 모르겠는데, 이건 더 공부해 봐야겠네요.

아무튼 매우 느리답니다.

참고로 memcpy로 교체도 해봤는데 속도는 똑같았습니다.


2. 메모리만 옮겨서 끝이 아니라 메타 정보도 바꿔줘야 했다.

일단 모든 타입에 메모리를 풀링 하려다 보니, 각 타입마다 사이즈가 달라 메모리를 풀링 하기 어려웠습니다.

때문에 해당 메모리의 Offset과 Size 등의 메타정보가 있어야 메모리를 해제해 줄 수 있었는데요.

객체를 삭제하면 뒤에 있는 메모리를 옮겨오는 것까진 했는데, 그럼 뒤에 있는 객체의 메타 정보도 하나하나 수정해줘야 했습니다.

그래서 이미 이때부터 아...망했구나를 직감했습니다.


3. 메모리 이동 시 버추얼 테이블이 깨진다.

이건 좀 충격이었습니다...

속도 다 버리고 구현이라도 해보자는 느낌으로 구현 다 해놨는데. 자꾸 Free 할 때 터지는 거예요. 디버깅으로 보니까 버추얼 테이블이 NULL이더라구요.

추후 자체 엔진에 도입하기 위해 Instance라는 객체를 상속받는 구조로 설계했기 때문에 버추얼 테이블이 있어야 상속 트리를 타고 소멸자가 호출되는 구조인데, 버추얼 테이블이 NULL라 터지는 것이었습니다.

결과적으로, 메모리를 옮기는 건 저수준 작업이라 버추얼 테이블은 안 옮겨진다고 하네요.... 때문에 상속 기반 객체는 메모리 풀링에 사용할 수 없었습니다.

제가 2번에 언급했던 메타 정보를 하나하나 바꿔줘야 했던 것처럼, 버추얼 테이블도 옮겨주는 작업이 필요한데? 사실상 불가능하다고 합니다.

그래서 상속도 안 하고 사용하게 만들었습니다... 못쓰더라도 끝은 봐야 하니까...


End. 속도 비교

대망의 속도 비교가 빠질 수 없겠죠? 사실 이미 망한 거 알고 있지만 테스트해봤습니다.

 

(Custom Dynamic Memory Pool vs New/Delete)

... 그만 알아보도록 하자.


그래서 제 인생은 망했습니다.

그냥 new/delete나 씁시다. 와~~

애매하게 만들 거면 만들지 말기로...

 


번외 1. 고정 타입 메모리 풀의 성능은 어떠할까?

아까 고정 타입 메모리 풀 코드도 올렸는데요?

이건 성능이 어떻게 나올까요?

(Fixed Type Memory Pool vs New/Delete)

 

와 ㅋㅋㅋㅋ

역시 유명한 패턴엔 이유가 있습니다. 그냥 이거나 써야겠다. 와~~


번외 2. 왜 저는 님 코드 복사해서 해봤는데 차이가 별로 안 나요?

디버그 말고 릴리즈로 실행해 보세요.

비주얼스튜디오 디버그는 생각보다 사람 손을 탈수록 많이 느리답니다?

비단 메모리 풀만이 아니라 이런저런 코드도 디버그에서 매우 느린 경우가 많아요.

안녕하세요. 저에요.

저번에 다중 상속에서의 캐스팅 주의점에 대해 알아봤는데요.

오늘은 어떻게 그런 식으로 캐스팅이 동작하는지 알아볼 거예요. (대충 지루하고 현학적인 이야기다요)

C++스타일의 cast에는 여러 종류가 있는데요.

오늘은 그중에서 가장 많이 쓰이는 cast은 static_cast와 dynamic_cast에 대해 알아봅시다.


● static_cast

static_cast에 대해 많은 오해를 하고 있는 부분이 있어요.

그건 바로 static_cast는 컴파일 타임에 에러를 검출해 낸다는 것인데요.

근데 써보신 분들은 이런 경험을 겪으신 적이 있을 겁니다.

난 분명 static_cast를 썼고, 컴파일도 제대로 되었는데, 런타임 중에 static_cast에서 터졌어!!

 

분명 컴파일 타임에 에러를 검출해 준다 했는데... 컴파일이 되었는데 터졌어.... 뭐지... 뭔가 일어나고 있음...

 

이런 경우를 겪으신 분 계신가요? (전 없긴함)

제대로 알고 써야 합니다!!

static_cast는 컴파일 타임에서 A와 B에 대한 상속 관계에 대한 합리성만 판단합니다.

무슨 말인지 예시를 들어보자면, (대충 지루하고 현학적인 예시)

영희는 철수에게 생일선물을 주려고 한다.
영희는 철수가 닌텐도를 가지고 싶다는 이야기를 들었다..
하지만 철수가 무슨 기종을 가지고 싶은지 명확히 모른다.....
그래서 영희는 최신 기종인 닌텐도 스위치를 선물하기로 했다!
아뿔싸! 철수가 가지고 싶었던 기종은 닌텐도 DS였다!!!!

이런 상황인 것이에요.

런타임 중에 static_cast가 터졌다는 것은,

닌텐도 DS든, 닌텐도 Wii든, 닌텐도 스위치든 전부 닌텐도라 할 수 있는데요.

영희는 이런 상황에 닌텐도 스위치가 닌텐도라는 합리성만 판단하고 위험하게 닌텐도 스위치를 선물한 겁니다.

 

코드로 표현하자면 이런 거죠.

class Nintendo {};
class Nintendo_Switch : public Nintendo {};
class Nintendo_DS : public Nintendo {};

// 1. 철수: 아 닌텐도가 가지고 싶다!
Nintendo_DS* ds = new Nintendo_DS(); 

// 2. 영희: 음... 철수가 닌텐도가 가지고 싶다 했지...
Nintendo* nt = ds; // Nintendo로 업 캐스팅

// 3. 영희: 그럼 최신 기종인 닌텐도 스위치를 선물해야겠다!
Nintendo_Switch* sw = static_cast<Nintendo_Switch*>(nt); // 컴파일러는 허용, 그러나 런타임에서는 잘못된 다운캐스팅

컴파일러 입장에선 Nintendo_Switch* ⇐ Nintendo* 관계가 성립하므로 캐스트 자체는 허용되지만, 실제로 ds는 Nintendo_Switch의 주소를 가리키는 게 아니기 때문에 알 수 없는 동작을 하게 됩니다.(보통은 터짐)

 

그러면 이런 상황에서 안전하게 확인하는 방법이 뭘까요?

그건 바로 dynamic_cast입니다!


● dynamic_cast

dynamic_cast는 말 그대로 안전한 캐스팅입니다.

RTTI를 통해 하나하나 유효성 검사를 실시하기 때문인데요.

RTTI란?
Run-Time Type Information의 약자로, 런타임에서의 객체들의 정보를 뜻합니다.

때문에 dynamic_cast는 캐스팅 시, 해당 객체에 대한 타입과 캐스팅 대상 타입을 상속 트리를 통해 하나하나 검사합니다.

실패할 시 터지지 않고 반환 값을 nullptr을 반환해 실패 여부도 확인할 수 있죠.

그럼 무조건 dynamic_cast 써야겠네? 무적이네?

절!!! 대!!! 아닙니다!!!!

하나하나 검사하는데 빠를 리가 없겠죠?

그래서 dynamic_cast는 속도가 느립니다. 여러분 생각보다 훨씬 느려요.

그저 맞냐 or 아니냐를 따지는 과정이 말로는 간단하지만, 여러 상황에 대한 예외 처리 때문에 생각보다 복잡한 과정이 있다고 하더라구요.

심지어 상속 관계가 복잡하고 깊어질수록 더 느려지겠죠?

 

그래서 자체 엔진 제작할 때 일화를 들려드리자면,

GetComponent<>() 함수 같은 것들에 dynamic_cast를 썼었는데....

테스트해 보니까 속도가 좀 느리더라구요....

그래서 다음에 만들 엔진엔 타입별 ID를 해싱해서 부여하고, 해시 맵으로 성능향상을 노려볼까 합니다.


 마치며

이런 과정을 통해 cast함수들은 오프셋을 받아와 주소 재보정을 거치게 됩니다.

그래서 저희는 캐스팅 후에 주소 값이 달라진 걸 저번 포스팅을 통해 확인했구요.

 

이상 저였습니다. 감사합니다.

오늘 C++에 대한 빨간약을 또 하나 먹었다...

 

안녕하세요. 저에요.

오늘도 슬프게 코딩을 하던 도중 C++ 빨간약을 먹었는데요?

다중 상속에 대한 것인데, 매우 간과하고 사용하고 있었어요.

C++의 세계란 알아도 끝이 없네요.

 

함께 알아가보자.


상속의 구조

상속은 컴파일러가 어떤 식으로 처리할까요?

바로 상속 객체 단위로 메모리 레이아웃을 배치하게 되는데요. (알고계셨다구요? 그럼 바로 아래 파트로 넘어가시죠.)

그게 무슨 소리임? 하실까봐 예시 코드를 하나 가져왔습니다.

class A 
{ 
public: 
    int a_member; 
};
class B 
{
public: 
    int b_member; 
};
class C : public A, public B 
{
public: 
    int c_member; 
};

 

A와 B를 상속받은 클래스 C가 있다고 칩시다.
그럼 메모리 레이아웃은 어떻게 형성될까요?

바로 C를 만들면서 상속한 순서대로 레이아웃이 형성됩니다.

 

바로 아래처럼 형성되는데요.

저 같은 놈 말은 안믿는다고요?

 

그럼 비주얼 스튜디오 메모리 레이아웃을 당장 켜보세요.

그럼 제 말대로 나옵니다. ㅇㅅㅇ

 

그럼 검증해볼까요?

int main()
{
	C* c = new C;
	std::cout << "a_member : " << &c->a_member << std::endl;
	std::cout << "b_member : " << &c->b_member << std::endl;
	std::cout << "c_member : " << &c->c_member << std::endl;
	return 0;
}

위 예시 클래스를 이용해 main함수를 작성해 보았습니다.

결과는 어떻게 나올까요?

더보기

결과:

4byte 단위 오프셋이 제대로 적용된 모습입니다.

여담으로, C의 상속 순서를 뒤집으면 어떻게 될까요?

더보기

이렇게 상속하면...

이렇게 뜬답니다?
와~~~ 신기하다~~~

 

지금까지 상속의 구조를 알아봤는데요.

"이게 업캐스팅이랑 뭔 상관임? 내 시간 돌려놓으셈!!!"

 

 

다음 파트에서 설명해보겠습니다...


다중 상속에서의 업캐스팅? 알고 쓰자!

위 설명에 사용한 예제에 이어 다음과 같은 코드를 추가해봤습니다.

c를 할당하고, c를 A객체로 업캐스팅한 a와 B객체로 업캐스팅한 b가 있습니다.

class A { public: int a_member; };
class B { public: int b_member; };
class C : public B, public A { public: int c_member; };

int main()
{
	C* c = new C;
	A* a = c;
	B* b = c;
	void* aPtr = static_cast<void*>(a);
	void* bPtr = static_cast<void*>(b);
	void* cPtr = static_cast<void*>(c);
	std::cout << "a: " << aPtr << std::endl;
	std::cout << "b: " << bPtr << std::endl;
	std::cout << "c: " << cPtr << std::endl;
	return 0;
}

 

결과는 어떨까요?

똑같지 않을까요?

더보기

결과:

c == a, c != b

충격적이게도 c와 c를 업캐스팅한 a는 같지만 c를 업캐스팅한 b는 다르다...

결과를 보셨나요?

 

왜 이런 결과가 나왔을까요.

사실 앞서 한 상속의 구조 설명을 잘 읽어보면 답이 있습니다. (괜히 설명한게 아니라고)

c는 사실 사실 가장 첫 상속 객체의 시작 주소를 가리키는데요.

C 클래스는 A->B순서로 상속받았으니 사실상 C에 속한 A 상속 객체의 시작 주소를 가리키고 있는겁니다.


그래서 결과적으로,

c(생성한 원본 C 객체) >> (C 클래스는 C::A의 시작 주소다.)

a(C객체에서 업캐스팅한 A객체) >> (C::A의 시작 주소) 

이므로 c == a가 되는 것이고,

b(C객체에서 업캐스팅한 B객체) >> (C::B의 시작 주소 (C의 시작 주소(A)에서 A객체의 메모리 크기만큼 건너 뛴 주소)

이므로 c != a가 되는 것입니다.

따라서 b는 c혹은a의 + 4byte(A객체의 크기)만큼의 주소를 가지게 되는거죠.


사실 이렇게 보면 당연한 소린데, 왜 이런 생각을 안했는지 모르겠네요. 쩝


마치며,

근데 뭔가 이상하지 않나요?

제가 아는 캐스팅은 주소는 안바뀌고, 해당 자료형을 바꿔서 사용하는 줄 알았는데?

c의 주소를 b로 캐스팅하니까 주소가 바뀐거 아닙니까?

그렇다는 것은 캐스팅 메소드가 알아서 주소를 C::B의 오프셋을 적용해서 반환해줬다는 건데.... 이거 괜찮은거 맞음...?

그것에 대해선 다음 포스팅에 다뤄보겠습니다. (이거때문에 (1)이라 적은 것 << 퍽퍽)

 

어쨋든!

이래서 RAII 설계가 중요하다고 하는 것 같습니다.

객체의 생성을 담당한 주체가 소멸도 담당해야한다는 것이, 이런 문제가 발생할 수도 있으니까 그런 것 같습니다.

 

이상 저였습니다. 감사합니다.

+ Recent posts