[C/C++] 가상 상속

그림자왕국·2020년 11월 1일
1

C++

목록 보기
3/24
post-thumbnail

가상 상속(Virtual Inheritance)

가상 상속은 다음과 같은 경우를 방지하기 위해 다중 상속 시 중간 클래스에서 사용된다.

  1. 다중 상속 받은 손자에서 같은 조부모 생성자가 중복으로 호출되는 걸 방지
  2. 다중 상속 구조에서 손자 클래스에서 조부모 함수를 호출할 때 모호함 방지
class A
{
public :
    A() { cout <<"A Constructor"<<endl; }
    void Func() { cout<<"A"<<endl; }
};
 
class Bone : public A
{
public :
    void BoneFunc() 
    {
        Func();
        cout<<"Bone"<<endl; 
    }
};
 
class Tons : public A
{
public :
    void TonsFunc() 
    {
        Func();
        cout<<"Tons"<<endl;
    }
};
 
class C : public Bone, public Tons
{
    void Func2()
    {
        BoneFunc();
        TonsFunc(); // 여기까지는 정상
        
        Func(); // Bone, Tons 중 누구의 A::Func()인지 몰라서 모호함 에러 발생
        // 아래와 같이 대체한다.
        Bone::Func(); // 어떤 부모 클래스의 부모 함수인지 명시적으로 지정해준다.
        Tons::Func(); // 어떤 부모 클래스의 부모 함수인지 명시적으로 지정해준다.
    }
};

 C ex; // 심지어 A 생성자가 두 번이나 호출된다. (Bone, Tons에서 각각 호출했기에)
 ex.Func2();

B1와 B2는 같은 A를 상속했기에 불필요하게 A 생성자가 두 번이나 호출되는 구조다.
어차피 B1와 B2에 동일한 A 생성자가 호출되기에 A를 딱 한 번만 상속하게끔 하는 것이 좋다.

가상 상속을 하는 방법은 상속할 때 virtual 선언을 붙여 가상 상속이 되도록 한다.

class Bone : virtual public A {};
class Tons : virtual public A {};

virtual 선언이 들어가면 아래와 같은 상속 구조가 된다.

해당 상속 구조를 다이아몬드 구조라고 하는데 B1과 B2는 공통의 A 클래스를 상속받아서
C 에서 A의 멤버 함수를 호출할 때 누구(B1 or B2)의 A 인지 스코프 연산자를 안 써도 되고
C 객체가 생성될 때 A 생성자는 한 번만 호출된다는 장점이 있다.

즉, 가상 상속을 통해 파생된 클래스를 상속받아 객체로 생성한다면 조부모 클래스 A의 멤버가 두 번이나 생성되어 메모리를 두 배로 낭비하는 걸 막을 수 있고 조부모 생성자도 한 번만 호출되도록 할 수 있다.


가상 상속 시 데이터 구조

저번 가상 테이블 칼럼에서 말했던 것처럼 일반적인 상속 관계의 객체 구조와 가상 상속 시 객체 구조는 약간의 차이점이 존재한다.

Animal * anim = new Lion();

위와 같이 객체를 파생 클래스로 동적 할당할 때 객체 안의 클래스 순서에서 차이가 난다.

일반적인 상속은 베이스 클래스 아래 파생 클래스 순으로 객체 구조가 설정되었다면
가상 상속은 파생 클래스가 위에 있고 그 아래 베이스 클래스가 존재하는 구조로 되어있다.

또한 중요한 점으로 virtual로 상속을 받았기에 상속을 받은 Lion 클래스에도 VTable Pointer가 생성됬다.

이러한 이유는 간접 참조(?)를 하기 위함이다. 설명에 앞서 클래스의 위치가 바뀐 이유를 알아보겠다.

기본 상속의 경우 객체의 시작점(빨간점)은 부모 클래스에서 시작되는데 베이스 클래스의 위치가 가장 위쪽의 0번지로 되어있기에 부모 클래스로 할당하든 자식 클래스로 할당하든 데이터를 찾을 때 내려가기만 하면 되니 부모나 자식 클래스의 시작점은 모두 동일한 위치(0)에서 시작한다. 그래서 자식 클래스의 멤버에 쉽게 접근할 수 있다.

하지만 가상 상속의 경우 시작점이 중간에서 시작되는데 자식 클래스에 접근하려면 위로 올라가야 하고 이런 구조로는 어떤 자식 클래스가 위로 할당되었는지 몰라서 찾으려는 자식 클래스의 멤버 데이터가 정확히 어디에 위치하는지 알 수 없다.

