
1. 다형성이란?
Polymorphism이란 단어를 보면 'Poly'라는 접두어와 'Morph'라는 단어가 합쳐져 '형태의 다양성'이라는 뜻을 가진다는 것을 알 수 있다.
- Poly는 같은 종류의 무언가가 많다는 뜻으로, 폴리곤(다각형), 폴리에틸렌(에틸렌의 반복적 결합 구조)이라는 단어를 보면 뉘앙스를 알 수 있다.
- Morph는 형태를 의미한다.
풀어쓰면 '겉은 똑같은데, 기능이 다르게 동작한다'라 할 수 있다.
다형성을 구현하는 방법으로 1. 오버로딩(Overloading) 2. 오버라이딩(Overriding) 이렇게 두 가지가 존재한다.
1. 오버로딩
함수 중복 정의
class Player
{
public:
void Move() { cout << "Player::Move()\n"; }
void Move(int a) { cout << "Player::Move(int)\n"; }
public:
int _hp;
};
int main()
{
Player p;
p.Move();
p.Move(100);
}

Player라는 클래스에 2가지 Move 메서드를 정의했다. 같은 이름을 사용했지만, 두 메서드의 파라미터가 달라 에러가 발생하지 않고 정상적으로 실행된다. 파라미터가 같아도 반환 타입이 다르면 오버로딩으로 동작한다.
2. 오버라이딩
class Player
{
public:
void Move() { cout << "Player::Move()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
public:
int _stamina;
};
int main()
{
Knight k;
k.Move();
return 0;
}

위 코드를 실행하면 "Player::Move()" 문자열이 출력되는 것을 알 수 있는데, Knight가 Player를 상속했기 때문에 기본적으로 public/protected으로 선언된 모든 요소는 Knight에서도 접근할 수 있다.
class Knight : public Player
{
public:
void Move() { cout << "Knight::Move()\n"; }
public:
int _stamina;
};

만약 Knight에서 Move 함수를 재정의하게 되면 Player의 Move 함수는 가려져서 "Knight::Move()" 문자열이 출력된다.
1. 이점
객체지향 이전 방식으로 플레이어와 기사를 움직이는 코드를 작성하고자 하면, 아래와 같이 각자의 타입에 대한 함수를 작성할 수 있을 것이다.
void MovePlayer(Player* player)
{
player->Move();
}
void MoveKnight(Knight* knight)
{
knight->Move();
}
int main()
{
Player p;
MovePlayer(&p);
Knight k;
MoveKnight(&k);
return 0;
}

그런데, MoveKnight 함수에 Player 타입의 변수를 넘기면 어떻게 될까?
int main()
{
Player p;
MoveKnight(&p);
return 0;
}

위와 같이 컴파일 에러가 발생한다. 'Is A' 관계를 생각해 보면 당연하다는 것을 알 수 있는데,
- MovePlayer(&p): Player Is A Player -> YES
- MoveKnight(&p): Player Is A Knight-> NO
즉 '모든 플레이어가 기사'라는 보장은 할 수 없기 때문이다. Player를 상속받은 클래스가 Knight 외에 Mage, Archer 등 여러 가지가 있을 수 있기 때문이다. 다시 말해 자식 클래스 -> 부모 클래스 변환은 자연스럽게 가능하나 부모 클래스 -> 자식 클래스 변환은 보장할 수 없다.
반대로, MovePlayer 함수에 Knight 타입 변수를 넘기면 어떻게 될까?
int main()
{
Knight k;
MovePlayer(&k);
}

정상적으로 빌드되는 것을 알 수 있다. 똑같이 IsA 관계를 생각해 보면 알 수 있는데,
- MoveKnight(&k): Knight Is A Knight -> YES
- MovePlayer(&k): Knight Is A Player -> YES
즉 기사는 플레이어를 상속받아 구현되었기 때문에, '모든 기사는 플레이어'임이 보장되기 때문이다.
결국, 최상위 클래스를 인자로 받는 함수 하나만 남겨 놓아도 된다는 뜻이 된다. 실제로 Player 뿐만 아니라 Monster 계열도 Player와 공통 부모 클래스를 두게 해서 전투 시스템을 설계하기도 한다.
void MovePlayer(Player* player)
{
player->Move();
}
int main()
{
Player p;
Knight k;
MovePlayer(&p);
MovePlayer(&k);
return 0;
}
2. 의문
MovePlayer 함수에 Knight 타입 변수를 넘긴 코드를 수행하면 어떤 결과가 나올까?
int main()
{
Knight k;
MovePlayer(&k);
}

재밌게도 Player의 Move 함수가 실행되는 것을 알 수 있다.
분명 우리는 Knight 클래스에서 Move 함수를 재정의하였다. 그런데 최상위 클래스를 인자로 받는 함수 하나로 관리하려고 하니, Knight의 Move 함수를 호출할 수 없는 상황이 발생한다. 왜 그럴까?
바인딩
한국정보통신협회의 용어 사전에 바인딩을 다음과 같이 정의하고 있다.
메모리 주소, 데이터형 또는 실제값으로 배정되는 것이 이에 해당되며, 원시 프로그램의 컴파일링 또는 링크 시에 확정되는 바인딩을 정적 바인딩(static binding)이라 하고, 프로그램의 실행되는 과정에서 바인딩되는 것을 동적 바인딩(dynamic binding)이라고 한다.
즉 컴파일을 수행하면 우리가 작성한 C++ 코드가 기계어로 변환되는데, 이 과정에서 함수 호출 부분이 어떤 함수를 호출할지를 결정하여 주소를 매핑한다. 이 과정을 바인딩이라 한다.
우리가 작성한 MovePlayer와 같은 일반 함수들은 정적 바인딩으로 동작한다. 우리가 MovePlayer(&k)와 같이 기사 타입 변수의 주소를 넘기더라도, 컴파일 시점에서 'Player 타입 변수를 받아 Player의 Move를 호출한다'가 결정되었기 때문에 Knight의 Move 함수를 호출할 수 없는 상황이 발생한 것이다.
Knight의 Move 함수가 호출되려면 실행 시점에 호출할 함수가 결정되는 동적 바인딩이 동작해야 한다. 동적 바인딩으로 동작하게 하려면 어떻게 해야 할까?
2. 가상 함수(Virtual Function)
이제부터 Player의 Move 함수에 virtual 키워드를 추가한다. Knight에는 override 키워드를 추가한다.
- 참고로 Knight의 Move 함수에 override 키워드가 없더라도, 상위 클래스에서 virtual 키워드로 선언하면 모든 자식 클래스에서도 가상 함수로 동작한다.
- override 키워드는 가상 함수를 재정의했음을 명확하게 표시하기 위해 C++11에서 추가된 문법이다.
class Player
{
public:
virtual void Move() { cout << "Player::Move()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
void Move() override { cout << "Knight::Move()\n"; }
public:
int _stamina;
};
int main()
{
Knight k;
MovePlayer(&k);
return 0;
}

이제 동적 바인딩이 동작해 Knight의 Move가 실행되는 것을 알 수 있다.
그런데 위에서 일반 함수는 정적 바인딩으로 동작한다고 언급했다. 그리고 여전히 그렇다. 즉 MovePlayer 함수는 여전히 'Player'인 것만 알 수 있다. virtual 키워드 하나 추가했다고 어떻게 Knight의 Move를 호출할 수 있게 된 것일까?
1. 원리
Player와 Knight에 생성자 코드를 추가하고, MovePlayer(&k) 부분에 breakpoint를 걸어보자.
class Player
{
public:
Player() { _hp = 100; }
virtual void Move() { cout << "Player::Move()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
Knight() { _stamina = 100; }
void Move() override { cout << "Knight::Move()\n"; }
public:
int _stamina;
};
void MovePlayer(Player* player)
{
player->Move();
}
int main()
{
Knight k;
MovePlayer(&k); // breakpoint
return 0;
}

Knight 타입 변수 k를 생성한 후, k의 주소에 접근했을 때 확인할 수 있는 값이다. 현재 Player와 Knight의 생성자에서 각각 변수 _hp, _stamina를 100이란 숫자로 설정했기 때문에 2번째, 3번째 줄 제일 왼쪽 값이 64(16진수), 10진수로 100이란 값이 나타나는 것을 확인할 수 있다.
그렇다면 첫 번째 줄에 있는 숫자는 무엇을 의미하는 것일까? 참고로 아래와 같이 virtual 키워드를 제거했을 경우 위 값은 나타나지 않는다.
class Player
{
public:
Player() { _hp = 100; }
void Move() { cout << "Player::Move()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
Knight() { _stamina = 100; }
void Move() { cout << "Knight::Move()\n"; }
public:
int _stamina;
};

2. 가상 함수 테이블(vftable)
정답은 가상 함수 테이블이다. 위키백과 따르면 '클래스에 가상 함수를 정의할 때마다 컴파일러가 클래스에 추가하는 숨겨진 멤버 변수'라 한다.
가상 함수 테이블 또한 일종의 주소이므로 4byte(64비트 환경일 경우 8byte) 크기를 갖는다. 해당 주소를 따라가면 호출해야 할 함수들의 주소가 나열돼 있다. 한번 아래와 같이 가상 함수를 추가하고, 다시 한번 메모리 주소를 확인해 보자.
class Player
{
public:
Player() { _hp = 100; }
virtual void Move() { cout << "Player::Move()\n"; }
virtual void Die() { cout << "Player::Die()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
Knight() { _stamina = 100; }
void Move() override { cout << "Knight::Move()\n"; }
void Die() override { cout << "Knight::Die()\n"; }
public:
int _stamina;
};
void MovePlayer(Player* player)
{
player->Move(); // breakpoint
player->Die();
}

2, 3번째 줄은 아까와 같이 100이란 값이 들어있고, 1번째 줄이 우리가 확인하고자 하는 vftable의 주소이다. 한 번 Move() 함수를 호출하는 부분의 어셈블리를 확인해 보자.
player->Move();
00007FF7155E2460 mov rax,qword ptr [player]
00007FF7155E2467 mov rax,qword ptr [rax]
00007FF7155E246A mov rcx,qword ptr [player]
00007FF7155E2471 call qword ptr [rax]
00007FF7155E2473 nop

첫 번째 줄은 player의 주소를 rax 레지스터에 저장하는 코드이다. 메모리를 확인한 이미지에 적힌 주소와 동일하다는 것을 알 수 있다.


두 번째 줄은 player 객체 주소의 첫 번째 오프셋에 있는 값을 다시 rax 레지스터에 저장하는 코드이다. 즉, 가상 함수 테이블 주소를 의미하는 것을 알 수 있다. 오른쪽 이미지를 보면 첫 번째 오프셋에 해당하는 주소값이 담긴 것을 확인할 수 있다.

그리고 네 번째 줄에서 rax 레지스터에 담긴 주소를 타고 가, 해당 주소에 있는 값을 call 해주는 것을 확인할 수 있다. 즉 가상 함수 테이블을 타고 들어가는 것을 알 수 있다.
이번에는 Die 함수에 대한 어셈블리를 확인해 보자.
player->Die();
00007FF7155E2474 mov rax,qword ptr [player]
00007FF7155E247B mov rax,qword ptr [rax]
00007FF7155E247E mov rcx,qword ptr [player]
00007FF7155E2485 call qword ptr [rax+8]
00007FF7155E2488 nop
Move 함수와 거의 동일한 것을 알 수 있다. 딱 하나 다른 부분이 call 하는 부분인데, Move에서는 rax 레지스터에 담긴 주소를 타고 들어갔다면 Die는 +8이란 오프셋을 준 주소를 타고 들어가는 것을 알 수 있다. 64비트 시스템이라 8바이트만큼의 오프셋을 나타내는 것이다.

즉 가상 함수 테이블에서 Move 함수는 첫 번째 줄, Die 함수는 두 번째 줄을 가리키는 것을 알 수 있다.
그림으로 표현해보면 아래와 같다. 이 그림은 Knight 클래스에서 Die 함수를 override 하지 않았을 때의 상황을 나타낸다.
class Player
{
public:
Player()
{
_hp = 100;
}
virtual void Move() { cout << "Player::Move()\n"; }
virtual void Die() { cout << "Player::Die()\n"; }
public:
int _hp;
};
class Knight : public Player
{
public:
Knight()
{
_stamina = 100;
}
void Move() override { cout << "Knight::Move()\n"; }
public:
int _stamina;
};

1. 가상 함수 테이블의 주소 값들은 어떻게 채워지는가?
이번에는 Knight 변수를 생성하는 부분에 breakpoint를 걸어보자.
int main()
{
Knight k; // breakpoint
MovePlayer(&k);
}

한 번 이 부분을 더 안쪽으로 파고 들어가 보자.
class Knight : public Player
{
public:
Knight()
00007FF63E181E30 mov qword ptr [rsp+8],rcx
00007FF63E181E35 push rbp
00007FF63E181E36 push rdi
00007FF63E181E37 sub rsp,0E8h
00007FF63E181E3E lea rbp,[rsp+20h]
00007FF63E181E43 lea rcx,[__C34D5134_main@cpp (07FF63E19502Eh)]
00007FF63E181E4A call __CheckForDebuggerJustMyCode (07FF63E181406h)
00007FF63E181E4F nop
00007FF63E181E50 mov rcx,qword ptr [this]
00007FF63E181E57 call Player::Player (07FF63E181140h) // player 생성자 호출
00007FF63E181E5C nop
00007FF63E181E5D mov rax,qword ptr [this]
00007FF63E181E64 lea rcx,[Knight::`vftable' (07FF63E18BC98h)] // vftable 세팅
00007FF63E181E6B mov qword ptr [rax],rcx
{
_stamina = 100;
00007FF63E181E6E mov rax,qword ptr [this]
00007FF63E181E75 mov dword ptr [rax+10h],64h
}
...
}
Knight의 생성자 부분에 대한 어셈블리 단으로 들어왔다. stamina 변수를 초기화하는 부분 이전, 즉 선처리 영역에서 vftable을 세팅하는 코드가 있는 것을 확인할 수 있다.
자세히 보면 vftable을 세팅하는 코드 3줄 위에 Player의 생성자를 call 하는 코드가 존재한다. 즉 Player의 생성자에서도 동일하게 vftable 세팅 등의 과정을 거치는데, 자식 클래스인 Knight에서 Player에서 세팅한 vftable을 덮어쓴다는 것을 알 수 있다. 이를 한 번 확인해 보자.

현재 Player의 생성자를 호출하기 직전, this 포인터가 가리키는 주소를 확인해보고 있다. 아직 부모 클래스의 생성자가 호출되지 않아 아무런 값이 세팅되지 않은 것을 확인할 수 있다.

Player의 생성자를 호출하면, Player의 vftable과 hp 변수가 세팅되는 것을 확인할 수 있다. 메모리 탭의 첫 번째 줄이 vftable에 대한 주소, 두 번째 줄이 hp 변수에 100이란 값이 들어간 것이다.

그런데 Knight의 vftable을 세팅하는 코드를 수행하고 나니, 맨 첫 번째 줄의 값이 바뀐 것을 확인할 수 있다. 변경된 저 값이 곧 Knight의 vftable을 가리키는 것을 알 수 있다.

변경된 vftable 주소를 타고 들어가 보면, Knight의 Move 함수와 Die 함수의 주소가 보이는 것을 확인할 수 있다.
2. 오버헤드
void MovePlayer(Player* player)
{
Knight* k = (Knight*)player;
k->_stamina = 100;
}
위 코드와 같이 인자로 받은 Player 타입 변수를 다시 Knight 타입 변수로 변환하여 사용할 수도 있을 것이다. 하지만 이는 매우 위험한데,

Knight는 Player를 상속받았기 때문에 Player의 부분을 들고 있는 것이라 표현할 수 있다. 그런데 만약 넘겨받은 변수가 Knight가 아닌 Player였다면 메모리 상으로 Knight 클래스로 정의되는 부분이 없다. 즉 "엉뚱한 메모리 영역을 침범"하는 일이 발생한다.
이 외에도 Knight가 아니라 Mage 같이 Player를 상속받은 다른 클래스가 온다면 또 다른 메모리 구조를 가지게 되므로 이 또한 엉뚱한 메모리 영역을 건드리게 되는 것이므로, 별도의 확인 없이 변환해서는 안된다.
3. 순수 가상 함수
사실 여러 직업이 존재하는 게임을 한 번 즐겨봤다면 그냥 "플레이어"라는 직업을 생성하는 일은 없다. "전사", "마법사", "궁수"와 같은 특정 직업군을 생성하게 되는데, Player라는 클래스는 프로그래머가 코드 상에서 통합적으로 관리하기 위해 존재하는 일종의 추상 클래스 역할이 강하다. 즉 위에서 작성한 것처럼 Player 타입의 변수를 그냥 생성해서는 안된다.
이를 가능하게 해주는 것이 순수 가상 함수로, 구현은 없고 인터페이스만 전달하는 용도로 사용된다.
- 참고로 모던 C++에서는 = 0; 이란 문법 대신 abstract 키워드로 대체해서 사용할 수 있다.
class Player
{
public:
Player()
{
_hp = 100;
}
virtual void Move() { cout << "Player::Move()\n"; }
virtual void Die() { cout << "Player::Die()\n"; }
virtual void Attack() = 0; // 순수 가상 함수
public:
int _hp;
};
int main()
{
Player p;
Knight k;
MovePlayer(&k);
return 0;
}

이 상태에서 빌드를 수행하면 컴파일 에러가 발생한다.
- 어떤 클래스에 순수 가상 함수가 하나라도 존재하는 순간, 해당 클래스는 추상 클래스로 간주한다. 추상 클래스는 직접적으로 객체를 생성할 수 없다. 그렇기 때문에 위에서 '추상 클래스를 인스턴스화할 수 없습니다'라는 에러가 발생한 것이다.
- 또한 추상 클래스를 상속받은 클래스는 순수 가상 함수를 반드시 재정의해야 하며, Knight가 현재 Attack 함수를 override 하지 않았기 때문에 역시 에러가 발생한 것이다.
즉 아래와 같이 Knight에서 Player의 순수 가상 함수 Attack을 재정의하고, Player의 인스턴스를 생성하지 않아야 정상적으로 컴파일되는 것을 알 수 있다.
class Knight : public Player
{
public:
Knight()
{
_stamina = 100;
}
void Move() override { cout << "Knight::Move()\n"; }
void Die() override { cout << "Knight::Die()\n"; }
void Attack() override { cout << "Knight::Attack()\n"; }
public:
int _stamina;
};
int main()
{
Knight k;
MovePlayer(&k);
return 0;
}

'Computer Science > Programming Language' 카테고리의 다른 글
| [C++/Unreal] RTTI와 Reflection (0) | 2025.11.07 |
|---|---|
| [C++] 메모리 관리 2: 메모리 할당(Allocator) (6) | 2025.08.13 |
| [C++] 메모리 관리 1: 스마트 포인터 (6) | 2025.08.05 |
| [C++] 형 변환(Type Casting)과 캐스팅 연산자 (0) | 2025.05.24 |