0. 다형성(Polymorphism)이란?

다형성은 객체 지향 프로그래밍의 중요한 특징 중 하나로, 상속관계에 있는 클래스 사이에 서로 형변환이 가능한 성질을 말한다.

플레이어와 몬스터가 있는 게임을 예시로 보자.

class Object
{
public:
	virtual void Update() {};
};

class Player : public Object
{
public:
	virtual void Update() override
	{
		// 플레이어 행동 로직
	}

	void Dance()
	{
		// 춤추기
	}
};

class Monster : public Object
{
	virtual void Update() override
	{
		// 몬스터 행동 로직
	}
};

int main()
{
	Player player;
	Monster monster1;
	Monster monster2;
	Monster monster3;

	Object* objArr[4] = {&player, &monster1, &monster2, &monster3};

	while (true)
	{
		for (int i = 0; i < 4; ++i)
		{
			objArr[i]->Update();
		}

		// if (게임 종료 조건)
			// break;
	}

	return 0;
}

PlayerMonster는 엄연히 다른 클래스지만, 부모 클래스인 ObjectUp-Casting해 단순히 Update() 함수 호출을 통해 로직을 실행하고 있다. 겉보기에는 Object의 Update() 함수가 실행될 것 같지만 디버깅을 통해 동작을 살펴보면, 실제 객체의 타입에 맞는 Update() 함수가 호출되는 것을 볼 수 있다.

만약 다형성이 없었다면 player 배열 따로, monster 배열 따로 만들어서 호출하거나, 최악의 경우(여러 클래스 종류가 있지만 객체가 한개씩인 경우) 모든 객체에 대해 Update()를 일일이 호출해야 할 수도 있다.

위의 예시에서 알 수 있듯이, 다형성은 객체 지향에서 설계와 유지보수 측면에서 매우 큰 장점을 제공한다.

그런데 어떻게 이런 방식이 가능할까?

1. virtual, vpointer, vtable

컴파일러는 만약 클래스 내에 virtual 함수가 하나라도 있다면, 객체를 만들 때 내부에 vpointer(이하 vptr)을 생성한다(따라서 빈 클래스라도 virtual 함수가 있는 경우 8바이트(x64)를 차지한다 https://hyun-soon.github.io/cpp/EBO/).

이 vptr은 각 클래스 종류 별로 한 개씩 생성되는 vtable을 가리키는데, vtable에는 각 클래스가 가지는 virtual 함수들의 주소가 담겨있다(vtable은 .rdata 영역에 저장된다).

즉,

  1. virtual 함수가 있는 클래스별로 vtable이 생성되고
  2. 객체 생성 시 내부에는 vptr이 생성되어
  3. 다형성을 사용하더라도 vptr을 통해 vtable에서 함수의 주소를 확인하고, 실제 타입에 맞는 함수를 호출할 수 있는 것이다.

하지만 추가 메모리와 작업을 필요로 하므로 약간의 오버헤드가 발생한다.

vtable

1. dynamic_cast

그런데 다형성을 사용하다가도, Player::Dance() 함수를 호출하고 싶으면 Object 포인터를 Player 포인터로 Down-Casting 해야 한다.

만약 Player의 정확한 인덱스를 알고 있다면, static_cast<Player*>(objArr[idx])와 같이 변환할 수 있을 것이다. 하지만 게임이 점점 커지고 플레이어, 몬스터, NPC 등 여러 오브젝트들이 하나의 배열에 담겨있다고 상상해본다면, 인덱스 전부를 추적하기가 쉽지 않을 것이란 확신이 든다. 클래스 내부에 메모리를 조금 더 써서 타입을 저장하는 방법도 있지만, C++에서 제공하는 dynamic_cast를 사용할 수도 있다.

dynamic_csat를 사용해 objArr에서 임의의 한 포인터를 Player 포인터로 캐스팅해보자.

int randIdx = rand();

Player* playerPtr = dynamic_cast<Player*>(objArr[randIdx]);

if (playerPtr)
	playerPtr->Dance();

만약 objArr[randIdx]가 가리키는 실제 객체가 Player라면, dynamic_cast는 Player 포인터로 캐스팅된 결과를 반환하고, 실제 객체가 Player가 아니었다면 nullptr을 반환한다.

dynamic_cast를 레퍼런스로도 사용할 수 있는데, 레퍼런스는 선언과 동시에 값을 정해줘야 한다. 그런데 캐스팅하려는 타입이 잘못된 경우 리턴으로 사용할 값이 마땅치 않으므로, 이 경우에는 std::exception을 throw한다.

virtual 함수는 vtable 확인을 통해 실제 함수를 호출할 수 있다고 했다. 그럼 dynamic_cast는 런타임에 어떻게 실제 타입 검사를 하는걸까?

2. RTTI(Run-Time Type Information)

RTTI는 말 그대로 프로그램 실행 중에 객체의 실제 타입 정보를 확인할 수 있게 해주는 기능이다. RTTI가 제공하는 기능은 typeiddynamic_cast가 있다.

typeid의 사용 예시도 잠깐 살펴보자.

#include <iostream>
#include <typeinfo>

class Base { virtual void dummy() {} };
class Derived : public Base {};

int main() {
    Base* b = new Derived;

	// typeid() : 인자로 넣어준 변수 혹은 타입에 해당하는 type_info 클래스의 객체를 반환한다.

    std::cout << typeid(*b).name() << '\n';  // Derived의 타입이 출력됨
    return 0;
}

typeid를 사용하여 객체의 실제 타입 정보를 얻을 수 있으며, 타입 정보를 비교하거나 로그를 찍는데 유용하게 사용된다.

RTTI는 어떻게 작동할까?

RTTI를 사용하기 위한 조건으로, 클래스에 반드시 virtual 함수가 1개 이상 있어야 한다. 위에서 virtual 함수가 하나라도 있으면, 컴파일러는 vtable을 생성한다고 했다. 이 때, vtable 근처 혹은 내부(컴파일러마다 다름)에 RTTI 정보들을 담아놓은 RTTI Complete Object Locator를 가리키는 포인터가 같이 생성된다.

RTTI

아래 코드로 디버깅을 통해 RTTI를 확인해보자.

#include <iostream>
#include <typeinfo>

class Base
{
	virtual void test() {};
};

class Derived : public Base
{
	virtual void test() override {};
};

int main()
{
	Base base;

	std::cout << typeid(base).name() << '\n'; // breakpoint

	return 0;
}

주석이 있는 라인을 실행한 후, base 객체의 vptr 주변을 탐색해 봤더니, -1 인덱스에 RTTI Complete Object Locator가 있는 것을 확인할 수 있었다.

RTTIDebug

즉, virtual 함수처럼 RTTI도 런타임에 정보가 있는 주소를 찾아가, 실제 타입을 확인함으로써 typeid, dynamic_cast 같은 기능을 제공하는 것이다.

RTTI 역시 약간의 오버헤드가 발생한다.

Leave a comment