본문 바로가기

vtable과 vptr

@iamrain2025. 9. 5. 12:56

1. 이론

1.1. C++의 다형성과 동적 바인딩

C++에서 다형성(Polymorphism)은 주로 상속 관계에 있는 클래스에서 부모 클래스의 포인터 또는 참조로 자식 클래스의 객체를 가리키고, 동일한 함수 호출에 대해 각 객체의 실제 타입에 맞는 동작을 하도록 한다. 이러한 동작을 가능하게 하는 핵심 메커니즘이 바로 동적 바인딩(Dynamic Binding) 또는 런타임 바인딩(Runtime Binding)이다.

정적 바인딩(Static Binding) 또는 컴파일 타임 바인딩(Compile-time Binding)은 컴파일 시점에 호출될 함수가 결정되는 방식으로 일반적인 함수 호출이 여기에 해당한다.

동적 바인딩을 구현하기 위해 대부분의 C++ 컴파일러는 vtable(가상 함수 테이블)vptr(가상 함수 포인터)을 사용한다.

1.2. vtable (Virtual Table)과 vptr (Virtual Pointer)

vtable (가상 함수 테이블)

  • 정의: vtable은 가상 함수를 하나 이상 포함하는 클래스에 대해 클래스 단위로 생성되는 정적 배열이다. 이 배열은 함수 포인터들로 구성되며, 각 포인터는 해당 클래스의 가상 함수가 실제 메모리 어디에 위치하는지를 가리킨다.
  • 저장 위치: vtable은 컴파일 타임에 생성되어 프로그램의 데이터 세그먼트(주로 읽기 전용 메모리 영역)에 저장된다. 이는 클래스의 모든 객체가 단 하나의 vtable을 공유하게 하여 메모리 효율성을 높인다.
  • 구조: vtable은 단순히 함수 포인터 배열일 뿐만 아니라, 구현에 따라 추가 정보를 담기도 한다.
    • RTTI(Run-Time Type Information) 정보: vtable의 첫 번째 항목은 종종 해당 클래스의 type_info 객체를 가리키는 포인터를 포함한다. 이 정보는 dynamic_casttypeid 연산자가 런타임에 객체의 실제 타입을 확인하는 데 사용된다.
    • 가상 함수 포인터: RTTI 정보 뒤에는 클래스에 선언된 순서대로 가상 함수들의 주소가 나열된다.
    // 예시 클래스
    class Knight {
    public:
        virtual void Attack() { /* ... */ }
        virtual void Defend() { /* ... */ }
    };
    // Knight 클래스에 대한 vtable의 개념적 구조
    vtable_for_Knight:
      [0]: (optional) pointer to type_info for Knight
      [1]: &Knight::Attack
      [2]: &Knight::Defend
    가상 함수와 순수 가상 함수의 vtable 표현
    • 일반 가상 함수 (Virtual Function): vtable의 해당 슬롯은 함수의 구현 코드를 가리키는 유효한 함수 포인터를 저장한다.
    • 순수 가상 함수 (Pure Virtual Function): 순수 가상 함수를 포함하는 클래스는 '추상 클래스'이므로 직접 객체를 생성할 수 없다. 컴파일러는 이 사실을 vtable에 다음과 같이 표현한다.
      • vtable의 순수 가상 함수에 해당하는 슬롯은 0 (NULL) 또는 특정 에러 처리 함수(__cxa_pure_virtual 등)의 주소로 채워진다.
      • 만약 추상 클래스의 생성자나 소멸자에서 실수로 순수 가상 함수를 호출하는 코드를 작성하면, 런타임에 이 NULL 포인터를 참조하거나 에러 처리 함수가 호출되어 프로그램이 비정상적으로 종료된다.
      • 이 추상 클래스를 상속받는 자식 클래스가 순수 가상 함수를 구현(override)하면, 자식 클래스의 vtable에서는 해당 슬롯이 마침내 유효한 함수(자식이 구현한 함수)의 주소로 채워진다. 만약 자식 클래스도 구현하지 않으면, 그 자식 클래스 또한 추상 클래스가 된다.

