지금으로부터 거의 3년 전, 이 블로그가 개설된 지 얼마 되지 않았던 시절에 본인은 C++의 매우 기괴-_-한 문법인 다중 상속멤버 포인터(pointer-to-member)에 대해서 제각각 따로 글로 다룬 적이 있었다.
이제 오늘은, 그 기괴한 두 물건이 한데 합쳐지면 언어의 디자인이 얼마나 더 흉악해지는지를 보이도록 하겠다.
그 내력을 알면, C++ 이후의 객체지향 언어에서 다중 상속이 왜 봉인되어 버렸는지를 이해할 수 있을 것이다. 뭐, 이미 다 아는 분도 있겠지만 복습 차원에서.

클래스의 멤버 포인터는 그 가리키는 대상이 변수이냐 함수이냐에 따라서 내부 구조가 크게 달라진다는 말을 예전에 했었다. 함수일 때는 포인터답게 말 그대로 실행될 함수의 메모리 위치를 가리키지만, 변수일 때는 이 멤버가 this로부터 얼마나 떨어져 있는지를 나타내는 정수 오프셋에 불과하다. &POINT::x 는 0, &POINT::y는 4 같은 식.
그래서 비주얼 C++은 x64 플랫폼에서도 단순 클래스의 멤버 변수 포인터는 뜻밖에도 8바이트가 아닌 4바이트로 처리한다. UNT_PTR이 아니라 그냥 unsigned int라고 본 것이다.

그런데 다중 상속이 동반된 클래스는 '단순' 클래스라고 볼 수가 없어지며, 그런 클래스를 대상으로 동작하는 멤버 포인터는 내부 메커니즘이 굉장히 복잡해진다. 멤버 변수야 오프셋이 바뀌니까 그렇다 치지만, 멤버 함수의 포인터도 데이터 오프셋의 영향을 받는다. 비록 함수 자체는 오프셋을 타지 않고 고정된 메모리 주소이긴 하지만, 멤버 포인터가 어느 함수를 가리켜 부르느냐에 따라 그때 그때 this 포인터를 잘 보정해서 줘야 하기 때문이다.

