본문으로 바로가기

Inheritance 의 이해 - ⑤ Virtual Inheritance

앞서 다중 상속 글에서 아래와 같이 다이아몬드 상속이 이루어질 경우 조상 클래스인 animal 클래스가 두번 생성된다고 설명했다.


이 문제는 가상 상속(Virtaual Inheritance)으로 해결할 수 있다. 이번 글에서는 가상 상속이 메모리 레벨에서 어떻게 동작하는지 살펴본다. 먼저 가상 상속을 사용하는 방법은 다음과 같다.

class Animal 
{
public:
    virtual void Speak() { std::cout << "Animal!" << std::endl; };
    virtual ~Animal() = default;
private:
    double animalData;
};
class Lion : public Animal // non virtual inheritance
{
public:
    virtual void Speak() override { std::cout << "Lion! : " << lionData << std::endl; }
private:
    double lionData;
};
class Tiger : virtual public Animal // virtual inheritance
{
public:
    virtual void Speak() override { std::cout << "Tiger! : " << tigerData << std::endl; }
private:
    double tigerData;
};

int main()
{
    std::cout << sizeof(Lion) << std::endl; // non virtual inheritance
    std::cout << sizeof(Tiger) << std::endl; // virtual inheritance
    return 0;
}
24
32

가상 상속을 받기 위해서는 위 Tiger 클래스 코드처럼 public 키워드 앞에 virtual 키워드를 붙여줘야 한다. 가상 상속과 비 가상 상속의 차이를 살펴보자. 가상 상속을 받지 않은 Lion의 크기는 24바이트인 반면에 가상 상속을 받은 Tiger의 크기는 32바이트가 된다. 가상 상속을 받을 경우 아래의 그림처럼 가상 함수 포인터가 하나 더 생기기 때문에 사이즈가 더 커지게 된 것이다.


지금부터는 가상 상속에서 가상 함수 포인터가 왜 하나 더 필요한지와 어떻게 동작하는지 살펴본다.

int main()
{
    Animal* polyAnimalLion = new Lion(); // non virtual inheritance
    Animal* polyAnimalTiger = new Tiger(); // virtual inheritance
    polyAnimalLion->Speak();
    polyAnimalTiger->Speak();
    delete polyAnimalLion;
    delete polyAnimalTiger;
    return 0;
}

위 코드를 메모리상으로 살펴보면 아래의 그림과 같이 동작한다. 먼저 비 가상 상속을 받은 Lion 클래스부터 살펴보자.


Animal 포인터는 Animal 클래스의 데이터만 볼 수 있지만, Lion의 Speak 함수에 접근하게 되면 시작 위치에서 부터 2칸 아래에 lionData가 있다는 것을 알게 되어 접근할 수 있게 된다. 하지만 가상 상속을 받은 Tiger 클래스는 조금 다르게 동작한다.


가상 상속을 받게 되면 부모클래스 데이터가 자식 클래스 데이터밑에 생성된다. 그리고 부모 데이터만 볼 수 있는 Animal 포인터로 Tiger의 Speak 함수에 접근하게되면 tigerData의 위치를 알 수 없어서 문제가 생긴다. 그래서 다음과 같이 offset값을 활용하여 Animal의 vfptr로 Tiger클래스의 시작 위치를 찾아가도록 한다. offset값은 Animal 데이터의 시작점으로 부터 -16 위치에서 Tiger 클래스가 시작됨을 알려준다. 이렇게 offset값을 활용해서 데이터를 찾을 수 있도록 해주는 함수를 thunk 함수라고 한다.


thunk 함수와 일반함수를 구분하는 이유는 아래와 같이 자식클래스 포인터로 멤버함수에 접근할때는 offset값을 참조할 필요가 없기 때문이다.

int main()
{
    Tiger* tiger = new Tiger();
    tiger->Speak();
    delete tiger;
    return 0;
}

만약 Tiger 포인터에서 Animal 포인터 처럼 offset값을 참조하여 멤버변수를 찾게된다면 엉뚱한 값을 찾게된다. 때문에 Tiger 포인터에서는 일반 Tiger::Speak 함수를 호출할 수 있도록 하기 위해서 Virtual table에는 thunk 함수와 일반 멤버 함수를 모두 갖고 있게된 것이다.

이렇게 복잡한 구조를 가지는 이유는 animal 객체 위에 어떤 크기의 객체가 올라올지 동적으로 결정할 수 있는 유연성을 확보하기 위함이다. animal 오브젝트 위에 다양한 크기의 객체가 올라오더라도 offset값만 변경하면 사용할 수 있게된다.

다이아몬드 상속 문제 해결

다이아몬드 상속이 발생할 경우 조상 클래스가 두번 생성되는 문제가 발생되고, 지금 까지 설명한 가상 상속을 활용하면 두번 생성되는 문제를 해결할 수 있다. 지금 부터는 다이아몬드 상속에서 가상 상속을 활용하면 어떻게 문제를 해결할 수 있는지 살펴본다. 우선 아래와 같이 가상 상속을 받을 수 있다.

class Animal 
{
public:
    virtual void Speak() { std::cout << "Animal!: " << animalData <<std::endl; };
    virtual ~Animal() = default;
private:
    double animalData;
};

class Lion : virtual public Animal // virtual inheritance
{
public:
    virtual void Speak() override { std::cout << "Lion!: " << lionData << std::endl; }
private:
    double lionData;
};

class Tiger : virtual public Animal // virtual inheritance
{
public:
    virtual void Speak() override { std::cout << "Tiger!: " << tigerData  << std::endl; }
private:
    double tigerData;
};

class Liger : public Lion, public Tiger
{
public:
    virtual void Speak() override { std::cout << "Liger!: " << ligerData  << std::endl; }
private:
    double ligerData;
};

위와 같이 가상 상속을 받을 경우 오브젝트는 메모리 아래와 같이 생성됩니다.

지금은 큰 차이가 없어 보이지만 animal Data가 커질 수록 중복된 데이터로 인해서 비 가상 상속의 경우 불필요한 자원 낭비가 더 크게 발생할 수 있다. 그리고 가상 상속인 경우 thunk 함수 덕분에 animal 포인터를 활용해서 어떤 객체의 Speak 함수라도 호출할 수 있게 되었다.

 


출처: https://www.youtube.com/channel/UCHcG02L6TSS-StkSbqVy6Fg