vptr (가상 함수 포인터)

  • 정의: vptr은 가상 함수를 가진 클래스로부터 생성되는 모든 객체(인스턴스) 내부에 컴파일러가 몰래 삽입하는 숨겨진 포인터다.
  • 역할: vptr의 유일한 역할은 자신이 속한 객체의 클래스에 해당하는 vtable을 가리키는 것이다. 이를 통해 객체는 런타임에 자신의 타입에 맞는 가상 함수들을 찾을 수 있다.
  • 메모리 레이아웃: vptr은 객체의 멤버 변수들보다 앞서, 객체 메모리의 가장 첫 부분에 위치하는 것이 일반적이다. 이 때문에 가상 함수를 사용하는 객체는 sizeof(void*) (64비트 시스템에서는 8바이트) 만큼의 크기 오버헤드를 갖는다.
// Knight 객체 'k'의 개념적 메모리 구조 
 object_k: 
 +----------------+ 
 |      vptr      | ---> vtable_for_Knight 
 +----------------+ 
 | member_vars... | 
 +----------------+

vptr의 초기화: 생성자와 소멸자에서의 동작

vptr의 동작을 이해하는 데 가장 중요한 부분은 객체의 생성 및 소멸 과정이다. vptr은 생성자 호출 시점에 설정되며, 이는 "타입이 결정되는" 시점과 일치한다.

  • 생성 과정 (Construction):
    1. 부모 클래스 생성자 호출: 자식 클래스(Paladin)의 객체를 생성하면, 상속 체인을 따라 최상위 부모 클래스(Knight)의 생성자가 먼저 호출된다.
    2. 부모 vptr 설정: Knight 생성자가 실행되는 동안, 생성 중인 객체의 vptrKnightvtable을 가리킨다. 이 시점에서 객체는 아직 Knight 타입이다. 따라서 Knight 생성자 내에서 가상 함수를 호출하면 Knight의 버전이 실행된다.
    3. 자식 vptr 설정: Knight 생성자가 완료된 후, Paladin 생성자가 실행된다. 이 때, 객체의 vptrPaladinvtable을 가리키도록 "재설정"된다. 이제 객체의 타입은 Paladin으로 확정된다.
  • 소멸 과정 (Destruction):
    1. 자식 클래스 소멸자 호출: 객체의 소멸 시, 생성의 역순으로 자식 클래스(Paladin)의 소멸자가 먼저 호출된다. 이 시점까지 vptr은 여전히 Paladinvtable을 가리킨다.
    2. 부모 vptr 재설정: Paladin 소멸자 실행이 끝나면, 객체는 다시 부모 타입(Knight)으로 돌아간다. 이 때 vptrKnightvtable을 가리키도록 재설정된다.
    3. 부모 클래스 소멸자 호출: Knight의 소멸자가 호출된다. 만약 이 안에서 가상 함수를 호출하면, vptrKnightvtable을 가리키므로 Knight의 버전이 실행된다.

이러한 동작 방식 때문에, 생성자나 소멸자 안에서 호출된 가상 함수는 동적으로 동작하지 않고, 현재 실행 중인 생성자/소멸자가 속한 클래스의 버전이 정적으로 호출된다는 중요한 규칙이 있다.

1.3. 동작 원리: 가상 함수의 호출 과정

Base* ptr = new Derived(); 와 같이 부모 포인터로 자식 객체를 가리킬 때, ptr->virtual_function(); 호출은 다음 과정을 통해 동적으로 올바른 함수를 찾아간다.

  1. 객체 포인터 ptr을 통해 객체의 메모리에 접근한다.
  2. 객체의 메모리 맨 앞에 있는 vptr의 값을 읽는다. 이 vptrDerived 클래스의 vtable을 가리키고 있다.
  3. vtable에서 호출된 virtual_function에 해당하는 인덱스(컴파일 타임에 결정됨)를 찾아, 해당 인덱스에 저장된 함수 포인터를 얻는다.
  4. 얻어낸 함수 포인터를 통해 실제 호출될 함수(이 경우 Derived::virtual_function)의 주소로 점프하여 함수를 실행한다.

