다중상속

J·2025년 12월 14일

테스트

목록 보기
10/11

※ 책 "Fundamental C++ 프로그래밍의 원리"를 바탕으로 생각한 내용으로 이루어져, 틀릴 수 있음

다중 상속

  • 클래스가 여러개의 부모 클래스를 상속받는 구조.

다중 상속의 메모리 구조

// --------- 예시 코드
class BaseA
{	
public:
	int _aX = 0x11111111;
	int _aY = 0x22222222;
	int _aZ = 0x33333333;

	void FuncA()
	{
		_aX = 0x12121212;
	}
};

class BaseB
{
public:
	int _bX = 0x99999999;
	int _bY = 0xaaaaaaaa;

	void FuncB()
	{
		_bX = 0x23232323;
	}
};

class DerivedM : public BaseA, public BaseB
{
public:
	short _dX = 0xffff;
	char _dY = 0xee;
	int _dZ = 0x14141414;

	void FuncDerived()
	{
		_dX = 0x5555;
	}
};


int main()
{
	DerivedM m;
	
	BaseA* pBA = &m;
	BaseB* pBB = &m;
	DerivedM* pM = &m;

	wprintf(L"BaseA* : [%p]\n", pBA);
	wprintf(L"BaseB* : [%p]\n", pBB);
	wprintf(L"DerivedM* : [%p]\n", pM);

	m.FuncA();
	m.FuncB();
	m.FuncDerived();
}

메모리 구조는 다음과 같이 나타나며, 특이한 점은 DerivedM 객체가 BaseA / BaseB로 사용될때의 주소가 고정이 아닌 오프셋을 통해 계산되는 형식으로 나타남.

  • 메모리 뷰


  • 오프셋을 통해 계산된 위치

  • 함수 호출 시 this 셋팅값



  • 정리

다이아몬드 상속

  • 공통 조상의
    상속의 구조가 다음과 같이 나타나는 경우를 다이아몬드 상속이라고 한다.

다중 상속의 문제(이 문제는 다이아몬드 상속이 아닌 다중 상속 구조에서도 일어날 수 있음, 다만 다이아몬드 상속에서는 100% 멤버 중복으로 인한 모호성 문제가 발생)

중복된 객체의 생성으로 인해 일어나는 문제
1. 객체 / 변수의 중복으로 인한 모호성
2. 객체의 중복으로 인한 메모리 낭비
struct MemberStruct
{
	int a = 0x11223344;
	int b = 0x55667788;
	int c = 0x99aabbcc;
};
class Base
{	
public:
	int _baseX = 0x11111111;
	int _baseY = 0x22222222;
	int _baseZ = 0x33333333;

	MemberStruct _common;
};

class MiddleA : public Base
{
public:
	int _aX = 0x99999999;
	int _aY = 0xaaaaaaaa;
};

class MiddleB : public Base
{
public:
	int _bX = 0xcccccccc;
	int _bY = 0xdddddddd;
};

class DerivedM : public MiddleA, public MiddleB
{
public:
	short _dX = 0xffff;
	char _dY = 0xee;
	int _dZ = 0x14141414;

};

int main()
{
	DerivedM m;

	// m._common = { 4,5,6 }; // 이 코드는 MiddleA와 _coommon / MiddleB의 _common이 모두 존재하기 때문에 모호하다!
	m.MiddleA::_common = {1,2,3}; // 모호성을 해결하기 위해선 어떤 클래스의 객체인지 명시해줘야한다.
}

그림이 좀 헷갈릴 수도 있는데..여기서 말하고자 하는 건 중복된 객체의 생성(MiddleA와 MiddleB가 모두 Base를 상속받음으로써 나타나는 현상)에 대해
1. 모호성이 존재 (위 코드처럼 명확하게 지정함으로써 해결 가능하지만)
2. 1번을 해소해서 중복된 데이터 중 하나의 데이터만 사용한다고 하더라도 사용되지 않는 데이터에 대한 메모리가 낭비됨

이러한 1,2번에 대한 문제 해결을 위한 특별한 구조가 제시됨 (가상 상속)

가상 상속

  • 중복을 제거하고 싶은 클래스에 대해 virtual 키워드를 붙여 상속한다.
  • virtual로 상속을 하게 될 경우 상속되는 Base클래스를 가상 기반 클래스 (virtual base class)라고 한다.
  • 가상 기반 클래스는 여러 번 상속되더라도 메모리 구조상 하나만 존재하게 된다.
class Base // 가상 기반 클래스
{
public:
	Base() { wprintf(L"Base()\n"); }
	int m_base = 0x11111111;
};

// 이 코드에서 하나라도 virtual 안붙으면 중복 제거 효과 사라짐!!
class MiddleA : virtual public Base
{
public:
	int m_middleA = 0x22222222;
};