그래서 자식 클래스의 VTable에 멤버 변수의 위치를 계산 가능한 Offset 정보를 넣어준다.
Offset 정보를 통해 베이스 클래스의 위치로부터 사용할 파생 클래스의 시작점을 찾아낼 수 있다.
(하지만 Lion::Speak()와 같은 일반 멤버 함수는 Offset 정보를 사용할 줄 모르니 Offset을 사용하라고 전용 함수를 생성한다.)

이 함수를 Thunk 함수라고 하는데 멤버 함수별로 하나씩 존재한다. (ex : Thunk Lion::Speak())
이 함수는 Offset 정보를 사용하여 해당 함수를 [호출하는 곳]을 가리키는 역할을 한다.
즉 Thunk 함수는 Offset 정보를 이용해 원래 해당 함수를 호출하는 곳(Lion의 VTable Pointer가 위치한 지점)으로 시작점을 옮긴 후 Pointer가 해당 함수를 실행시켜주도록 하여준다.

일반 상속은 이런 기능이 없어서 무조건 자식 클래스에는 동등하게 부모 클래스가 존재해야 한다. ※ 핵심 (다이아몬드형 일반 다중 상속에서 각 부모 클래스별로 조부모 객체가 중복으로 생성되는 이유!)

다시 본론으로 들어가서 Lion 클래스에도 VTable Pointer가 생긴 이유는 Animal 클래스의 VT Pointer는 Thunk 함수를 가리키고 있는데 Thunk 함수는 실행될 때마다 Offset 연산을 수행하는 함수다.

처음에 Thunk 함수를 한 번 호출하여 시작점을 갱신하고 데이터를 계산 한 뒤에 베이스 클래스의 VT Pointer가 가리키는 Thunk 함수를 또 호출 할 경우 기껏 갱신 된 시작점(빨간점)에 Offset을 또 빼주기 때문에 시작점은 이상한 곳을 가리키게 된다.

그래서 사용되는 Lion 클래스에 VT Pointer를 하나 더 만들어 이제 시작점이 정확히 정렬되었으니 Thunk 함수가 아닌 일반 멤버 함수(::Speak)를 호출하도록 포인터가 VTable의 일반 함수를 가리키도록 구성된 것이다.

이러한 복잡한 구성을 한 이유는 상속받는 클래스(크기)가 다양하게 들어올 수 있기에 이러한 임의의 식(arbitrary expressions)이 주어졌을 때 다이나믹하게 대응해야 하기에 다이나믹 정보를 VTable에서 Offset을 통해 대응하도록 설계하였기 때문이다.

결론 : 가상 상속은 VTable에 사용할 클래스의 크기에 맞는 Offset를 통해 실제 주소를 계산하여 멤버의 위치를 도출해내는 상속 구조를 가졌기에 다양한 클래스 타입에 맞게 사용이 가능하다.


가상 상속과 일반 상속 비교

특히 다이아몬드 구조로 클래스를 설계 할 때 가상 상속 구조가 필요한데 일반적인 상속 구조로는
Lion과 Tiger에 베이스 Animal 클래스의 멤버와 VT Pointer가 각각 들어있어서 중복으로 데이터 영역 할당과 생성자 호출이 일어나서 비효율적인 설계가 됬다.

하지만 가상 상속으로 설계된 Lion, Tiger 클래스는 다형성과 같이 베이스 Animal 클래스를 기준으로하는 상속 구조를 지녔기에 Animal 클래스가 가상 테이블로 오프셋 정보를 수집하여 사용할 파생 클래스의 멤버 주소를 계산하는 방식이기에 컴파일러는 부모 객체를 1:1로 병행 생성하여 Liger 멤버 주소를 찾아가는 일반 상속의 메모리 낭비 문제를 방지할 수 있고, 부모 객체를 1:1로 생성함에 따라 생기는 부모 생성자도 여러 번 호출되는 사태가 일어나지 않는다.

위 그림의 가상 상속 부분을 설명하자면 Lion 클래스와 Tiger 클래스의 VT Pointer는 각각 Liger의 오버라이딩 된 함수를 가리키고 있으며

시작점이 위치한 Animal 클래스의 VT Pointer는 동적 할당 된 객체 Liger에 위치한 시작점을 식별할 수 있는 Offset 계산이 완료된 Liger의 Thunk 함수를 기리키고 있어서 이 함수를 호출하여 Liger에 오버라이딩 된 멤버 함수(Liger::)의 VTable 정보를 가지고 있는 자식 쪽
VTable Pointer가 해당 함수를 호출하도록 명령할 수 있게 된다.

이렇게 가상 상속의 특징에 대해 세심하게 알아봤으며 많이 어렵긴 했지만 혹시나 다중 상속을 할 때 효율적인 프로그램을 위한다면 요긴하게 사용할 수 있을 것이다.

profile
언리얼 엔진 매니아입니다.

0개의 댓글