관리 메뉴

기억을 위한 기록들

[Fundamental C++] 6. 클래스 (5) - 다중 상속과 가상 상속 본문

C & CPP/Fundamental C++

[Fundamental C++] 6. 클래스 (5) - 다중 상속과 가상 상속

에드윈H 2021. 5. 5. 13:10

 

다중 상속이 어떤 것이고, 어떻게 하는지 등 자세한 설명은 생략한다.

 

다중 상속의 메모리 구조

한 클래스(Child) 가 두 개의 클래스(A, B)를 상속받게 될 때, 첫 번째로 상속받는 클래스(A)의 시작 주소는 자식 클래스(Child)의 시작 주소와 동일하다. 

 

class ParentA
{
public:
	ParentA()
	{
		mAInt = 5;
	}


	int mAInt;
};

class ParentB
{
public:
	ParentB()
	{
		mBInt = 10;
	}
	int mBInt;
};

class Child : public ParentA, public ParentB
{
public:
	Child()
	{
		mInt = 100;
	}
	int mInt;
};

 

Child 클래스의 객체 child의 주소를 A, B, Child 클래스 포인터 변수에 대입하여 주소를 확인해보게 되면, 모두 같다고 생각할 수 있겠지만, 다르다.

int main()
{
	Child child;
	ParentA* pA = &child;
	ParentB* pB = &child;
	Child* pChild = &child;


	cout << "부모 A: " << pA << endl;
	cout << "부모 B: " << pB << endl;
	cout << "자식 : " << pChild << endl;
	return 0;
};

첫 번째로 상속받는 A클래스와 자식 클래스의 주소가 동일하다 그리고 B클래스의 주소는 클래스 크기 4 만큼 오프셋이 떨어져 있다 (C-8 =4). 

 

그 이유는 각 클래스에 최초로 선언된 멤버 함수를 호출할 때(상속받는 함수 제외)는 멤버 함수가 사용할 수 있는 실제 클래스의 시작 주소를 넘겨주어야 하기 때문이다.

 

멤버 함수는 자신이 처음으로 선언된 클래스의 실제 시작 주소를 기준으로 해당 클래스의 멤버들을 접근하도록 구성되어있기 때문이다. 그래서 함수를 호출할 때는 반드시 클래스의 실제 시작 주소를 넘겨주어야 하며, 그러기 위해서 컴파일러는 포인터 타입 변환이 일어날 때 적절히 오프셋을 더하게 된다.

class Child : public ParentB, public ParentA //B먼저 상속받기
{
public:
	Child()
	{
		mInt = 100;
	}
	int mInt;
};

위와 같이 B를 먼저 상속받게 변경 후 다시 실행해보면, B와 자식 클래스 포인터가 가리키는 메모리 주소가 같아지게 된다

 

클래스의 멤버 함수는 클래스의 실제 시작주소인 this를 기준으로 멤버 변수에 접근하도록 컴파일 된다.

 

따라서 해당 함수를 호출하기 전에는 함수가 속한 클래스의 정확한 시작 주소를 this에 넘겨야 하며, 다중 상속의 두 번째 부모처럼 클래스의 시작 주소가 자식 클래스와 일치하지 않는 경우 컴파일러는 부모 클래스의 오프셋 정보를 더하여 실제 부모 클래스의 시작 주소를 this에 넘기게 된다.

 

다중 상속의 문제점(다이아몬드 구조)

다중 상속의 문제점으로는 한 클래스 A를 상속받는 두 개의 클래스가 있다고 하자, 만약에 그 두 개의 클래스를 다중 상속받는 또하나의  자식 클래스가 있다고 할때, 그 자식 클래스에서는 A의 멤버변수의 접근하기 위해서는 중간의 두개의 클래스중 어느 클래스의 어떤 것을 사용해야 할지 모호해진다. 그렇다고 방법이 없는 것은 아니고

A::멤버 변수

식으로 범위 연산자를 붙여 접근 가능하다. 하지만, 그 중간 클래스 2개도 같은 멤버 변수가 있기에 메모리 낭비가 되는 것이다. 그런 부분을 메모리 낭비를 줄이기 위함이 가상 상속이다.

 

가상 상속 

꼭 다이아몬드 구조가 아니더라도 멤버 중복의 모호함 문제가 발생할 수 있게 된다.

 

virtual로 상속하게 될 경우 상속되는 부모 클래스를 가상 기저 클래스 (Virtual Base Class)라고 한다. 가상 기저 클래스는 여러 번 상속되더라도 메모리 구조상 하나만 존재하게 된다.

class ParentA //가상 기저 클래스
{
   //...
};


class Child : virtual public ParentA //virtual 상속
{
   //...
};

 

그리고 가상 기저 클래스의 생성자 또한 여러 번 호출되지 않고 한 번만 호출된다.

 

class ParentA //가상 기저 클래스 후보
{
public:
	ParentA()
	{
		cout << "가상기저클래스 생성!" << endl;
	}

	int mPInt;
};


class ChildA : public ParentA //일반 상속
{
	//..

	int mCAInt;
};
class ChildB : public ParentA //일반 상속
{
	//..
	int mCBInt;
};

class FClass : public ChildA, public ChildB
{
   //..
	int mFInt;
};

virtual 상속을 지우면 ParentA 가상 기저 클래스가 2번 호출되고, 

class ChildA : virtual public ParentA //virtual 상속
{
	//..
    int mCAInt;
};
class ChildB : virtual public ParentA //virtual 상속
{
	//..
    int mCBInt;
};

가상 상속하게 되면  한 번만 호출하게 된다.

 

가상 상속의 메모리 구조

 

위의 예를 사용하게 되면,

ChildA : vbptr A
ChildA : mCAInt;
ChildB : vbptr B
ChildB : mCBInt;
FClass :  mFInt;
Parent A : mPInt

 

Parent A는 단 한 번만 나타나고, 맨 아래에 있지만 맨 위가 될 수도 있다. 정해진 것은 아니고 컴파일러 제작사마다 다르다.

 

그런데 가상 상속을 통해 메모리의 크기가 일반 상속받는 것에 비해 더 커질 수도 있게 된다. 정말 작은 크기의 클래스를 가상 기저 클래스로 만들 경우 메모리 절약 효과가 그리 크지 않을 수 있다. 따라서 가상 기저 클래스로 만들 클래스는 중복을 제거할 때 메모리 절약 효과가 있을 정도로 크키가 커야 의미가 있다.

 

메모리 절약 효과가 크다고 해서 무턱대고 가상 상속을 사용해서도 안된다. 균형 효과에 의해서 메모리 절약 효과가 있는 대신에 성능은 떨어질 수밖에 없다.

 

 

vbptr은 임의로 지어낸 이름이고 가상 상속 테이블 포인터이다. 가상 기저 클래스로 접근하기 위해 있는 것이고, 클래스의 멤버 함수를 호출하거나, 멤버에 접근할 경우 클래스 포인터의 타입 변환이 일어나는데, 가상 상속을 사용하게 되면, vbptr을 한번 더 거쳐야 해서, 성능 저하를 가져온다. 그래서 가상 상속은 신중해야 한다.

애초에 다이아몬드 구조를 사용하지 않는 게 더 효과적일 수도 있다.