class MiddleB : virtual public Base
{
public:
	int m_middleB = 0x33333333;
};

class Derived : public MiddleA, public MiddleB
{
public:
	Derived()
	{
		m_base = 0x11111111; // 가상 상속을 통해 중복을 제거 -> 모호성 없음
		
		m_middleA = 0x22222222;
		m_middleB = 0x33333333;
	}
	int m_derived = 0x44444444;
};

int main()
{
	// MiddleA a;
    
	Derived m;
}

실행


Base 객체의 생성자가 1번만 호출되는 것을 확인.

그렇다면 어떻게 실행되고, 메모리는 어떤 구조로 이루어지나?

구조

일단 위에서 정의된 클래스 Derived를 보면 public MiddleA, public Middle B의 형태로 이루어졌기 때문에 메모리의 구조를 예상하자면


이런 형태로 이루어질 것 같지만 그렇지 않다.

기본적인 형태는 위와 비슷하나, 가상 기반 클래스 멤버가 하나만 존재하게 되는 구조로 변한다.

✨✨ 중요

(Visual C++ 컴파일러 기준,컴파일러마다 다를 수 있음)
가상 기반 클래스 멤버의 경우 메모리 구조의 아래쪽에 존재한다.

어떤 클래스 C가 존재하고, C의 조상 클래스 (부모, 부모의 부모, 부모의 부모의 부모.... 등) 에 가상 기반 클래스가 존재할 경우 컴파일러는 클래스 C의 메모리 시작 부분에 가상 기반 클래스 관리를 위한 포인터 메모리 영역을 확보한다. (vbptr)

위의 MiddleA a; 코드를 풀고 생성된 메모리 구조를 확인하면
어셈블리상 먼저 호출되는 MiddleA()에서 다음과 같이 실행 후

이런 형태로 만들어지고, Derived 객체 m 또한 Derived()의 내부에서는
로 실행,

MiddleA의 호출 시 push 0을 하는데 이것은 MiddleA / MiddleB의 클래스의 생성자가 실행될때 0이 아니면 vbPtr의 셋팅과 Base 멤버의 초기화를 담당하는 것을 결정하는 작업으로 추정된다.

  • 정리한 Derived 객체 m

vbPtr (virtual base table pointer)

  • 가상 기반 클래스의 오프셋 정보가 포함되어 있는 테이블
  • 해당 vbptr의 첫 항목은 자기 자신의 클래스 오프셋 정보가 들어감
    • 그렇기 때문에 최소 2항목 (자기 자신 + 가상 기반 클래스 오프셋)은 가짐
  • 자기 자신의 offset은 가상 함수가 존재하지 읂는 일반적인 경우는 0이지만, 가상 함수가 존재한다면 변경될 수 있음.

가상 함수가 존재할 때

class CD1
{
public :
	int m_d1A = 0x11111111;
	int m_d1B = 0x22222222;
};

class CD2 : virtual public CD1
{
public:

	virtual ~CD2() {}
	int m_d2 = 0x33333333;
};

다음과 같은 형태로 이루어진다.

  • 메모리

  • vbptr (가상 기반 테이블 포인터) 내용


  • 실행


  • 구조

다양한 경우..

가상함수를 사용하지 않지만 위와 같이 vbptr이 클래스 메모리 시작 주소에 위치하지 않는 경우.
class CD1A
{
public :
	int m_d1A = 0x11111111;
};
class CD1B
{
public:
	int m_d1B = 0x22222222;
};

class CD2 : public CD1A, public virtual CD1B
{
public:

	int m_d2 = 0x33333333;
};

int main()
{
	CD2 cd2;
}

CD2 객체 생성 시
구조는 다음과 같이 이루어짐을 확인할 수 있었다.

  • vbptr 내용

0x0093f814 (vbptr 저장 주소) -4 (0xfffffffc)하면 CD2 객체의 this(0x0093f810)이 나오고, 0x0093f814 + 0x00000008을 해서 나온 0x0093f81c의 주소에 CD1B (가상 기반 클래스 멤버)의 시작 주소가 위치함을 확인할 수 있다.

virtual 상속받은 클래스를 virtual 상속받는 구조
class CD1A
{
public :
	int m_d1A = 0x1a1a1a1a;
};
class CD2A : public virtual CD1A
{
public:
	int m_d2A = 0x2a2a2a2a;
};

class CD1B
{
public:
	int m_d1B = 0x1b1b1b1b;
};
class CD2B : public virtual CD1B
{
public:
	int m_d2B= 0x2b2b2b2b;
};


class CD3 : public virtual CD2A, public virtual CD2B
{
public:

	int m_d3 = 0x3c3c3c3c;
};

int main()
{
	CD3 cd3;
}
  • ✨가상 기반 테이블에 등록되는 오프셋의 순서는 최상위 가상 기반 클래스부터 그것을 상속하는 가상 기반 클래스로 이어지며, 자식 가상 기반 클래스가 끝나면 최상위 가상 기반 클래스의 이웃 가상 기반 클래스로 이어지게 된다. (중요)
    • CD2A : virtual CD1A , CD2B : virtual CD2B 의 구조고 CD3 : virtual CD2A, virtual CD2B 인 상태에서 테이블의 등록은 [CD1A - CD1B - CD2A - CD2B]의 형태로 이루어진다는 얘기임
  • 그렇기 때문에 위 코드는 다음과 같은 구조로 이루어짐.
    CD3을 제외한 4개 클래스는 모두 가상 기반 클래스.

CD2A vb테이블 과 CD2B vb테이블에서이 가리키는 처음 0 :
해당 CD2A / CD2B의 클래스 시작 주소까지의 오프셋이 0으로 동일,
각자 가리키는 CD1A / CD1B 클래스 시작 주소까지의 오프셋이 -4으로 동일하기 때문에 같은 vb테이블을 사용하고 있는 것으로 보이며,

CD3의 vb테이블에는 [CD3 클래스 시작 주소까지의 오프셋 - CD1A - CD1B - CD2A - CD2B] 의 형태로 이루어져있음.

0x00DAFB84 + 0 => CD3 클래스 시작 주소
0x00DAFB84 + 8 => CD1A 클래스 (가상 기반 클래스) 시작 주소
0x00DAFB84 + 12 => CD2A 클래스 (가상 기반 클래스) 시작 주소
0x00DAFB84 + 20 => CD1B 클래스 (가상 기반 클래스) 시작 주소
0x00DAFB84 + 24 => CD2B 클래스 (가상 기반 클래스) 시작 주소

virtual을 상속받는 클래스의 public 상속
class CD1A
{
public :
	int m_d1A = 0x1a1a1a1a;
};
class CD2A : public virtual CD1A
{
public:
	int m_d2A = 0x2a2a2a2a;
};

class CD1B
{
public:
	void Go() {}

	int m_d1B = 0x1b1b1b1b;
};
class CD2B : public virtual CD1B
{
public:
	int m_d2B= 0x2b2b2b2b;
};


class CD3 : public  CD2A, public  CD2B
{
public:

	int m_d3 = 0x3c3c3c3c;
};

int main()
{
	CD3 cd3;


	{
		CD1B* pCd1b = &cd3;

		pCd1b->Go();
	}


	// -------

	CD2B* pCd2b = &cd3;
	{
		CD1B* pCd1b = pCd2b;

		pCd1b->Go();
	}
}

이 형태에서 cd3은 생각한 것과 같은 이런 구조를 나타낸다.

특이한 점은 CD2A vbptr에는 vbptr of CD3을 위해 추가된 오프셋이 존재하는데 책에서는 이렇게 설명을 한다.

... "vbptr of CD2A는 CD2A 클래스의 가상 기저 클래스 오프셋 테이블을 가리키기도 하지만, CD3의 가상 기저 클래스 오프셋 테이블도 가리키고 있는 것이다. 즉, vbptr 자체가 공용으로 사용되고 있으며, 오프셋 테이블 역시 공용으로 사용된다. 더 정확하게 얘기하자면 CD3의 가상 기저 클래스 오프셋 테이블이 CD2A의 가상 기저 클래스 오프셋 테이블을 포함하고 있는 것이다. 그렇다면 어떻게 이런 구조가 가능한 것일까? 이미 설명했듯이 어떤 클래스의 조상 클래스 중에 가상 기저 클래스가 있을 경우, vbptr이 생성된다고 했다. 즉, 클래스 CD3은 직접적으로 부모 클래스 CD2A와 CD2B를 가상 상속하지는 않지만 CD2A와 CD2B가 이미 CD1A와 CD1B를 가상 상속하고 있기 때문에 CD3 입장에서 조상 클래스면서 가상 기저 클래스인 CD1A, CD1B가 존재하므로 vbptr이 생성되는 것이다"...

라고 설명하며

  • 왜 직접 가상 상속하지 않은 클래스에도 vbptr을 만들어주는 것일까?
    • 타입 변환을 쉽게 하기 위해서이다.

CD3 클래스에서 부모 클래스인 CD1B 클래스로 타입 변환을 할 때 오프셋을 통해 CD1B 클래스를 찾아가는 과정이 비효율적이어서 효율성을 위해 컴파일러가 직접적으로 OFFSET을 추가한다고 한다.