이 모든 과정은 런타임에 일어나므로, ptr이 어떤 실제 객체(Derived1, Derived2 등)를 가리키고 있더라도 항상 그 객체 타입에 맞는 가상 함수를 호출할 수 있다.

1.4. 상속과 vtable

  • 단일 상속: 자식 클래스는 부모 클래스의 vtable을 복사하여 자신만의 vtable을 만든다.
    • 오버라이딩: 자식 클래스가 부모의 가상 함수를 오버라이딩하면, 자식 클래스의 vtable에서 해당 함수의 주소가 오버라이딩된 자식 함수의 주소로 교체된다.
    • 새 가상 함수 추가: 자식 클래스에서 새로운 가상 함수를 추가하면, 그 함수의 주소는 vtable의 끝에 추가된다.
  • 다중 상속: 다중 상속 시에는 더 복잡해진다. 객체는 각 부모 클래스에 대한 vptr을 가질 수 있으며, 이로 인해 메모리 구조와 가상 함수 호출 메커니즘이 더 복잡해질 수 있다. (Thunk 기법 등이 사용되기도 한다.)

1.5. 계층 구조별 동작 방식

  1. 사용자 영역 (User Code)
    • 개발자는 base_ptr->use()와 같이 간단하게 가상 함수를 호출.
  2. 컴파일러 영역 (Compiler Layer)
    • 컴파일러는 use()가 가상 함수임을 인지.
    • 이 호출을 (*base_ptr->vptr[1])(base_ptr) 와 같은 형태의 코드로 변환. (여기서 1use 함수의 vtable 내 고유 인덱스).
    • 가상 함수를 가진 각 클래스에 대한 vtable을 생성하고, 생성자 코드에 객체의 vptr이 해당 클래스의 vtable을 가리키도록 하는 코드를 삽입.
  3. 런타임 영역 (Runtime Layer)
    • 프로그램 실행 중 base_ptr->use() 코드가 실행.
    • base_ptr이 가리키는 객체의 vptr을 참조, vtable 주소를 탐색.
    • vtable에서 use 함수의 인덱스에 있는 함수 주소 획득.
    • 해당 주소의 함수를 호출.
  4. 운영체제 / 하드웨어 영역 (OS/Hardware Layer)
    • OS는 코드, 데이터(vtable), 힙/스택(객체)을 위한 메모리를 할당하고 관리.
    • CPU는 위의 런타임 과정에서 발생하는 메모리 접근(포인터 역참조) 및 함수 호출 명령을 실제로 수행.

1.6. 정적 바인딩 vs 동적 바인딩

구분 정적 바인딩 (Static Binding) 동적 바인딩 (Dynamic Binding)
결정 시점 컴파일 타임 런타임
대상 일반 함수, static 함수, private 함수 virtual로 선언된 함수
장점 - 속도가 빠름 (런타임 조회 없음)
- 오버헤드 없음
- 유연하고 확장성 높은 설계 가능 (다형성)
- OCP(개방-폐쇄 원칙) 만족 용이
단점 - 다형적 동작 불가 - 약간의 성능 저하 (vptr 역참조)
- 메모리 오버헤드 (객체당 vptr)
사용 시점 다형성이 필요 없는 모든 경우 상속 관계에서 부모 타입으로 자식 객체를 다루며, 각 자식 타입 고유의 동작이 필요할 때

2. 실습 코드

아래 코드는 게임 캐릭터가 인벤토리에 있는 다양한 아이템(포션, 주문서, 무기)을 사용하는 시나리오를 vtablevptr을 이용한 동적 바인딩으로 구현한 예시다.

#include <iostream>
#include <string>
#include <vector>
#include <memory>

// 게임 캐릭터 클래스
class Character {
public:
    Character(const std::string& name) : name(name), health(100) {}

    void OnDamaged(int damage) {
        health -= damage;
        std::cout << name << "이(가) " << damage << "의 피해를 입었습니다. 현재 체력: " << health << std::endl;
    }

