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_cast
나typeid
연산자가 런타임에 객체의 실제 타입을 확인하는 데 사용된다. - 가상 함수 포인터: RTTI 정보 뒤에는 클래스에 선언된 순서대로 가상 함수들의 주소가 나열된다.
// 예시 클래스 class Knight { public: virtual void Attack() { /* ... */ } virtual void Defend() { /* ... */ } };
가상 함수와 순수 가상 함수의 vtable 표현// Knight 클래스에 대한 vtable의 개념적 구조 vtable_for_Knight: [0]: (optional) pointer to type_info for Knight [1]: &Knight::Attack [2]: &Knight::Defend
- 일반 가상 함수 (Virtual Function):
vtable
의 해당 슬롯은 함수의 구현 코드를 가리키는 유효한 함수 포인터를 저장한다. - 순수 가상 함수 (Pure Virtual Function): 순수 가상 함수를 포함하는 클래스는 '추상 클래스'이므로 직접 객체를 생성할 수 없다. 컴파일러는 이 사실을
vtable
에 다음과 같이 표현한다.vtable
의 순수 가상 함수에 해당하는 슬롯은0
(NULL) 또는 특정 에러 처리 함수(__cxa_pure_virtual
등)의 주소로 채워진다.- 만약 추상 클래스의 생성자나 소멸자에서 실수로 순수 가상 함수를 호출하는 코드를 작성하면, 런타임에 이 NULL 포인터를 참조하거나 에러 처리 함수가 호출되어 프로그램이 비정상적으로 종료된다.
- 이 추상 클래스를 상속받는 자식 클래스가 순수 가상 함수를 구현(override)하면, 자식 클래스의
vtable
에서는 해당 슬롯이 마침내 유효한 함수(자식이 구현한 함수)의 주소로 채워진다. 만약 자식 클래스도 구현하지 않으면, 그 자식 클래스 또한 추상 클래스가 된다.
- RTTI(Run-Time Type Information) 정보:
vptr (가상 함수 포인터)
- 정의:
vptr
은 가상 함수를 가진 클래스로부터 생성되는 모든 객체(인스턴스) 내부에 컴파일러가 몰래 삽입하는 숨겨진 포인터다. - 역할:
vptr
의 유일한 역할은 자신이 속한 객체의 클래스에 해당하는vtable
을 가리키는 것이다. 이를 통해 객체는 런타임에 자신의 타입에 맞는 가상 함수들을 찾을 수 있다. - 메모리 레이아웃:
vptr
은 객체의 멤버 변수들보다 앞서, 객체 메모리의 가장 첫 부분에 위치하는 것이 일반적이다. 이 때문에 가상 함수를 사용하는 객체는sizeof(void*)
(64비트 시스템에서는 8바이트) 만큼의 크기 오버헤드를 갖는다.
// Knight 객체 'k'의 개념적 메모리 구조
object_k:
+----------------+
| vptr | ---> vtable_for_Knight
+----------------+
| member_vars... |
+----------------+
vptr의 초기화: 생성자와 소멸자에서의 동작
vptr
의 동작을 이해하는 데 가장 중요한 부분은 객체의 생성 및 소멸 과정이다. vptr
은 생성자 호출 시점에 설정되며, 이는 "타입이 결정되는" 시점과 일치한다.
- 생성 과정 (Construction):
- 부모 클래스 생성자 호출: 자식 클래스(
Paladin
)의 객체를 생성하면, 상속 체인을 따라 최상위 부모 클래스(Knight
)의 생성자가 먼저 호출된다. - 부모 vptr 설정:
Knight
생성자가 실행되는 동안, 생성 중인 객체의vptr
은Knight
의vtable
을 가리킨다. 이 시점에서 객체는 아직Knight
타입이다. 따라서Knight
생성자 내에서 가상 함수를 호출하면Knight
의 버전이 실행된다. - 자식 vptr 설정:
Knight
생성자가 완료된 후,Paladin
생성자가 실행된다. 이 때, 객체의vptr
은Paladin
의vtable
을 가리키도록 "재설정"된다. 이제 객체의 타입은Paladin
으로 확정된다.
- 부모 클래스 생성자 호출: 자식 클래스(
- 소멸 과정 (Destruction):
- 자식 클래스 소멸자 호출: 객체의 소멸 시, 생성의 역순으로 자식 클래스(
Paladin
)의 소멸자가 먼저 호출된다. 이 시점까지vptr
은 여전히Paladin
의vtable
을 가리킨다. - 부모 vptr 재설정:
Paladin
소멸자 실행이 끝나면, 객체는 다시 부모 타입(Knight
)으로 돌아간다. 이 때vptr
은Knight
의vtable
을 가리키도록 재설정된다. - 부모 클래스 소멸자 호출:
Knight
의 소멸자가 호출된다. 만약 이 안에서 가상 함수를 호출하면,vptr
이Knight
의vtable
을 가리키므로Knight
의 버전이 실행된다.
- 자식 클래스 소멸자 호출: 객체의 소멸 시, 생성의 역순으로 자식 클래스(
이러한 동작 방식 때문에, 생성자나 소멸자 안에서 호출된 가상 함수는 동적으로 동작하지 않고, 현재 실행 중인 생성자/소멸자가 속한 클래스의 버전이 정적으로 호출된다는 중요한 규칙이 있다.
1.3. 동작 원리: 가상 함수의 호출 과정
Base* ptr = new Derived();
와 같이 부모 포인터로 자식 객체를 가리킬 때, ptr->virtual_function();
호출은 다음 과정을 통해 동적으로 올바른 함수를 찾아간다.
- 객체 포인터
ptr
을 통해 객체의 메모리에 접근한다. - 객체의 메모리 맨 앞에 있는
vptr
의 값을 읽는다. 이vptr
은Derived
클래스의vtable
을 가리키고 있다. vtable
에서 호출된virtual_function
에 해당하는 인덱스(컴파일 타임에 결정됨)를 찾아, 해당 인덱스에 저장된 함수 포인터를 얻는다.- 얻어낸 함수 포인터를 통해 실제 호출될 함수(이 경우
Derived::virtual_function
)의 주소로 점프하여 함수를 실행한다.
이 모든 과정은 런타임에 일어나므로, ptr
이 어떤 실제 객체(Derived1
, Derived2
등)를 가리키고 있더라도 항상 그 객체 타입에 맞는 가상 함수를 호출할 수 있다.
1.4. 상속과 vtable
- 단일 상속: 자식 클래스는 부모 클래스의 vtable을 복사하여 자신만의 vtable을 만든다.
- 오버라이딩: 자식 클래스가 부모의 가상 함수를 오버라이딩하면, 자식 클래스의 vtable에서 해당 함수의 주소가 오버라이딩된 자식 함수의 주소로 교체된다.
- 새 가상 함수 추가: 자식 클래스에서 새로운 가상 함수를 추가하면, 그 함수의 주소는 vtable의 끝에 추가된다.
- 다중 상속: 다중 상속 시에는 더 복잡해진다. 객체는 각 부모 클래스에 대한 vptr을 가질 수 있으며, 이로 인해 메모리 구조와 가상 함수 호출 메커니즘이 더 복잡해질 수 있다. (Thunk 기법 등이 사용되기도 한다.)
1.5. 계층 구조별 동작 방식
- 사용자 영역 (User Code)
- 개발자는
base_ptr->use()
와 같이 간단하게 가상 함수를 호출.
- 개발자는
- 컴파일러 영역 (Compiler Layer)
- 컴파일러는
use()
가 가상 함수임을 인지. - 이 호출을
(*base_ptr->vptr[1])(base_ptr)
와 같은 형태의 코드로 변환. (여기서1
은use
함수의 vtable 내 고유 인덱스). - 가상 함수를 가진 각 클래스에 대한
vtable
을 생성하고, 생성자 코드에 객체의vptr
이 해당 클래스의vtable
을 가리키도록 하는 코드를 삽입.
- 컴파일러는
- 런타임 영역 (Runtime Layer)
- 프로그램 실행 중
base_ptr->use()
코드가 실행. base_ptr
이 가리키는 객체의vptr
을 참조,vtable
주소를 탐색.vtable
에서use
함수의 인덱스에 있는 함수 주소 획득.- 해당 주소의 함수를 호출.
- 프로그램 실행 중
- 운영체제 / 하드웨어 영역 (OS/Hardware Layer)
- OS는 코드, 데이터(
vtable
), 힙/스택(객체)을 위한 메모리를 할당하고 관리. - CPU는 위의 런타임 과정에서 발생하는 메모리 접근(포인터 역참조) 및 함수 호출 명령을 실제로 수행.
- OS는 코드, 데이터(
1.6. 정적 바인딩 vs 동적 바인딩
구분 | 정적 바인딩 (Static Binding) | 동적 바인딩 (Dynamic Binding) |
---|---|---|
결정 시점 | 컴파일 타임 | 런타임 |
대상 | 일반 함수, static 함수, private 함수 |
virtual 로 선언된 함수 |
장점 | - 속도가 빠름 (런타임 조회 없음) - 오버헤드 없음 |
- 유연하고 확장성 높은 설계 가능 (다형성) - OCP(개방-폐쇄 원칙) 만족 용이 |
단점 | - 다형적 동작 불가 | - 약간의 성능 저하 (vptr 역참조) - 메모리 오버헤드 (객체당 vptr) |
사용 시점 | 다형성이 필요 없는 모든 경우 | 상속 관계에서 부모 타입으로 자식 객체를 다루며, 각 자식 타입 고유의 동작이 필요할 때 |
2. 실습 코드
아래 코드는 게임 캐릭터가 인벤토리에 있는 다양한 아이템(포션
, 주문서
, 무기
)을 사용하는 시나리오를 vtable
과 vptr
을 이용한 동적 바인딩으로 구현한 예시다.
#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 |