COM 구조에 관한 스터디 중 Inteface 지원을 위한 vptr 및 vtable에 관한 내용을 보다, 직접 vptr과 vtable memory layout을 쑤셔보고자 시작. 이 내용을 한두번 본 것도 아닌데 또다시 그냥 지나치려니 영 감질 맛이 나는거다. 하긴, 지금 아니면 언제 이 구조를 뜯어보겠어?

일단, vtable은 함수 포인터의 배열로 이루어진 가상함수 테이블을 의미하며, vptr은 vtable을 가리키는 C++ 개체에 숨겨진 포인터를 뜻한다. 이들 vtable 및 vptr이란 명칭은 관례적으로 그리 사용한다고. 물론 가상함수가 없는(해당 클래스에 virtual 키워드가 없는) 개체는 이들 둘 모두 없다. 따라서 해당 개체의 크기는 vptr의 크기(32bit OS일 경우 4byte)만큼 작아지겠다.

앞으로 보일 예제는 32bit WindowsXP 위의 Visual C++ 2005 버전으로 테스트한 것이다. 참고로, C++ 표준은 vptr의 개체내 위치나 vtable 구현법에 대해 지정하지 않는다고 한다. 따라서 컴파일러에 따라 결과는 달라질 것이다.
확인할 사항은 첫째 vptr 및 vtable의 존재 여부와 다중 상속시 vptr의 개수이다. 확인 방법은 직접 vptr과 vtable의 포인터를 얻어, 이들 포인터를 통해 각 가상 함수를 호출하는 것이다. 다음은 이를 확인해 줄 마루타 클래스들. 순수 추상 클래스까지나 쓸 필요는 없었지만 COM 인터페이스 구현을 모방하기 위해 일부러 이를 선택했다.
class B1
{
public:
    virtual void B1_1() = 0;
    virtual void B1_2() = 0;
};
 
class B2
{
public:
    virtual void B2_1() = 0;
};


class D : public B1, public B2
{
public:
    string var_;

public:
    D() : var_("Hello!") {}

    virtual void B2_1() { cout << "B2" << endl; }

    virtual void D1() { cout << "D1" << endl; }
    virtual void D2() { cout << "D2" << endl; }

    virtual void B1_2() { cout << "B1_2" << endl; }
    virtual void B1_1() { cout << "B1_1" << endl; }
};
 
별거 없다. 두 개의 base 클래스(B1, B2)와 이들을 다중 상속하면서 string 멤버 변수가 하나 있는 D 클래스이다.

일단 본격적인 테스트를 위한 주요 항목 값을 검사했다(16진수 값 읽기는 워낙 쥐약이라 편의상 주소는 죄다 10진수 타입으로 변환했다).
D d; cout << "size of d object :" << sizeof(d) << endl; cout << "object start address :" << (int)&d << endl; cout << "size of var_ :" << sizeof(d.var_) << endl; cout << "var_ address :" << (int)&d.var_ << endl;
다음은 위 코드의 결과이다.
size of d object        :40
object start address  :1244972
size of var_              :32
var_ address            :1244980
d 개체 크기는 40byte에, 크기가 32byte인 var_의 주소는 개체 시작 주소로부터 8byte 떨어진 곳에 위치한다. 이제 멤버 변수의 주소를 알고 있으므로 멤버 변수를 직접 부르지 않고도 주소를 통해 간접적으로 변수 값을 확인할 수 있다.
string* pDsVar = reinterpret_cast<string*>((int)(&d) + 8);
cout << "d's var_ member value :" << *pDsVar << endl;
결과는 "Hello!"로 OK!. 사실 변수 주소를 확인하기 위해 변수 var_의 visiblity scope를 public으로 했지만, private으로 변경하여도 위 코드는 동작한다.

이제 본격적으로 vptr를 얻어본다. 상속한 base 클래스가 두 개이기에 각 vptr의 크기를 더하면 8byte. 이 공간은 변수 var_의 앞쪽 공간과 일치하며, 변수 var_ 뒤로는 남는 공간이 없기에 바로 이 공간에 각각의 vptr이 위치함을 알 수 있다. 이제는 각각의 vptr이 어떤 base 클래스의 vptr인지만 확인하면 된다. 편의상 B1의 vptr이 먼저 온다고 가정하고 코드를 넣어보았다.
typedef void (*Pfn)();

int* vptr1 = reinterpret_cast<int*>(&d);
Pfn* vtable1 = reinterpret_cast<Pfn*>(*vptr1);

Pfn pfn = 0;
for(int i=0; i<4; ++i)

{
    pfn = vtable1[i];
    pfn(); 
}
아래는 위 코드의 결과 값이다.
B1_1
B1_2
D1
D2
재수가 좋았다. 잘 동작한다.
위 코드에는 vptr 값을 얻어 vtable에 접근하는 방법이 나타나있다. 먼저, 개체의 (특정) 주소가 vptr 타입의 포인터도 됨을 알고 있으므로, 이를 주소 타입의 포인터로 형변환한다. 이제 vptr의 값은 vtable을 가리키는 포인터이므로 이를 vtable 타입(위의 경우 Pfn*)으로 다시 형변환한다.

이제 확신을 갖고 남은 B2의 vptr 및 vtable을 확인할 수 있다.
int* vptr2 = reinterpret_cast<int*>((int)&d + 4); Pfn* vtable2 = reinterpret_cast<Pfn*>(*vptr2); pfn = vtable2[0]; pfn();
결과는 'B2'로서 멀쩡하게 잘 나온다. 이들 vptr을 구분 짓는 요소는 상속 순서 밖에 없다. 실제로 D로의 상속 시 B1과 B2의 순서를 바꾸고 위 테스트 코드를 돌리면 당장에 오류가 발생한다. 잘못된 메모리 참조가 되겠다.

또한, 위 결과를 보면 B1의 메서드가 먼저 호출되고 그 뒤로 D의 메서드가 호출됨을 알 수 있으며, 구현 순서에 상관없이 먼저 선언된 메서드부터 호출되는 것도 확인할 수 있다. 마지막으로 하나 더. 순수 D 클래스의 메서드는 첫 번째 vtable에 가서 붙어있다는 것도 확인된다.

p.s
1. 우짜다보니 COM이란 배보다 C++ 개체 모델이란 배꼽이 더 커졌다. 헌데 타 언어와는 달리 C++ 프로그래밍을 하면 자연스리 성능을 생각하게 되고, 이를 생각하다보면 다시한번 자연스리 저수준으로 내려가 생각하게 된다. C++ 개체의 memory layout은 COM 프로그래밍 뿐 아니더라도 타 영역에서역시 자주 생각나게 하는 부분. 음... 이거 왠 변명조의 말투야?

2. 쓰다보니 컬럼 형식인 글이 되어버렸네. 마치 내가 글 순서에 따라 해당 내용을 깨닫게 된 것처럼 썰을 풀었는데, 사실은 전혀 그렇지 않다. ㅡㅡ;;

reference :
Essential COM (by Don Box)
Inside the C++ Object Model (by Stanley B. Lippman)
C++ FAQ Lite, [20] Inheritance - virtual functions (by Marchal Cline)
C++ Object series (The Hacks of Life blog)
Posted by 어쨌건간에