내 뇌내망상

근데 내 생각으로는 (그냥 추측임.. 틀렸을 수도 있지만) 책에서 설명한 CD3클래스에 대한 vbptr이 생성된다기보단 virtual을 상속한 첫번째 조상이 사용하는 vb테이블에 최종 derived 클래스가 사용할 정보를 추가하는 그런 느낌이다.

왜 그러냐면 책의 내용대로라면

class CD1A
{
public :
	int m_d1A = 0x1a1a1a1a;
};
class CD2A : public virtual CD1A
{
public:
	int m_d2A = 0x2a2a2a2a;
};

class FSF {
public:
	int msf = 0x1;
};
class CD1B
{
public:
	void Go() {}

	int m_d1B = 0x1b1b1b1b;
};
class CD2B : public virtual CD1B
{
public:
	int m_d2B= 0x2b2b2b2b;
};


class CD3 :	public FSF, public  CD2A, public  CD2B
{
public:

	int m_d3 = 0x3c3c3c3c;
};

다음과 같은 상황에서는 이런 구조로

만약 CD2A vbptr이 CD3의 가상 기저 클래스 오프셋 테이블과 같이 사용되는 구조라면 저 CD2A vbptr이 CD3과 CD2A vbptr로 분리되어야할 것 같은데 그렇지 않고 vb테이블 안의 내용도 변화가 없다. (실제로 CD3이 사용해야한다면 저 0x006023dc의 내용은 0xfffffffc가 되었어야함...)
나중에 깊은 공부를 해봐야 알겠지만.. 지금은 그냥 앞에서 말한대로 생각해야겠다..

virtual 다이아몬드 상속
class CD1
{
public :
	int m_d1 = 0x1a1a1a1a;
};
class CD2A : public virtual CD1
{
public:
	int m_d2A = 0x2a2a2a2a;
};


class CD2B : public virtual CD1
{
public:
	int m_d2B= 0x2b2b2b2b;
};


class CD3 :	virtual public  CD2A, virtual public  CD2B
{
public:

	int m_d3 = 0x3c3c3c3c;
};

int main()
{
	CD3 cd3;
}

쉽게 예상을 할 수 있는 구조다.
CD3 클래스의 가상 기반 클래스는 CD1, CD2A, CD2B 이다.

여기서 CD1은 CD2A와 CD2B에서 사용하는 중복된 가상 기반 클래스이고, CD3의 vb테이블에 보이듯 이 오프셋은 1번만 들어간다. ( cd3 vb테이블을 보면 [CD3 - CD1 - CD2A - CD2B] 로 오프셋이 들어가 있는 것을 확인 가능 )

번외....

가상 기반 클래스의 생성자 호출은 ... ? 누가누가 ?

class CD1
{
public :
	CD1(const char* msg) { printf("CD1 : %s\n",msg); }
	int m_d1 = 0x1a1a1a1a;
};
class CD2A : public virtual CD1
{
public:
	CD2A() : CD1("CD2A") { printf("CD2A\n"); }
	int m_d2A = 0x2a2a2a2a;
};


class CD2B : public virtual CD1
{
public:
	CD2B() : CD1("CD2B") { printf("CD2B\n"); }

	int m_d2B= 0x2b2b2b2b;
};


class CD3 :	 public  CD2A, public  CD2B
{
public: 
	CD3() : CD1("CD3") { printf("CD3\n"); }

	int m_d3 = 0x3c3c3c3c;
};

class CD4 : public CD3
{
public:
	CD4() : CD1("CD4") { printf("CD4\n"); }

	int m_d4 = 0xd4d4d4d4;
};


int main()
{
	CD4 cd4;
}

여기서 cd3을 생성하면 CD1의 생성자는 어떤게 호출이 될까?

가상 상속에 대해 테스트하면서 어셈블리 호출을 봤던 걸 떠올리면 금방 떠오른다.

가상 기반 클래스의 생성자 호출은 최종 파생 클래스에서 담당한다. (물론 생성자에 맞는 호출을 해줘야함)

마치며..

다중 상속과 다중 상속의 문제점, 그리고 다중 상속의 문제점을 해결하기 위한 가상 상속에 대해서 알아보았다. 가상 상속을 사용하더라도 vb테이블에 접근해서 offset을 얻어내고.. offset을 더해서 접근하는 방식.. (비용의 추가) 를 잘 생각해서 설계해야하고.. 가상 상속의 기본적인 동작에 대해서 공부했는데 나중에 다중 상속이나 가상 상속을 활용하게 된다면 (사람 일은 모르는 거니까... 언젠가..) 이 글을 보면서 다시 개념들 복습하고 확장했으면 좋겠다.. 이상!

profile
낙서장

0개의 댓글