C++에서 A라는 클래스를 만들었다. 이 클래스는 앞으로 당신이 만들 거의 모든 클래스들이 상속 받을 아주 기본적이고 공통적인 기능을 갖추고 있다. COM으로 치면 IUnknown, MFC로 치면 CObject 같은 기능을 하는데, 여기서는 그 예로 자체적인 reference counting 기능을 내장하고 있다고 치자.
class A {
int nRefCnt;
public:
A(): nRefCnt(1) {}
~A() {}
int AddRef() { return ++nRefCnt; }
int Release() { int nt=--nRefCnt; if(nt==0) delete this; return nt; }
};
이제 당신은 A로부터 상속 받은 여러 클래스들을 만든다.
class B: public A {
public:
int nAddVal;
B(): nAddVal(2) {}
};
class C: public A {
public:
int nExitVal;
C(): nExitVal(3) {}
};
그런데 C++에는 다중 상속이라는 게 존재한다.
어쩌다 보니, A의 자식 클래스들 중 서로 다른 클래스를 골라서 이들의 기능을 다 물려받은 클래스를 만들고 싶어진다. (욕심도 참 많다!)
class D: public B, public C {
public:
int nLast;
D(): nLast(4) {}
};
여기서 문제가 생긴다.
이 경우, D는 B와 C의 기능을 물려받는 과정에서, B와 C가 공동으로 소유하는 A는 두 번 상속받게 된다.
32비트 기준으로 obj의 멤버 배열 순서는 대략 "1, 2, 1, 3, 4" 정도가 된다.
D obj;
obj.AddRef();
이 코드의 실행 결과는 어떻게 될까?
고민할 필요 없다. 이 코드는 컴파일 자체가 되지 않을 테니 말이다. =_=
D라는 클래스에는 A의 nRefCnt라는 멤버 자체가 둘 존재한다.
그렇기 때문에 B 쪽에 속하는 nRefCnt를 건드릴지, C 쪽에 속하는 nRefCnt를 건드릴지 판단할 수 없어서 컴파일러는 모호성 오류를 일으키는 것이다.
클래스 가계도는 보통 tree 구조가 되는데 이 경우 엄밀히 말하면 cycle이 존재하게 된다. 이 cycle을 일명 '죽음의 다이아몬드'라고 부른다.
obj.B::AddRef() 내지 obj.C::AddRef()라고 구문을 바꾸면 컴파일 에러 자체는 없앨 수 있다.
그러나 이것은 미봉책일 뿐이지 문제를 본질적으로 해결하는 방법은 아닐 것이다.
이건 클래스 B와 C가 아무 공통분모 없이, 우연히 AddRef라는 껍데기만 동일하고 의미는 완전히 다른 함수를 제각기 갖고 있는 것과 다를 게 없는 상황이다.
근본적으로는 D라는 클래스는 비록 B와 C의 기능을 동시에 상속 받았더라도 A는 단 한 번만 상속 받게 하는 방법이 있어야 한다. 그게 가능할까?
그래서 C++은 '가상 상속'이라는 걸 제공한다.
일반적으로 B라는 클래스가 A라는 클래스로부터 상속을 받았다면, B라는 클래스는 내부적으로 A의 몸체 뒤에 자기 몸체가 덧붙는다. 따라서 B 클래스의 오프셋과 A 클래스의 오프셋 사이의 간격은 컴파일 시간 때 딱 결정이 되어 버리며 언제나 고정 불변이다.
그런데 B가 A를 상속 받으면서 A를 '가상'으로 상속하면, B 클래스로부터 A 클래스의 오프셋은 자기가 별도의 내부 멤버로 갖고 있게 되며, 컴파일 시점이 아니라 실행 시점 때 동적으로 바뀔 수 있게 된다. 기반 클래스가 특수한 처리를 하는 게 아니라, 상속을 받고 싶어하는 자식 클래스가 상속을 특수한 방법으로 받아야 한다.
그래서 위의 네 클래스 A~D 중, 죽음의 다이아몬드를 해소하기 위해서는 B와 C가 A를 virtual로 상속 받게 하면 된다.
class B: virtual public A { ... };
class C: virtual public A { ... };
이 경우 B와 C는, 기반 클래스인 A가 자신과 메모리 상으로 굳이 연속적으로 이어져 있지 않더라도, B나 C 자신이 스스로 갖고 있는 부가 정보를 통해 기반 클래스인 A의 위치를 추적할 수 있다.
클래스 C만 갖고 생각하는 경우, 당연히 메모리 상으로는 A와 C가 바로 따를 것이고, C 내부에 있는 A의 포인터는 자기 바로 앞을 가리키고 있을 것이다.
하지만 클래스 D는 멤버가 ABCD와 같은 순으로 쫙 배열될 수 있으며, A와 C 사이에 B 같은 다른 클래스가 있을 수 있다. 그래도 B와 C가 A를 공유할 수 있게 된다. 공유를 하려고 이 지저분한 짓을 자처한 것이다.
자, 그럼 가상 기반 클래스의 구현 비용은 어느 정도 될까? C++에서
ptr->Function(a, b);
이라는 문장이 있고 Function이 virtual이 아닌 일반 클래스 멤버 함수라면, 위의 코드는 C 언어 문법으로 표현했을 때 대략
Function(ptr, a, b);
이 된다. 즉, this만 암묵적으로 추가되고 일반 함수와 완전히 똑같은 형태이다. 가장 간단하다.
하지만 Function이 가상 함수라면,
ptr->functbl->Function(ptr, a, b);
과 같은 꼴이 되고 오버헤드가 꽤 커진다. 우리 멤버가 가리키는 공용 가상 함수 테이블로 가서 거기서 함수 포인터를 참조하는 셈이다.
그렇다면 Function이 ptr이 가상으로 상속 받은 기반 클래스의 비가상 멤버 함수라면,
Function(ptr + ptr->baseptr, a, b);
정도. 가상 함수 정도는 아니지만 그래도 this 포인터의 위치 계산을 위해서 두세 개의 명령 오버헤드가 추가된다.
일단 다중 상속이라는 개념 자체가 컴파일러 문법상의 단순 형변환일 뿐이던 typecasting을 굉장히 복잡하게 만들었다는 점을 알 필요가 있다.
그럼 Function이 가상 상속에다가 가상 함수이기까지 하면 어떻게 될까?
그래도 functbl을 찾는데 오버헤드가 더해지지는 않는다. 어차피 각 클래스의 함수 테이블에는 자기가 지금까지 상속 받은 모든 클래스의 가상 함수들이 누적 기록되어 있기 때문에, 함수 호출을 위해 여러 테이블을 돌아다니지는 않는다.
참고로 A에 가상 함수가 있고 B와 C가 이를 제각기 오버라이드를 했는데 D가 B와 C를 동시에 상속 받고도 그 가상 함수를 또 오버라이드하지 않았다면, 컴파일 에러가 난다. B와 C 중 어느 장단에 맞춰 춤을 추리요? C++ 컴파일러는 그 정도 모호성은 자동으로 지적해 준다. C++ 컴파일러 만들기란 정말 힘들겠다는 생각이 들지 않는가?
가상 함수, 가상 상속, 멤버 함수 포인터... =_=;;
오늘날 프로그래밍 업계에서 다중 상속은 굉장히 지저분하고 흉악한 개념으로 간주되어 금기시되고 있다. C언어의 전처리기, 포인터와 더불어 현대의 프로그래밍 언어에서는 찾을 수 없는 면모가 되고 있다.
여러 클래스의 기능이 한꺼번에 필요하면, 무리하게 상속으로 해결하지 말고 해당 클래스 개체를 '멤버'로 가지는 쪽으로 가라는 것이다.
다중 상속이라든가 가상 상속이 골치아픈 게 결국은 데이터 멤버들의 오프셋 계산 때문이다. 하지만 가상 함수만 잔뜩 만드는 것은 그 클래스 자체가 아닌 함수 테이블의 덩치를 대신 키우는 것이고 클래스 자체는 가상 상속 같은 복잡한 테크닉을 필요로 하지 않기 때문에, 대다수 현대 객체 지향 언어들은 '인터페이스'라는 개념을 도입하여 이것으로 다중 상속의 기능을 어느 정도 대체하고 있다.
사실, C++의 각종 어려운 OOP 개념을 실제로 구현하는 데 비용이 얼마나 드는지를 잘 이해하고 있는 것만으로도 상당한 수준의 프로그래밍 내공을 쌓을 수 있다!
Posted by 사무엘