    void OnHealed(int amount) {
        health += amount;
        std::cout << name << "이(가) " << amount << "만큼 체력을 회복했습니다. 현재 체력: " << health << std::endl;
    }

    void EquipWeapon(const std::string& weaponName) {
        std::cout << name << "이(가) " << weaponName << "을(를) 장착했습니다." << std::endl;
    }

private:
    std::string name;
    int health;
};

// 아이템의 기반 클래스
class Item {
public:
    Item(const std::string& name) : name(name) {}
    virtual ~Item() = default; // 부모 클래스의 소멸자는 가상 소멸자로 선언하는 것이 안전합니다.

    // Q1: 이 함수를 'virtual'로 선언하는 이유는 무엇이며, 만약 'virtual'이 없다면 어떤 문제가 발생할까요?
    virtual void Use(Character& owner) {
        std::cout << "아이템 '" << name << "'을(를) 어떻게 사용해야 할지 알 수 없습니다." << std::endl;
    }

protected:
    std::string name;
};

// 체력을 회복시키는 포션 아이템
class Potion : public Item {
public:
    Potion(const std::string& name, int healAmount) : Item(name), healAmount(healAmount) {}

    // Q4: 'override' 키워드를 사용하는 이유는 무엇이며, 사용하지 않았을 때 발생할 수 있는 잠재적인 문제는 무엇일까요?
    void Use(Character& owner) override {
        std::cout << "'" << name << "' 포션을 사용합니다." << std::endl;
        owner.OnHealed(healAmount);
    }

private:
    int healAmount;
};

// 마법 주문을 외우는 주문서 아이템
class Scroll : public Item {
public:
    Scroll(const std::string& name, int damage) : Item(name), damage(damage) {}

    void Use(Character& owner) override {
        std::cout << "'" << name << "' 주문서를 사용해 파이어볼을 시전합니다!" << std::endl;
        // 실제 게임이라면 상대방에게 데미지를 주겠지만, 여기서는 사용자에게 데미지를 주는 것으로 표현
        owner.OnDamaged(damage);
    }

private:
    int damage;
};

// 무기 아이템
class Weapon : public Item {
public:
    Weapon(const std::string& name) : Item(name) {}

    void Use(Character& owner) override {
        std::cout << "'" << name << "' 아이템을 사용합니다." << std::endl;
        owner.EquipWeapon(name);
    }
};


int main() {
    // 캐릭터 생성
    Character player("용사");

    // Q2: 아래 Potion 객체가 생성될 때, 컴파일러는 객체의 메모리 구조에 무엇을 추가할까요? 그리고 그 역할은 무엇일까요?
    // 인벤토리 생성 및 아이템 추가
    std::vector<std::unique_ptr<Item>> inventory;
    inventory.push_back(std::make_unique<Potion>("하급 체력 포션", 20));
    inventory.push_back(std::make_unique<Scroll>("파이어볼 주문서", 30));
    inventory.push_back(std::make_unique<Weapon>("강철 장검"));

    // 인벤토리의 모든 아이템을 순서대로 사용
    for (const auto& item : inventory) {
        // Q3: 이 코드는 어떻게 항상 올바른 Use() 함수(Potion, Scroll, Weapon)를 호출할 수 있을까요? 
        // 컴파일 타임이 아닌 '런타임'의 동작을 vtable, vptr과 연관지어 설명하세요.
        item->Use(player);
        std::cout << "-------------------------" << std::endl;
    }

    return 0;
}

'C++' 카테고리의 다른 글

RAII (Resource Acquisition Is Initialization)  (0) 2025.09.08
RTTI (Run-Time Type Information)  (1) 2025.09.05
동적 바인딩 feat. vtable & vptr  (0) 2025.09.03
구조체  (0) 2025.09.03
클래스  (0) 2025.09.02
iamrain
@iamrain :: Annals of Unreal

iamrain 님의 블로그 입니다.

공감하셨다면 ❤️ 구독도 환영합니다! 🤗

목차