4 분 소요

가상 함수 테이블

정적 결합은 컴파일러가 호출될 함수의 주소를 분명히 알 때 사용하는데 비해 동적 결합은 호출될 함수를 컴파일중에 결정할 수 없을 때 사용한다. 실행중에 호출 함수를 결정해야 한다면 동적 결합에 의해 생성되는 코드는 객체의 타입을 판별해서 이 타입에 맞는 함수를 선택하는 동작으로 번역되어야 할 것이다. 뭔가를 판별하는 동작이 필요하다면 가상 함수 호출문은 if 문과 비교 연산문 또는 switch문 등으로 번역되는 것일까? 동적 결합은 과연 어떤 식으로 구현될지 궁금할 것이다.

C++ 언어는 가상 함수의 정의와 동작 방식에 대해서는 분명하게 규정하고 있지만 이 함수 호출문을 어떤 식으로 구현해야 한다고 구체적으로 명시하고 있지는 않으며 어떠한 강제도 없다. 그래서 동적 결합을 구현하는 방식은 컴파일러마다 다를 수 있으며 컴파일러 개발자가 C++의 요구에 맞게 작성하기만 하면 된다. 앞에서 예를 든 if문으로 만든 코드도 물론 가능하다. 동적 결합을 구현하는 방법에는 여러가지가 있겠지만 대부분의 컴파일러는 vtavle이라는 가상 함수 목록을 작성하고 각 객체에 vtable을 가리키는 숨겨진 멤버 vptr을 추가하는 방식을 사용한다.
vtable이란 가상 함수의 번지 목록을 가지는 일종의 함수 포인터 배열이다. 즉, 이 클래스에 소속된 가상 함수들이 어떤 번지에 저장되어 있는지를 표 형태로 저장해 놓은 목록이다. 컴파일러는 가상 함수를 단 한 개라도 가진 클래스에 대해 vtable을 작성하는데 이 테이블에는 클래스에 소속된 가상 함수들의 실제 번지들이 선언된 순서대로 기록되어 있다. 그리고 이 클래스 타입의 객체가 생성될 때 각 객체의 선두에 vtable의 번지인 vptr을 기록한다. vptr이 항상 객체의 선두에 오고 다음으로 이 객체의 멤버들이 순서대로 온다. 다음 예제를 통해 vtable의 실체를 구경해 보도록 하자.

class B 
{
private:
    int memB;

public:
    B() : memB(0x11111111)
    
    virtual void f1() { puts("B::f1"); }
    virtual void f2() { puts("B::f2"); }
    virtual void f3() { puts("B::f3"); }
    void normal() { puts("non virtual"); }
};

class D : public B
{
private:
    int memD;

public:
    D() : memD(0x22222222) {}
    virtual void f1() { puts("D::f1"); }
    virtual void f2() { puts("D::f2"); }   
};

int main()
{
    B *pB;
    B b;
    D d;

    pB = &b;
    pB->f2();
    pB = &d;
    pB -> f2();
    pB -> f3();

    return 0;
}

Result1

B가 세 개의 가상 함수와 하나의 비가상 함수를 정의 하고 있으며 이를 상속 받은 D는 그중 f1, f2를 재정의하고 있다. 테스트의 편의를 위해 멤버 변수도 하나씩 선언했다. B와 D의 객체 b와 d가 생성되었을 때 이 객체들이 메모리에 구현된 모양을 그려보면 다음과 같다.
1

