Virtual Function Table(VFT)의 구조와 스킴

 

조금 간단하게 구현되어 있는 언어의 VFT부터 확인하자.

 C++의 동적 바인딩 메커니즘은 생각보다 조금 복잡하다. 우리가 생각 했던 가정을 조금 확장해서 객체지향적 언어의 시조 격인 Smalltalk VFT를 가지고 동적 바인딩을 구현하는 과정을 살펴 보자. C에서 클래스와 유사하게 추상화 할 수 있는 구조체의 경우는 한편으로는 public으로 공개된 데이터 멤버들을 묶는 도구라고 볼 수 있는데 이 구조체는 프로그램이 컴파일 시에 생성되어야 하는 각 변수의 크기와 양식을 결정하기 위한 일종의 레이아웃 규약으로 사용되며 실행 시에는 구조체와 관련된 정보는 전혀 필요가 없다. 이런 구조체들은 마치 멤버 변수로 선언된 변수들을 메모리에 선형적으로 나열 시킨 것과 같다. 하지만 객체 지향 언어의 경우 클래스와 관련된 정보는 동적 바인딩을 처리하는 과정에서 필요 하기 때문에 컴파일 이후에도 계속 남아 있다. 물론 이러한 정보는 동적 바인딩 시에만 사용되는 것은 아니다. 앞에서 우리가 확인 한 바와 같이 멤버 변수 이름, 네임스페이스 그리고 개수 등 여러 가지 정보를 같이 가지고 있는데 이는 실행 시에 타입을 식별하는 RTTI, 자바의 Reflection 기능과 유사한 클래스 멤버의 정보 인출 등을 위해서도 사용된다.


사용자 삽입 이미지

<그림 1>  smalltalk VFT 구조

 

 

 위 그림은 Smalltalk의 동적 바인딩 메커니즘을 좀 더 간략화 시킨 것으로 좀 더 이해를 편리 하게 하였다. 좌측에 보이는 두 개의 인스턴스들은 자신을 정의한 클래스에 대한 포인터와 인스턴스 변수(C C++에서는 멤버 변수)들을 가지고 있다. 이중에 클래스 포인터를 통하여 클래스 자료 구조에 접근 하게 되는데 이 클래스 자료구조에는 멤버들의 이름과 멤버수, 그리고 메서드 사전(Method dictionary)라는 일종의 함수 맵(Function map)을 가지고 있다. 함수 맵의 각 엔트리들은 셀렉터(Selector)라고 부르는 필드와 메서드 포인터를 가지고 있는데 셀렉터는 각각의 메서드 이름을 그리고 거기에 매칭되는 메스드 포인터는 실제 메서드가 구현되어 있는 주소를 가리킨다. 각 인스턴스의 클래스 포인터는 우리가 앞에서 Visual Studio 예제에서 보았듯이 프로그래머에게 감춰진 상태로 삽입된다. 만약에 어떤 인스턴스의 메서드가 호출 되면 (smalltalk에서는 메서드 호출이라고 부르지 않고 메시지를 보낸다고 부른다. 이는 TAU와 같은 CASE툴에서도 마찬가지로 메시지를 보내는 것을 기본으로 하고 있다. 하지만 우리는 smalltalk를 배우고자 하는 것은 아니므로 앞으로 계속 메서드 호출이라는 용어를 사용 하겠다.)버추얼 머신은 각 인스턴스의 클래스 포인터를 이용하여 클래스 자료 구조에 접근 하게 된다. 여기서 가장 핵심이 되는 부분은 메서드 사전인데 이 메서드 사전의 셀렉터와 호출된 메서드의 이름이 일치 하는 것을 찾아서 그 해당 필드의 메서드 포인터를 가지고 메서드를 실행 하게 된다. 우리가 봤던 함수 포인터와 같은 것이다. 만약에 해당 클래스의 메서드 사전에 호출된 메서드가 없다면 클래스 자료 구조의 슈퍼 클래스 포인터(C++로 치면 기본 클래스)를 찾아서 메서드 사전을 뒤지는 일을 반복 한다.

 

C++은 객체 지향적이라도 C의 아들이다.

