템플릿 얘기가 좀 길어졌는데.. 다음 주제는 가상 함수와 관련된 엉뚱한 생각이다.
C++이라는 언어는 앞서 잠깐 언급했던 바와 같이, '오버로딩과 오버라이딩' 사이에 뭔가 견제를 하는 게 있다.
그것과 비슷하달까, C++은 오버라이딩과 멤버 함수 포인터 사이에도 디자인 차원에서 선을 긋는 게 있어 보인다.

무슨 말이냐 하면.. 가상 함수가 존재하는 어떤 객체가 주어졌을 때, 이 객체가 참조하는 vtable 값을 직접 확인하는 것을 언어 차원에서 전혀 허용하거나 지원· 고려하지 않는다.
이 객체의 어떤 가상함수는 부모 클래스의 것과 같은지, 아니면 오버라이딩 됐는지.. 이런 것을 알 수 없다.

&obj->Foo == &TBase::Foo 이런 식으로 비교하는 거?? 가상과 비가상 불문하고 다 안 된다. 클래스의 non-static 멤버 함수의 주소를 얻는 건 컴파일 타임 바인딩이 가능한 &클래스::멤버 형태만 허용될 뿐, 런타임 바인딩인 &변수->멤버는 안 된다. 그냥 컴파일 에러로 처리된다.

멤버 함수 포인터를 이용해서 pFunc에다가 특정 클래스의 Foo를 집어넣었더라도.. (obj->*pFunc)()를 호출해 보면 obj->Foo()를 호출한 것과 동일하게 접수된다.
멤버 함수 포인터에는 자신의 vtable을 참조해서 그걸 호출해 주는 thunk 함수만 연결되기 때문이다. 그 아래의 vtable 상의 주소로 다이렉트 접근이나 제어는 안 된다! 신기하지 않은가?
(하긴, 다중 상속 체계에서는 this 오프셋 보정도 이런 thunk가 하는 일 중 하나이겠지만)

C++ 클래스에서 vtable이란 걸 바이너리 차원에서 꼭 이렇게 구현해야 한다고 C++ 표준에 규정돼 있지는 않다.
그러나 실제로 구현되는 방식은 컴파일러 불문하고 거의 뻔할 뻔자이다. 그렇기 때문에 다음과 같은 동작을 억지로 구현해 줄 수 있다.

class Base {
public:
  int x;
  virtual void VF1() {}
  virtual void VF2() {}

  void* GetFuncPtr(int n) { //0: VF1, 1: VF2
    void*** pppf = (void***)this;
    return (*pppf)[n];
  }
};

무려 삼중 포인터가 쓰인 GetFuncPtr이라는 저 함수를 주목하시라. void에 대한 포인터(1)의 배열(2)을 가리키는 포인터(3)이기 때문에 삼중이 된 것이다.
가상 함수가 들어있는 클래스는 맨 첫 멤버가 vtable 포인터이기 때문에 this에 대해 저런 형변환이 가능하다.

그 다음으로, Base에 대해 1번 함수를 오버라이드한 Derived1, 2번 함수를 오버라이드한 Derived2, 그리고 둘 다 오버라이드한 Derived3. 이 세 클래스를 다음과 같이 선언해 보자.

class Derived1 : public Base {
public:
  int y;
  virtual void VF1() {}
};

class Derived2 : public Base {
public:
  int z;
  virtual void VF2() {}
};

class Derived3 : public Base {
public:
  int w;
  virtual void VF1() {}
  virtual void VF2() {}
};

요렇게 한 뒤, Base, Derived1, Derived2, Derived3 아무 객체나 선언해서 GetFuncPtr(0)을 호출해 보면 Base와 Derived2는 같은 값을 되돌린다. Derived2는 VF1을 오버라이드 하지 않았기 때문이다.
GetFuncPtr(1)을 호출해 보면 Derived1이 Base의 것과 같은 값을 되돌린다. 이유는 동일.

그렇다고 이 주소값은 &Base::VF1, &Derived2::VF2 처럼 실존하는 멤버 함수의 주소를 C++ 연산자를 통해서 얻은 주소값과 일치하지 않는다. 이거는 멤버 함수 포인터에다가 대입해서 호출을 할 수 있지 않다.
애초에 멤버 함수 포인터는 일반 함수 포인터와 달리 임의의 정수형으로부터 대입하는 게 아예 불가능하지 싶다. reinterpret_cast나 C-style cast도 되지 않는다.
그러니 저 값은 그냥 클래스 간에 값이 같은지 다른지 비교 용도로만 써먹을 수 있을 뿐이다.

