C++ - vTable의 동작 원리

yys·2026년 8월 13일

TIL

목록 보기
84/85

vptr과 vTable


Character* Ptr만 보면 컴파일러가 아는 것은 Character*뿐인데, 런타임에는 어떻게 Player::Attack을 찾아갈까?

대부분의 C++ 구현은 이를 위해 vptr + vTable 구조를 사용한다.

vptr

vptr(Virtual Table Pointer)는 객체가 자신의 vTable을 찾기 위해 가지는 숨겨진 포인터다.

class Character
{
public:
    virtual void Attack();
    virtual void Move();

private:
    int Health;
};

그래서 virtual 함수가 하나라도 있으면 객체 크기가 커진다.

class A // sizeof == 4
{ 
	int Value; 
};

class B // sizeof == 16
{ 
public: 
	virtual void Func();
    int Value; 
};     

B는 vptr 8Byte + int 4Byte + 정렬 패딩 4Byte로 16Byte가 된다.

vTable

vTable은 클래스의 virtual 함수 주소를 담은 테이블이며, 객체마다가 아니라 클래스마다 하나 존재하고 그 클래스의 모든 객체가 공유한다.

덕분에 객체는 함수 주소를 전부 들고 다닐 필요 없이 vptr 하나만 가지면 된다.

Derived Class의 vTable

Character에 이어서 Player 자식 클래스를 선언하게 되면 다음과 같이 vTable이 선언된다.

class Player : public Character
{
public:
    void Attack() override; // Move는 Override하지 않음
};

Override한 슬롯만 Derived 함수로 교체되고, 나머지는 Base 구현을 그대로 유지한다. Player 객체의 vptr은 Player용 vTable을 가리킨다.

만약 다음과 같이 코드가 작성되어 있다면 vPtr은 실제 메모리에 할당된 주소를 기반으로 선언이 되므로, 이 경우 Player의 vTable을 가리키게 된다.

Character* Ptr = new Player();
Ptr->Attack();

순수 가상 함수의 슬롯

순수 가상 함수도 vTable에서 슬롯을 차지하지만 그 자리에는 호출 가능한 최종 구현이 없는데,
만약 Character::Attack이 순수 가상함수라면 이 자리에 _purecall 같은 런타임 핸들러가 포함이 된다.

순수 가상 함수를 직접적으로 호출하는 것을 불가능 하지만, 생성자를 통해서 호출하는 등 우회 가능성이 있으므로 이러한 핸들러가 포함된 것으로 보인다.

RTTI


RTTI(Run-Time Type Information)실행 중 객체의 실제 타입 정보를 확인하기 위한 기능이며, typeiddynamic_cast가 이를 사용한다.

typeid

#include <typeinfo>

Player MyPlayer;
Character* Ptr = &MyPlayer;

std::cout << typeid(*Ptr).name(); // class Player

앞서 살펴보았던 구조에서 Character가 virtual 함수를 하나 이상 가진 타입임을 확인했으므로 *Ptr동적 타입을 확인할 수 있다.
만약 가상함수가 하나라도 없다면 typeid는 런타임 정보를 보지 않고 정적 타입을 그대로 알려준다.(class Character)

dynamic_cast

dynamic_cast는 클래스 계층에서의 타입 변환이 실제 객체 구조상 유효한지를 런타임에 검사하는 Cast다.

class Character 
{ 
public: 
	virtual ~Character() = default; 
};

class Player : public Character
{
public:
    void PlayerOnlyFunction() {}
};

class Enemy : public Character {};
Player MyPlayer;
Character* CharacterPtr = &MyPlayer;

Player* PlayerPtr = dynamic_cast<Player*>(CharacterPtr); // 성공
Enemy*  EnemyPtr  = dynamic_cast<Enemy*>(CharacterPtr);  // 실패 → nullptr

실패 방식은 포인터와 참조가 다르다.

try
{
    Enemy& EnemyRef = dynamic_cast<Enemy&>(*CharacterPtr);
}
catch (const std::bad_cast&)
{
    std::cout << "Cast Failed\n";
}
dynamic_cast 포인터 실패 → nullptr
dynamic_cast 참조 실패 → std::bad_cast 예외

가상 함수가 없을 때의 dynamic_cast

class Character {};          // virtual 함수 없음
class Player : public Character {};

Character* CharacterPtr = new Player();

Player* PlayerPtr = dynamic_cast<Player*>(CharacterPtr); // 컴파일 에러

런타임 검사가 필요한 Downcast는 Source Type이 virtual이 하나 이상 있어야 한다.
그래야 typeid가 생성되어 dynamic_cast를 수행할 수 있기 때문이다.

dynamic_cast의 정적 캐스팅

Derived → Base 방향의 Upcast는 상속 관계가 컴파일 시점에 확정되어 있으므로 런타임 타입 검색이 필요 없다.

Player* PlayerPtr = new Player();
Character* CharacterPtr = dynamic_cast<Character*>(PlayerPtr);

static_cast와 dynamic_cast의 비교

static_cast
  컴파일 시점의 타입 관계만 사용
  Downcast 시 런타임 검증 없음 -> 캐스팅은 되지만 올바른 검증인지는 불확실

dynamic_cast
  실제 Dynamic Type 확인
  Pointer 실패 → nullptr / Reference 실패 → std::bad_cast
  런타임 비용 발생, virtual 1개 이상 필요

생성자에서 virtual 함수를 호출하면?


class Character
{
public:
    Character()
    {
        Initialize();
    }

    virtual void Initialize()
    {
        std::cout << "Character Initialize\n";
    }
};

class Player : public Character
{
public:
    void Initialize() override
    {
        std::cout << "Player Initialize\n";
    }
};
Player MyPlayer;

Player::Initialize가 실행될 것 같지만, 실제 출력은 다음과 같다.

Character Initialize

생성자와 소멸자가 실행되는 동안의 virtual 호출은 현재 생성/소멸 중인 클래스보다 더 파생된 클래스의 Override로 내려가지 않는다.

그 이유는 생성 순서를 보면 이유가 분명해진다.

Player 생성 시작
    ↓ Character 생성
    ↓ Character 생성자 호출
    ↓ Player의 필드 초기화
    ↓ Player 생성자 호출
Player 생성 완료

Character 생성자가 도는 시점에는 Player 부분이 아직 초기화되지 않았기 때문이다.

profile
게임 개발 지망생

0개의 댓글