읽으면서 독자들도 짐작 했겠지만, smalltalk의 동적 바인딩은 근본적인 문제를 안고 있다. 그것은 퍼포먼스 문제이다. 대부분의 다이나믹 프로그래밍 기법(테이블에 모든 내용을 저장하는 것을 기본으로 하는 알고리즘)은 항상 검색 시간이 큰 문제이다. 위에서 모든 셀렉터를 비교하면서 뒤진다는 것은 일단 VFT가 없는 것에 비하여 비교 하는 인스트럭션이 더 요구 될 것이며, 뒤지는 검색 시간이 메서드 수에 비례한다는 것이다. 클래스가 덩치가 커지고 상속의 깊이가 깊어지면 그만큼 실행시간은 느려지게 된다. C는 전형적으로 언어적인 아름다움, 또는 재사용성 이런 것 보다는 퍼포먼스를 중요시 한다. 그도 그럴 것이 적은 인스트럭션에 많은 일들을 수행해야 하는 임베디드에 적격인 언어이기 때문이다. C++의 태생이 C일진데 아무리 객체 지향 언어라고 하지만 자신의 태생을 무시 할 수는 없는 것이다. C++이 동적 바인딩을 처리 하기 위한 메커니즘은 smalltalk와는 또 다른 방법을 선택하여 퍼포먼스 향상을 꾀하였다. 또 다른 방법이라는 것은 결국 고정된 가상 함수 테이블(VFT : Virtual Function Table)의 이용이다. 이제 우리가 처음 가정을 나름대로 구현 해볼 때 나오던 이름이 나오기 시작한다. 이 가상함수 테이블은 C++이 컴파일 과정에 각각의 클래스를 위해 구축하는데 이 테이블에는 단순히 기계어가 저장되는 RO 영역의 포인터만 저장되고 단순한 포인터 배열로 잡히게 된다. 필자는 여기서 고정된 가상 함수 테이블이라고 이야기 하였는데 그렇게 부른 이유는 모든 가상 함수 테이블에 적용 되는 원칙 때문이다. 우리가 설계한 하나의 클래스 계층 구조 안에 들어가는 클래스라면 그 안에 가상 함수의 메서드를 저장하는 테이블의 인덱스는 모두 같다. Smalltalk의 셀렉터가 필요 없고 각 셀렉터를 비교 하는 연산뿐만 아니라 매칭 되는 메서드를 찾는데 걸리는 시간이 상수시간 안에 이루어지는 것을 보장하고 결국 이 가상 함수 테이블을 이용 한다는 것은 실행시간의 퍼포먼스를 향상시키는 것을 보장한다. 물론 이것은 smalltalk의 메서드 사전보다 엔트리 공간을 그만큼 많이 요구하게 되는데 결국 저장 공간과 퍼포먼스의 트레이드 오프에서 C++은 퍼포먼스를 선택한 것이다.

 

사용자 삽입 이미지

<그림 3>  C++ VFT

 

그림에서 가장 상위에 CObject 추상화 클래스가 존재 한다. 이 추상화 클래스는 가상함수 2와 가상함수 4의 구현을 하지 않고 순수 추상화 함수로 선언하였다. 이러한 경우 이 추상화 클래스의 VFT 2,4의 엔트리(Entry)에 각각 NULL을 가지게 됨으로써 그 가상함수가 가리키는 구현 자체가 없음을 시사한다. 이러한 VFT를 가지는 CObject 실제 인스턴시에이션을 할 수 없다. 여기서 상속 받은 CDerived1는 가상 함수 2, 4에 대해서 구현 하고 1,3의 가상 함수는 CObject로부터 상속 받았다. CDerived1 VFT를 살펴 보면 각각 1,3은 자신의 부모 클래스인 CObejct의 메서드를 포인팅하고 구현한 2,4번에 대해서는 자신의 메서드를 포인팅하도록 테이블을 구성하고 있다. 바깥에서 개발자가 CDerived1의 인스턴스를 선언하여 사용하는 경우 맨 앞 4바이트의 주소는 CDerived1 VFT를 가리키게 된다. 이때 사용자가 가상 함수1을 호출 하게 되면 VFT의 첫번째 엔트리를 참조하여 CObject의 가상 함수1을 바로 호출 하게 될 것이며 가상함수 2를 호출 하게 되면 CDerived2의 가상 함수2를 호출 할 수 있게 된다. 물론 이 인스턴스의 선언이라는 것은 동적 할당을 반드시 받아야 한다. 그림에서 이듯 p라는 이름으로 동적 할당 받아 인스턴시에이션을 하였다. 이러한 코드는 컴파일러에 의해 아래와 유사하게 변형된다.