그리고 사실은 언제나 저렇게 가상 함수 2개짜리 vtable이 생긴다는 보장도 없다.
클래스를 만들어서 멤버 함수를 virtual로 선언했지만 실제 코드에서 이 클래스의 인스턴스를 한 번도 포인터로 접근하지 않아서 런타임(다이나믹) 바인딩이 필요하지가 않다면..
컴파일러가 최적화 스킬을 발휘해서 vtable을 곧이곧대로 만들지 않을 수도 있다. 그러면 우리가 저렇게 만든 GetFuncPtr 함수도 제대로 맞게 동작하지 않을 것이다.

일반적인 함수라면.. 형태가 너무 단순해서 컴파일러가 평소에는 인라이닝이나 인트린식으로 실컷 최적화한다고 하더라도 그 함수의 주소가 필요할 때는 일반적인 함수 포인터 값이 반드시 제공돼야 한다. 최적화는 부가 기능일 뿐이지만, 함수 포인터는 언어 스펙에서 제공되는 필수 기능이기 때문이다.
그러나 vtable에 명시된 함수 주소는 그렇게 공식적으로 제공되는 기능이 아니다. 프로그래머가 언제까지나 꼼수로.. at your own risk를 염두에 두고 써야 한다.

이상이다.
글을 맺으면서 문득 드는 생각인데.. C++의 RTTI (런타임 type info)가 내부적으로 구현되는 방식도 가상 함수가 구현되는 방식과 아주 밀접한 관계가 있으며, 서로 떼어서 생각할 수 없어 보인다.

vtable이라는 게 결국은 한 클래스와 무조건 일대일 대응하는 고유한 정보이니, 참조하는 vtable이 동일한 객체는 동일한 클래스의 인스턴스임이 보장되기 때문이다.
그러니 자기 클래스의 이름이라든가, 부모 클래스 목록 같은 RTTI도 vtable과 함께 두거나 최소한 RTTI를 가리키는 포인터를 vtable에다가 둘 법도 해 보인다. dynamic_cast 연산자는 그런 정보를 참조하면서 동작하면 될 테고.

아 그런데.. 이런 깔끔한 관계는 단일 상속 체계에서나 보장되겠다;;
다중 상속이라면 2개째 이후의 기반(부모) 클래스에 대해서 매번 vtable 포인터가 또 추가될 테니 일이 정말 복잡해지겠다.
단일 상속에서는 복잡도가 뭔가 1씩 더해지는 것만 생각하면 됐는데, 다중 상속에서는 2씩 곱해지는 수준으로 복잡도가 폭발적으로 증가한다.;;

그러니 C++ 표준화 이전부터 RTTI를 자체 구현했던 C++ 프레임워크들은(가령, MFC 같은 골동품) 그런 건 깔끔하게 포기하고 단일 상속만 염두에 두고 저런 기능들을 구현했지 싶다.

말이 길어져 버렸는데.. 암튼 이 글의 결론은
"이 ptr은 Base의 파생 클래스이긴 한데요, 특정 무슨 함수가 오버라이드 돼서 Base의 원래 것과는 달라졌는지 아닌지만 좀 알 수 있을까요?"
요건 언어 차원에서 알려주지 않는다는 것이다. 그런 건 query하는 함수를 사용자가 수동으로 구현해 줘야 한다.

Posted by 사무엘

2025/04/17 08:35 2025/04/17 08:35
,
Response
No Trackback , No Comment
RSS :
http://moogi.new21.org/tc/rss/response/2374

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

Leave a comment
« Previous : 1 : ... 73 : 74 : 75 : 76 : 77 : 78 : 79 : 80 : 81 : ... 2291 : Next »

블로그 이미지

그런즉 이제 애호박, 단호박, 늙은호박 이 셋은 항상 있으나, 그 중에 제일은 늙은호박이니라.

- 사무엘

Archives

Authors

  1. 사무엘

Calendar

«   2026/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:
3915803
Today:
938
Yesterday:
320