컴파일러는 B클래스를 위해 B클래스에 속한 가상 함수의 번지를 vtable로 작성한다. 이때 비가상 함수의 번지는 목록에서 제외된다. 그래서 vtable normal 함수의 번지는 없는데 이 함수는 정적으로 결합되므로 테이블에 있을 필요가 없다. B타입의 b객체에는 자신의 멤버 변수 memB앞에 B클래스의 vtable에 대한 포인터 vptr이 먼저 배치되고 이 포인터가 가리키는 vtable에는 자신이 호출할 수 있는 가상 함수들에 대한 실제 번지들이 기록되어 있다. B클래스는 모든 가상 함수의 코드를 정의하고 있으므로 vtable에는 자신의 멤버 함수들에 대한 번지만 있다.
D 클래스도 가상함수를 가지고 있으므로 컴파일러는 D에 대해서도 vtable을 작성한다. 이 테이블의 f1, f2는 D가 재정의한 함수를 가리키고 있으며 f3는 B로부터 상속받은 B::f3를 가리키고 있다. 이 표에 의해 D타입의 객체가 f1, f2를 호출하면 D::f1, D::f2가 호출되지만 f3에 대해서는 상속받은 B::f3가 호출되어야 한다는 것을 알 수 있다. D타입의 객체 d에는 상속받은 memB와 memD 앞에 D클래스의 vtable을 가리키는 포인터 vptr이 배치되어 있다.
이 상태에서 pB->f2() 호출문이 처리되는 과정을 상상해 보자. pB가 b객체 그러니까 B타입의 객체를 가리키고 있다면 b객체의 vptr이 가리키는 vtable에서 호출할 함수의 번지를 결정한다. vtable에는 가상 함수들의 번지가 선언된 순서대로 작성되어 있고 컴파일러는 pB->f2가 두번째 가상함수라는 것을 알 수 있으므로 vtable의 두 번째 주소를 호출하면 된다. 만약 pB가 D타입의 객체인 d를 가리키고 있다면 d객체의 vptr이 가리키는 vtable에서 호출할 함수의 번지를 찾는다. 결국 어떤 타입의 객체가 전달되는가에 따라 참조하는 vtable이 바뀌고 따라서 실제 호출될 함수도 달라지는 것이다. f2

vtable을 사용하는 방법은 실행중에 호출할 함수를 결정한다기보다 호출할 함수의 목록을 vtable에 미리 작성해 놓고 실행중에는 객체의 vtable을 찾고 vtable에서 다시 호출할 함수의 번지를 찾는 방법이다. 즉, 실행중에 호출할 함수를 신속하게 결정하기 위해 컴파일할 대 모든 예비 동작을 미리 취해 놓는다고 할 수 있다. 이렇게 만반의 준비가 되어 있으면 가상 함수 호출문은 객체에서 vtable을 찾고 vtable에서 함수 번지를 찾아 점프하는 문장으로 번역할 수 있다. 컴파일 속도가 좀 느려지고 실행 파일이 약간 커지겠지만 가상 함수 호출 속도는 극적으로 빨라진다.
vtable의 장점은 미래에 추가될 자식 클래스에 대해서도 아주 잘 동작한다는 점이다. D를 파생시켜 G클래스를 만들었고 G는 f1가상함수를 재정의한다고 해 보자. 이 클래스를 컴파일 할때 G에 대한 vtable이 작성될 것이고 이 테이블에는 G::f1, D::f2, B::f3의 주소가 작성될 것이다. 그래서 B *형의 pB가 G형의 객체 g를 가리키는 상황이 되더라도 pB->f1() 호출문은 새로 추가된 G의 f1을 잘 찾아간다. pB->f1() 호출문이 이미 컴파일되어 있더라도 g의 vtable이 이 호출문의 요구에 맞게 작성되기 때문이다.
3

G다음에 X, Y, Z가 계속 파생되어도 pB는 이 객체들을 가리킬 수 있고 각 클래스는 자신이 호출할 가상 함수 목록의 vtable을 가지므로 항상 정확하게 동적 결합된다. 계층이 복잡해지면 vtable도 무척 복잡해지겠지만 컴파일러가 이 테이블을 알아서 잘 관리할 것이고 테이블을 뒤져 함수를 호출하는 일은 CPU의 몫이므로 우리는 클래스 계층만 잘 디자인하면 된다.

호출할 함수가 해당 클래스의 몇 번째 가상함수인지를 먼저 조사(n)하고 포인터가 가리키는 곳의 첫 번째 숨겨진 멤버에 있는 vptr이 가리키는 곳의 vtable의 n번째 주소가 가리키는 함수를 호출하는 것이다. 이를 식으로 표현해보면

p->vptr->vtable[n]

을 호출한다고 할 수 있다. 가상 함수는 vtable을 통해 호출되고 그러기 위해서는 번지를 가져야 하므로 아무리 코드가 짧아도 인라인이 될수는 없다.
가상 함수를 호출하는 과정이 이렇게 복잡하기 때문에 동적 결합은 정적 결합보다 호출 속도가 느리다. 뿐만 아니라 가상 함수를 가진 클래스별로 vtable이라는 여분의 메모리를 더 소모하며 객체들도 vptr을 위해 4바이트씩 더 커진다. 그래서 멤버 함수에 대한 결합 방법의 디폴트가 정적으로 되어 있으며 virtual 키워드를 쓸 때만 동적 결합을 하는 것이다.