다음 코드를 생각해 보자.
참고로, class 대신 struct를 쓴 이유는 public: 을 따로 써 주는 귀찮음을 해소하기 위해서일 뿐이다. (C#은 struct와 class의 용도가 구분되어 있는 반면, C++은 전혀 그렇지 않으므로.)

struct B {
    int valB; void functionB() { printf("functionB: %p\n", this); }
};
struct C {
    int valC; void functionC() { printf("functionC: %p\n", this); }
};

struct D: public B, public C {
    int valD; void functionD() { printf("functionD: %p\n", this); }
};

그 뒤,

D ob;
void (D::*fp)();
printf("this is %p\n", &ob);
printf("sizeof pointer-to-member is %d\n", sizeof(fp));

fp = &D::functionB; (ob.*fp)();
fp = &D::functionC; (ob.*fp)();
fp = &D::functionD; (ob.*fp)();

코드를 실행해 보면, 놀라운 결과를 볼 수 있다.
이제 fp의 크기가 포인터 하나의 크기보다 더 커졌다.
비주얼 C++ 기준으로, '포인터+int'의 합이 된다. 그래서 x86에서는 8바이트, x64에서는 12바이트.

게다가 중요한 건, functionC를 실행했을 때만 this의 값이 달라져 있다는 것이다.
이건 뭐 다중 상속의 특성상 어쩔 수 없는 면모이며, 멤버 함수를 ob.functionC()라고 직접 호출할 때는 컴파일러가 알아서 처리해 주는 기능이긴 하다.
하지만, 직접 호출이 아니라 멤버 포인터를 통한 간접 호출을 할 때는 이걸 어떻게 구현해야 할까?

결국은 멤버 함수 포인터 자체에 추가 정보가 들어갈 공간이 있어야 하고, 그 정보는 포인터에다가 함수에 대한 대입이 일어날 때 implicit하게 따로 공급되어야 한다.
다중 상속을 받은 클래스의 멤버 함수를 가리키는 포인터는 this 보정을 위한 정수 오프셋이 내부적으로 추가된다. 이제 fp는 단일 포인터 변수라기보다는 구조체처럼 바뀌었다는 뜻이다.

이 fp에다가 functionB나 functionD를 대입하면 그 멤버 함수의 주소만 대입되는 게 아니라, 숨겨진 오프셋 변수에다가도 0이 들어가며(보정할 필요가 없으므로), functionC를 대입하면 그 주소와 함께 오프셋 변수에다가도 0이 아닌 값이 같이 대입된다. 그리고 실제로 fp 호출을 할 때는 this 포인터에다가 보정이 된 값이 함수로 전달된다.

이야기는 여기서 끝이 아니다. 설상가상으로 가상 상속까지 추가된다면?
내가 클래스를 A가 아니라 B에서부터 시작한 게 이것 때문이다. 맨 앞에다가 드디어 다음 코드를 추가하고,

struct A {
    int valA;
    void functionA() { printf("functionA: %p\n", this); }
};

앞에서 썼던 B와 C도 A로부터 가상 상속을 받게 고쳐 보자.

struct B: virtual public A { ... }
struct C: virtual public A { ... }

이것도 물론 추가하고.

fp = &D::functionA; (ob.*fp)();

이렇게 해 보면..
비주얼 C++ 기준 fp의 크기는 더욱 커져서 '포인터+정수 2개' 크기가 된다. x86에서는 12바이트, x64에서는 16바이트.
다중 상속만 있을 때는 함수 말고 변수의 멤버 포인터는 크기가 변함없었던 반면, 가상 상속이 가미되면 변수 멤버 포인터도 이렇게 '크기 할증'이 발생한다. 대입 연산이나 함수 호출 때 몰래 같이 발생하는 일도 더욱 많아지며, 이 현상을 좀 유식하게 표현하면 cost가 커진다.

그 이유는 어렴풋이 유추할 수 있을 것이다. 가상 상속이라는 건 말 그대로 기반 클래스의 오프셋이 클래스의 인스턴스별로 동적으로 변할 수 있다는 뜻이다. this 포인터 보정이 뒷부분 파생 클래스의 정확한 위치를 파악하기 위해서 발생하는 일이라면, 가상 상속 보정은 앞부분 기반 클래스의 위치를 파악하는 것이 목적이다.

이런 사정으로 인해 functionA()도 원래 개체의 주소와는 다른 주소를 받으며, 이것은 functionC()가 받는 주소와는 또 다르다.
다만, pointer-to-member는 가상 함수와는 기술적으로 전혀 무관하게 동작하기 때문에, 가상 함수가 존재하는 클래스라고 해서 오버헤드가 추가되는 건 없다. 함수 멤버 포인터로 가상 함수를 가리키면, 아예 가상 함수 테이블을 참조하여 진짜 함수를 호출하는 wrapper 함수가 따로 만들어져서 그걸 가리키고 있게 된다.

요컨대 비주얼 C++은 단순 클래스, 다중 상속만 있는 클래스, 거기에다 가상 상속까지 있는 클래스라는 세 등급에 따라 멤버 포인터를 관리한다. 다만, 함수가 아닌 변수 멤버 포인터는 가상 상속 여부에 따라 두 등급으로만 나누는 듯하다. 이 정도면, 이 글을 쓰는 본인부터 이제 머리가 핑그르르 도는 것 같다.

이제 마지막으로 생각해 볼 문제가 있다. C++은 클래스의 명칭 선언만 하는 게 가능하다는 점이다.

class UnknownBase;
class UnknownDerived;

굳이 클래스의 몸체를 몰라도 이 클래스에 대한 포인터 정도는 선언이 가능하다. 그렇기 때문에 명칭 선언은 컴파일 때 헤더 파일간의 의존도를 줄이고 모듈간의 독립성을 높일 때 요긴하게 쓰이는 테크닉이다.
다만, 여러 클래스들을 명칭 선언만 하면 이들간의 상속 관계도 아직 밝혀지지 않기 때문에, 실질적인 기반 클래스와 파생 클래스 사이에 암시적인 형변환이나 static_cast, dynamic_cast 따위를 쓸 수 없다는 점도 주의해야 한다.

게다가 이렇게 명칭만 달랑 선언된 클래스에 대해서 멤버 포인터를 선언하면..
컴파일러는 이 클래스가 다중 상속이 존재하는지, 가상 상속이 존재하는지 같은 걸 알지 못한다!
그렇다고 무식하게 에러 처리하며 멤버 포인터의 선언을 거부할 수도 없는 노릇이니,
컴파일러는 가장 보수적으로 이 클래스가 어려운 요소들은 모두 갖추고 있을 거라고 생각하고 가장 덩치 크고 복잡한 등급을 선택할 수밖에 없다.

나중에 사용자가 추가 인클루드를 통해 클래스의 몸체를 선언하여, 이 클래스는 단순한 놈이라는 게 알려지더라도 한번 복잡하게 결정되어 버린 타입 구조는 다시 바뀌지 않는다.
게다가 이렇게 unknown 클래스에 대한 멤버 포인터는 단순히 '가상 상속 클래스' 등급이 아니라, 메타 정보가 추가로 붙는지 비주얼 C++에서는 함수 기준으로 x86에서 무려 16바이트를 차지하며, x64에서는 24바이트를 차지하게 된다. 포인터 둘, int 둘의 합이다.

printf("%d\n", sizeof(void (UnknownDerived::*)() ));

물론, 멤버 포인터부터가 굉장한 레어템인데, 몸체도 없이 명칭 선언만 된 클래스에 대해서 멤버 포인터를 덥석 들이대는 코딩을 우리가 실생활에서 직접 할 일은 극히 드물다. 하지만 딱 machine word와 동일한 크기를 기대했던 멤버 함수 포인터가 3~4배 크기로 갑자기 뻥튀기되고 생각도 못 했던 오버헤드가 추가되는 일은 없어야 하겠기에, 비주얼 C++은 역시 비표준 확장을 통해서 이 문제에 대한 해결책을 제시하고 있다.

그것은 바로 _single_inheritance, _multiple_inheritance, _virtual_inheritance라고 참 길게도 생긴 키워드.
클래스를 명칭 선언만 할 때

class _single_inheritance UnknownDerived;

이런 식으로 써 줌으로써 “이놈은 다중 상속 같은 귀찮은 요소가 없는 클래스이다. 따라서 얘에 대한 멤버 포인터는 추가 오프셋이 없는 제일 간단한 등급으로 만들어도 OK다”라는 힌트를 컴파일러에다 줄 수 있다.
복잡한 놈이라고 예고를 해 놓고 단순한 형태로 클래스를 선언하는 건 괜찮으나, 간단한 놈이라고 예고를 해 놓고 나중에 다중 상속이나 가상 상속을 쓰면 물론 컴파일 에러가 발생하게 된다.

아마 스타크래프트를 만든 사람도 스탑 럴커 같은 전술은 생각을 못 하지 않았을까.
저런 판타지 같은 면모는 C++을 설계한 사람이나 추후에 기능을 확장한 표준 위원회 사람들도 생각을 못 했을 가능성이 높아 보인다.
그렇기 때문에 비주얼 C++처럼 단순, 다중, 가상으로 세 등급을 나눠서 포인터에다 할증 제도를 넣고, 관련 예약어까지 추가한 건 전적으로 표준이 아니라 컴파일러 구현하기 나름이다.

아, 자세한 건 이 사이트 내용을 좀 공부하고 글을 쓰려고 했는데 도저히 다 읽을 엄두가 안 난다.
관심 있는 분들은 알아서 탐독해 보시길.
언뜻 보니, 다중 상속이 멤버 포인터보다 시기적으로 나중에 등장했다. 그래서 둘을 한꺼번에 구현하는 게 이 정도로 복잡하게 꼬인 셈이다.

Posted by 사무엘

2013/04/02 08:33 2013/04/02 08:33
,
Response
No Trackback , 2 Comments
RSS :
http://moogi.new21.org/tc/rss/response/813

Trackback URL : http://moogi.new21.org/tc/trackback/813

Comments List

  1. 김재주 2013/04/04 00:47 # M/D Reply Permalink

    C++은 사실 개정할 게 아니라 아예 기반을 다시 썼더라면 좋았을 것 같습니다. 현대 프로그래밍 언어로서 말이죠..

    1. 사무엘 2013/04/04 05:35 # M/D Permalink

      수없이 많은 레거시 코드들이 C++의 넘사벽급의 최대 버팀목이자, 최대의 발목 잡는 단점인 것 같습니다.
      너무 오덕스럽게 설계된 요소, 객체지향 시행 착오 같은 것들이 참 많은 애증을 자아내게 하죠.

      그나저나, 본문의 말미에 링크해 놓은 codeproject 글을 드디어 날잡아서 하루 읽어 봤는데.. 글쓴이의 어마어마한 내공과 깊이에 깜짝 놀랍니다. pointer-to-member를 분석하려면 저 정도로 분석해야겠다는 생각이 들어 제 글이 다 부끄러워질 지경이네요.
      모든 컴파일러들이 비주얼 C++처럼 구현한 것도 아니고, this 포인터와 관련된 모든 보정은 별도의 함수가 알아서 하게 해서 멤버 포인터 자체는 어떤 경우에도 크기를 할증시키지 않게 한 물건도 있습니다.

Leave a comment
« Previous : 1 : ... 795 : 796 : 797 : 798 : 799 : 800 : 801 : 802 : 803 : ... 1521 : Next »

블로그 이미지

철도를 명절 때에나 떠오르는 4대 교통수단 중 하나로만 아는 것은, 예수님을 사대성인· 성인군자 중 하나로만 아는 것과 같다.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2019/07   »
  1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30 31      

Site Stats

Total hits:
1220896
Today:
81
Yesterday:
474