p->virtualfunc1();

p->virtualfunc2();

p->virfualfunc3()

 

(*(p->vft[0]))();

(*(p->vft[1]))();

(*(p->vft[2]))();

<코드 4> 가상함수의 호출에 대한 의사코드

 

두 번째로 CDerived1에서 상속 받은 CDerived2 클래스 또한 똑 같은 방식으로 VFT를 구성하게 된다. 아마 독자 중에 smalltalk와 구분이 잘 아가는 경우도 있을 것이다. 그도 그럴 것이 smalltalk의 경우 메서드 디렉터리라는 것이 있는데 이게 사실 C++ VFT와 같은 기능을 하기 때문이다. 하지만 다시 한번 생각 해보자. 메서드 디렉터리는 각각의 셀렉터를 통하여 검색을 시도 한다. 이때 앞서 말한 것과 같이 검색 시간이 걸릴 것이다. 검색을 모두 시도 했는데 이 메서드 디렉토리에는 개발자가 호출한 메서드와 일치 하는 엔트리가 없을 경우 어떻게 하겠는가? Smalltalk는 이러한 경우에 자신의 슈퍼 클래스의 포인터를 다시 참조하여 슈퍼 클래스의 메서드 디렉터리를 다시 뒤져 이에 대한 메서드를 호출 하게 된다. C++에서 상속 받은 메서드를 오버라이딩이나 오버로딩을 하지 않고 그대로 사용 하는 경우 부모 클래스의 VFT를 찾아 실제 자신이 상속 받은 메서드를 호출 하게 되는 것이다. 하지만 실제 C++ VFT를 보면 CDerived1의 경우에도 엔트리는 4개 모두를 가진다. 실제 구현한 메서드는 가상함수 2,4 밖에 없는데도 말이다. VFT는 선언된 클래스가 상속 받은 부모 클래스의 가상 함수 모두를 포함하는 VFT를 생성 한다. 이로써 같은 족보를 가진 클래스의 모든 가상 함수의 호출은 모두 같은 인덱스를 가지게 된다. 즉 특정 테이블 엔트리의 주소는 원칙적으로 같은 서비스(메서드)의 위치를 가지는 게 되는데 이는 테이블을 검색 할 필요 없이, 또는 테이블에 없을 경우 부모 클래스를 다시 뒤질 필요 없이 바로 접근이 가능 하다는 것이다.

이러한 점에서 C++로 작성된 소프트웨어의 실행 속도는 C언어로 작성된 소프트웨어의 실행 속도에 비해 크게 느리지 않다고 평가 되고 있다. 일반적으로 C로 작성된 프로그램의 실행 속도를 100으로 본다면 smalltalk의 경우 그 두배인 200정도가 들게 되지만 폴리모피즘을 위한 가상함수를 가지고 있는  C++의 실행 속도는 20%정도 느린 120정도로 평가 되고 있다. 앞서 계속 이야기 된 바와 같이 C++의 최대 단점인 VFT의 공간에 따른 프로그램 사이즈이다. 만약 위에서 CObject가 가상함수를 99개 순수 가상함수를 1개 선언하였고 상속을 받은 CDerived1 클래스에서 그 순수 가상 함수 1개를 구현 하였다면 실제 CDerived1 클래스에서 구현된 1개 함수이외에 VFT에는 부모 클래스에서 정의된 메서드를 가리키기 위해 99개의 엔트리를 더 생성한다. 물론 smalltalk의 경우는 1개만 생성하였을 것이다. 근래 컴퓨팅 능력을 비교 해볼 때 이러한 단점은 사실 단점으로 파악되기 힘들만큼 미비하다. 다만, 임베디드 환경에서는 전혀 환영 받지 못한다는 것에 대해 아쉬울 뿐이다.

이러한 면에서 C++의 창시자인 Stroustrup은 사용하지 않는 또는 사용해야 하지 않는 기능들과 관련하여 메모리 사용이나 처리 시간 등의 부담을 떠안지 않는 것이 C++의 기본 철학이라고 이야기 했듯 임베디드나 또는 WDM과 같은 제한적 환경에서는 동적 바인딩을 하지 않도록 여러분의 코드를 정적 바인딩 시킬 수 있다.