C++에서 가상 함수(virtual function)는 상속과 다형성을 지원하기 위한 메커니즘이며, 기반 클래스에서 선언되고 파생 클래스에서 재정의(override)될 수 있습니다. 가상 함수는 virtual 키워드를 사용하여 선언됩니다.
class Shape {
public:
virtual void draw() {
cout << "Drawing a shape" << endl;
}
};
class Circle : public Shape {
public:
void draw() override {
cout << "Drawing a circle" << endl;
}
};
가상 함수는 vtable(virtual table)이라는 특별한 테이블을 사용하여 동적으로 바인딩됩니다. 객체의 타입을 실행 시간에 결정하여 적절한 함수를 호출합니다.
Shape* shape = new Circle();
shape->draw(); // 출력: "Drawing a circle"
기반 클래스 포인터(shape)를 사용하여 파생 클래스(Circle)의 draw 메서드를 호출할 수 있습니다. 이렇게 하면 다양한 Shape 파생 클래스를 동일한 인터페이스로 처리할 수 있습니다.
가상함수는 C++에서 다형성을 구현할 때 사용되며, 가상포인터는 가상함수 테이블을 가리킵니다. 객체가 생성될 때, 가상포인터는 해당 클래스의 가상함수 테이블을 가리키게 설정됩니다. 가상함수가 호출될 때, 가상포인터를 통해 올바른 함수를 찾아 실행합니다. 이렇게 해서 런타임 다형성이 구현됩니다.
[Base Class Object]
+------------------+
| vptr | ----> [Base VTable]
+------------------+ +------------------+
| Base members | | Base::func1() |
+------------------+ +------------------+
| Base::func2() |
+------------------+
[Derived Class Object]
+------------------+
| vptr | ----> [Derived VTable]
+------------------+ +------------------+
| Base members | | Derived::func1() |
+------------------+ +------------------+
| Derived members | | Derived::func2() |
+------------------+ +------------------+
vptr: 가상포인터, 객체가 생성될 때 설정됩니다.VTable: 가상함수 테이블, 가상함수들의 주소를 저장합니다.vptr는 객체마다 고유하며, 각 객체의 vptr는 해당 객체의 타입에 따른 VTable을 가리킵니다. 반면에 VTable은 클래스 당 하나만 생성되어 해당 클래스의 모든 객체에 의해 공유됩니다. 즉, 같은 클래스의 객체들은 같은 VTable을 사용하게 됩니다.
가상함수를 호출할 때, vptr를 사용해 적절한 VTable을 찾고, 그 테이블에서 함수의 주소를 찾아 실행합니다. 이렇게 런타임 다형성이 가능해집니다.
// Assuming SomeFunction() is a virtual functions, this call
ptr->SomeFunction();
// will be tranformed into something like this:
(*ptr->__vptr[n])(ptr)
ptr->SomeFunction();이라는 가상함수 호출은 컴파일러에 의해 내부적으로 다른 형태로 변환됩니다. 이 변환된 형태가 (*ptr->__vptr[n])(ptr)입니다.
ptr->__vptr: ptr이 가리키는 객체의 가상포인터(vptr)를 의미합니다. 이 vptr는 해당 객체의 가상함수 테이블(VTable)을 가리킵니다.ptr->__vptr[n]: vptr가 가리키는 VTable에서 n번째 위치에 있는 함수의 주소를 가져옵니다. n은 SomeFunction()의 VTable 내 위치입니다.(*ptr->__vptr[n])(ptr): n번째 위치에 있는 함수를 호출합니다. ptr는 this 포인터로 전달됩니다.이렇게 하면 런타임에 적절한 함수가 호출됩니다. 이는 다형성을 구현하는 데 필수적인 메커니즘입니다.
typeid 연산자와 가상함수는 C++의 런타임 타입 정보(RTTI) 시스템에서 밀접한 관계를 가집니다. 가상함수가 있는 클래스의 객체에 typeid를 사용하면, 런타임에 객체의 실제 타입을 확인할 수 있습니다.
vptr (가상 포인터)를 통해 가상함수 테이블(VTable)에 접근하고, VTable에 저장된 RTTI 정보를 사용하여 객체의 실제 타입을 알아냅니다.typeid는 컴파일 타임에 객체의 타입을 결정합니다. 이 경우에는 VTable과 RTTI가 필요하지 않습니다.typeid를 사용하여 런타임에 타입을 확인할 때, 가상함수와 VTable이 없으면 다형성을 지원하지 않는 클래스의 객체에 대해서는 정적 타입만을 반환합니다. 가상함수와 VTable이 있는 경우에는 런타임에 객체의 실제 타입을 확인할 수 있습니다.
SomeObject: VTable
+--------+ +---------------+
| __vptr |----+ | offset_to_top | -2
+--------+ | +---------------+
| p | | | RTTI | -1
+--------+ | +---------------+
| q | +---> | ~Foo() | 0
+--------+ +---------------+
| SomeFunction | 1
+---------------+
음수 인덱스에 RTTI 정보가 저장되는 것은 구현 세부사항이므로 모든 컴파일러에서 보장되지는 않습니다. 그러나 많은 C++ 컴파일러 구현에서는 이러한 방식으로 동작합니다.
class Base {
public:
virtual ~Base() {}
virtual void func1() {}
};
class Derived : public Base {
public:
virtual ~Derived() {}
virtual void func1() override {}
virtual void func2() {}
};
[Base Class]
+------------------+
| vptr | ----> [Base VTable]
+------------------+ +------------------+
| Base members | | Base::~Base() |
+------------------+ +------------------+
| Base::func1() |
+------------------+
[Derived Class]
+------------------+
| vptr | ----> [Derived VTable]
+------------------+ +--------------------+
| Base members | | Derived::~Derived()|
+------------------+ +--------------------+
| Derived members | | Derived::func1() |
+------------------+ +--------------------+
| Derived::func2() |
+--------------------+
vptr: 가상 포인터, 객체의 VTable을 가리킵니다.VTable: 가상함수와 가상 소멸자의 주소를 저장합니다.Base 클래스의 객체는 Base VTable을 가리키는 vptr를 가집니다.Derived 클래스의 객체는 Derived VTable을 가리키는 vptr를 가집니다.Derived::func1()은 Base::func1()을 오버라이딩하므로, Derived VTable에서는 Derived::func1()의 주소가 저장됩니다.
Derived::func2()는 Derived 클래스만의 고유한 가상함수이므로, Derived VTable에 추가됩니다. 이렇게 해서 다형성이 구현됩니다.
class Base0 {
public:
virtual void func0() {}
};
class Base1 {
public:
virtual void func1() {}
};
class Derived : public Base0, public Base1 {
public:
virtual void func0() override {}
virtual void func1() override {}
};
이 예시에서 Derived 클래스는 Base0과 Base1을 상속받습니다. 따라서 Derived 객체는 두 개의 vptr를 가지며, 각각은 해당 기반 클래스의 VTable을 가리킵니다. 이렇게 다중 상속에서는 복잡한 메모리 레이아웃과 VTable 구조를 가질 수 있습니다.
[Derived Class]
+------------------+ +------------------+
| vptr to Base0 | ----> | Derived::func0() |
+------------------+ +------------------+
| vptr to Base1 | ----> | Derived::func1() |
+------------------+ +------------------+
| Derived members |
+------------------+
vptr가 필요합니다.VTable이 필요합니다.// For this:
Derived* d = new Derived;
Base1* b = d;
// the compiler will transform the code to (via vtable):
Base1* b = d + sizeof(Base0);
다중 상속 상황에서, Derived 클래스의 객체를 Base1 클래스의 포인터로 변환하려면, 포인터의 주소를 Base0의 크기만큼 조정해야 합니다. 이런 조정은 런타임에 이루어지며, function_thunk가 이 역할을 합니다.
// then for a virtual function call needing pointer adjustment:
ptr->function();
// becomes:
ptr->__function_thunk(ptr);
__function_thunk:
ptr += offset;
function(ptr);
function_thunk는 "thunk" 함수의 한 예입니다. Thunk는 일반적으로 런타임에 주소나 포인터의 조정이 필요할 때 사용되는 작은 코드 조각입니다. 이 경우, function_thunk는 실제 가상 함수(function)를 호출하기 전에 필요한 포인터 조정을 수행합니다. 처리되는 객체의 유형이 컴파일 타임에 알려지지 않기 때문에 이러한 변환은 모두 런타임에 발생합니다.
+----------------+
| offset_to_top |
Derived: +----------------+
+-------------+ | Derived RTTI |
| _vptr_Base0 |---+ +----------------+
+-------------+ +--> | Base0 virtuals |
| ... | +----------------+
+-------------+ | ... |
| _vptr_Base1 |---+ +----------------+
+-------------+ | | offset_to_top |
| ... | | +----------------+
+-------------+ | | Derived RTTI |
| +----------------+
+--> | Base1 virtuals |
+----------------+
| ... |
+----------------+
다중 상속을 사용하는 Derived 클래스의 경우, 각 기반 클래스(Base0, Base1 등)의 VTable에는 해당 기반 클래스의 가상 함수들에 대한 정보가 들어가고, 추가로 Derived에서 오버라이드한 가상 함수에 대한 정보도 들어갈 수 있다는 점입니다.
예를 들어, Derived 클래스가 Base0의 func0()을 오버라이드하고, Base1의 func1()을 오버라이드했다면, Derived 클래스의 Base0과 Base1에 대한 VTable은 각각 Derived::func0()과 Derived::func1()에 대한 정보를 포함할 것입니다.
이렇게 각 VTable에는 Derived 클래스에서 오버라이드한 함수에 대한 정보가 들어가므로, 어떤 기반 클래스의 포인터로도 Derived 객체의 적절한 함수를 빠르게 호출할 수 있습니다.
가상 상속(virtual inheritance)의 주된 목적은 다중 상속을 사용할 때 "다이아몬드 문제"를 해결하는 것입니다. 다이아몬드 문제란, 두 개 이상의 클래스가 같은 부모 클래스를 상속받을 때, 그 부모 클래스의 멤버가 중복으로 존재하는 문제를 말합니다.
ClassA
/ \
ClassB ClassC
\ /
ClassD
예를 들어, ClassA를 상속받는 ClassB와 ClassC가 있고, 이 두 클래스를 다시 상속받는 ClassD가 있다면, ClassD는 ClassA의 두 개의 인스턴스를 가지게 됩니다. 이렇게 되면, ClassA의 멤버에 접근할 때 어떤 인스턴스의 멤버에 접근해야 하는지 모호해집니다.
이 경우, ClassD의 메모리 레이아웃은 다음과 같이 두 개의 ClassA 인스턴스를 포함하게 됩니다.
ClassD: [ ClassB [ ClassA ] ] [ ClassC [ ClassA ] ]
가상 상속을 사용하면, 이러한 문제를 해결할 수 있습니다. 가상 상속을 통해 ClassB와 ClassC가 ClassA를 상속받으면, ClassD는 ClassA의 단 하나의 인스턴스만을 가지게 됩니다. 즉, ClassA의 멤버는 ClassD 내에서 단일 인스턴스로만 존재하게 되어, 모호함이 해결됩니다.
가상 상속을 사용하면, ClassD의 메모리 레이아웃은 다음과 같이 단 하나의 ClassA 인스턴스만을 포함하게 됩니다.
ClassD: [ ClassB ] [ ClassC ] [ ClassA ]
여기서 [ ClassB ]와 [ ClassC ]는 ClassA를 가상으로 상속받기 때문에, ClassA의 단일 인스턴스가 ClassD에 직접 포함됩니다. 이렇게 하면 ClassA의 멤버에 대한 모호함이 해결됩니다.
가상 기본 클래스가 하나만 있는 간단한 경우를 고려해 보겠습니다.
class Base {
public:
~Base();
int p { 5 };
};
class Derived : virtual public Base {
public:
~Derived();
int q { 7 };
};
Derived d;
Derived 의 실제 메모리 레이아웃은 아래와 같습니다.
(lldb) p sizeof(d)
(unsigned long) $0 = 16
(lldb) x/4w &d
0x7fff5fbffb68: 0x00001028 0x00000001 0x00000007 0x00000005
| | | | | |
..................... .......... ..........
virtual pointer q p
위의 간략화된 메모리 레이아웃의 특성과 같이 부모 클래스의 비정적 데이터 멤버가 파생 클래스의 멤버 메모리뒤에 배치되는 것을 확인할 수 있습니다.
객체의 메모리 레이아웃은 컴파일 시점에 결정되며, 이는 컴파일러와 플랫폼, 그리고 클래스의 상속 구조에 따라 달라집니다. 가상 상속을 사용하는 경우, 일반적으로 가상 베이스 클래스의 멤버 변수들은 파생 클래스의 메모리 레이아웃에서 가장 아래에 위치하게 됩니다. 이는 가상 상속의 특성 때문에 가상 베이스 클래스의 멤버 변수들이 단 한 번만 인스턴스화되어야 하기 때문입니다.
생성자 호출 순서와 메모리 레이아웃은 별개의 문제입니다. 생성자는 객체가 생성될 때 초기화 작업을 수행하는 역할을 하며, 메모리 레이아웃은 컴파일러가 결정합니다. 따라서, 생성자가 호출되는 순서와는 무관하게, 각 클래스의 멤버 변수는 컴파일러가 결정한 메모리 레이아웃에 따라 적재됩니다
VTT(Virtual Table Table)는 C++의 가상 상속에서 중요한 역할을 하는 데이터 구조입니다. VTT는 각 클래스의 생성자와 소멸자가 호출될 때 해당 클래스의 vptr(virtual pointer)가 올바른 vtable을 가리키도록 도와줍니다. 이는 다중 상속과 가상 상속이 복합적으로 이루어진 복잡한 상속 구조에서 특히 중요합니다.
VTT(Virtual Table Table)의 역할을 이해하기 위해 간단한 예를 들어보겠습니다. 다음과 같은 클래스 구조를 가정해봅시다.
class Base {
public:
virtual void func() { /* ... */ }
};
class Derived1 : virtual public Base {
public:
virtual void func() override { /* ... */ }
};
class Derived2 : virtual public Base {
public:
virtual void func() override { /* ... */ }
};
class MostDerived : public Derived1, public Derived2 {
public:
virtual void func() override { /* ... */ }
};
이 경우, MostDerived 객체를 생성할 때 다음과 같은 과정이 일어납니다.
Base의 생성자가 호출됩니다. 이 때, Base의 vptr는 Base의 vtable을 가리킵니다.Derived1의 생성자가 호출됩니다. Derived1의 vptr는 Derived1의 vtable을 가리키도록 변경됩니다.Derived2의 생성자가 호출됩니다. Derived2의 vptr는 Derived2의 vtable을 가리키도록 변경됩니다.MostDerived의 생성자가 호출됩니다. MostDerived의 vptr는 MostDerived의 vtable을 가리키도록 변경됩니다.이 과정에서 VTT는 각 단계에서 어떤 vtable을 가리켜야 하는지 정보를 제공합니다. 즉, 생성자와 소멸자가 호출될 때마다 VTT를 참조하여 vpointer를 올바른 vtable로 설정합니다.
VTT(Virtual Table Table)가 없다면, 다중 상속과 가상 상속이 복합적으로 사용될 때 문제가 발생할 수 있습니다. 예를 들어, 위와 같이 MostDerived 객체를 생성하는 상황에서 Derived1이 먼저 생성되었다고 가정하면, Base 클래스의 vptr(virtual pointer)는 Derived1의 vtable을 가리킬 것입니다.
이후에 Derived2의 생성자가 호출되면, Derived2의 vptr도 설정되어야 하지만, Base의 vptr는 이미 Derived1의 vtable을 가리키고 있을 것입니다. 이 상태에서 MostDerived의 생성자가 호출되면, Base의 vptr가 어떤 vtable을 가리켜야 할지 모호해집니다.
이러한 문제는 VTT를 사용함으로써 해결됩니다. VTT는 객체의 생성과 소멸 과정에서 각 클래스의 vptr가 올바른 vtable을 가리키도록 도와줍니다.
MostDerived 객체 메모리 레이아웃:
+-------------------+
| MostDerived::vptr | ----> MostDerived::vtable
+-------------------+
| Derived1::vptr | ----> Derived1::vtable
+-------------------+
| Derived2::vptr | ----> Derived2::vtable
+-------------------+
| Base::vptr | ----> MostDerived::vtable (가상 상속 때문)
+-------------------+
| MostDerived 멤버 |
+-------------------+
| Derived1 멤버 |
+-------------------+
| Derived2 멤버 |
+-------------------+
| Base 멤버 |
+-------------------+
MostDerived 객체가 생성되는 과정에서 Base의 생성자가 호출되면, 이 시점에서 Base의 vptr는 MostDerived의 vtable을 가리키게 설정됩니다. 이렇게 하면, MostDerived 객체를 통해 Base의 가상 함수를 호출할 때 올바른 함수가 실행됩니다.
따라서, MostDerived 객체 내에서 Base의 vptr는 MostDerived의 vtable을 가리키게 됩니다. 이는 VTT(Virtual Table Table)를 통해 관리될 수 있으며, 이로 인해 다중 상속과 가상 상속이 복합적으로 사용될 때도 가상 함수 호출이 올바르게 작동합니다. 이렇게 하면, Base의 vptr는 최종적으로 MostDerived의 vtable을 가리키게 됩니다.
따라서, VTT가 없으면 다중 상속과 가상 상속이 복합적으로 사용될 때 vptr 설정이 제대로 이루어지지 않아, 가상 함수 호출 등에서 문제가 발생할 수 있습니다.
이렇게 VTT는 복잡한 상속 구조에서도 객체가 올바르게 생성되고 소멸될 수 있도록 도와줍니다. 이는 컴파일러가 자동으로 관리하는 부분이므로 일반적으로 개발자가 직접 다루지는 않습니다.