클래스의 virtual function table과 가상함수의 동적바인딩이 잘 안 됩니다.
가상함수 테이블(virtual function table)은 컴파일 타임에 생성이 되고, 가상함수 테이블의 대상인 클래스로부터 생성된 객체 포인터의 vptr은 실행시에 동적으로 결정되는 것으로 알고 있습니다. 그래서 실습코드를 만들어보던 중에 동적결합의 이상유무를 발견하게 되었습니다. 코드는 다음과 같습니다.
class TopC {
public:
virtual void QueryInterface()=0;
};
class MiddleC : public TopC {
public:
virtual void QueryInterface() {
printf("MiddleC::QueryInterface\n");
}
virtual void QueryInterface(const char* name) {
printf("MiddleC::QueryInterface(const char* name), %s\n", name);
}
};
class ImpC : public MiddleC {
public:
virtual void QueryInterface() {
printf("ImpC::QueryInterface()\n");
}
};
class OverC : public ImpC {
public:
virtual void QueryInterface() {
printf("OverC::QueryInterface()\n");
}
};
클래스의 계층구조를 위와 같이 설계하고, 다음과 같이 코드를 언급했습니다.
ImpC* PIpC2=new ImpC;
PImpC2->QueryInterface();//ImpC::QueryInterface() 호출
//PImpC2->QueryInterface("huge");//컴파일 오류
MiddleC* PMiddleC2=PImpC2;
PMiddleC2->QueryInterface();//ImpC::QueryInterface() 호출
PMiddleC2->QueryInterface("huge");//컴파일이 안되야 하는데, 호출까지 이루어짐.//MiddleC::QueryInterface()
PImpC2 포인터 변수와 PiddleC2 포인터 변수는
new ImpC
로 생성된 객체를 가리키고, 그렇기 때문에 같은 vptr을 공유합니다. 그래고 해당 vptr은 ImpC 클래스의 vtable을 가리킵니다. 그림으로 그리면 다음과 같습니다.
ImpC이 vtable
virtual void QueryInterface의 주소 <--------------------- vptr <------- PImpc2, PMiddleC2
그래서 PMiddleC2라는 포인터로 호출할 수 있는 함수는 QueryInterface() 함수로 국한돼야 하는데, 가상함수테이블에 놓이지 않은 QueryInterface(const char* name) 함수가 아무런 문제없이 호출됩니다. 가상함수 테이블을 살펴보기도 하였으나, 그 구성원리에 따르면, ImpC 클래스에서 QueryInterface() 함수가 언급됨에 따라 MiddleC에서 언급된 QueryInterface(const char* name) 함수는 이름이 가리워져 테이블에 놓이지 않을 것으로 생각하는데, 어째서 QueryInterface(const char* name) 함수가 호출되는지 궁금합니다. 그 이유를 알고 싶습니다.
QueryInterface(const char*)
QueryInterface(const char*) 함수가 ImpC 단계에서 숨겨진거지 존재하지 않는건 아닙니다.
ImpC* 타입의 포인터로 함수를 호출하는 것과, MiddleC* 타입의 포인터로 함수를 호출하는 것은 다르게 동작합니다. QueryInterface() 함수가 가상함수이므로 런타임에서 최종적으로는 같은 함수가 호출되긴 합니다.
PMiddleC2->QueryInterface("huge"); 가 컴파일 안 될 이유는 없습니다. PMiddleC2는 MiddleC* 타입이고 MiddleC 클래스에는 엄연히 QueryInterface(const char*) 가 존재하기 때문입니다. (여기서 PMiddleC2가 실제로는 ImpC 객체를 가리키고 있다는 맥락은 고려되지 않습니다.)
그러면 MiddleC* PMiddleC2=PImpC2; 는 문제가 안되는가? 예, 하위 클래스 타입의 포인터를 상위 클래스 타입의 포인터로 사용하는 것이므로 역시 문제가 없습니다.
정적바인딩 정적바인딩 둘 중에 하나여야 하지 않을까요?
비가상함수는 정적바인딩되고 가상함수는 실행시에 정적바인딩되는 것으로 압니다.
MiddleC* PMiddleC2=PImpC2;
상속계층 어떤 클래스에도 비가상함수를 언급하지 않았으니 정적바인딩되는 함수는 없습니다. 그러면 바인딩에서 고려되는 것은 오직 동적바인딩이죠. PMilldeC2와 PImpC2의 vptr의 값은 같지 않습니까. 그러니까 같은 ImpC에 대한 가상함수테이블을 가리킬 것이고요. 이 가상함수테이블에는 QueryInterface(const char* name) 라는 함수를 가리키는 열이 없습니다. 가상함수를 호출할 수 있는 유일한 단서는 vptr일텐데요. 마치 컴파일이 이 함수를 정적바인딩해야할 대상으로 여기는 것 같기도 하고. 혹시 가상함수인데 파생클래스에서 재정의를 하지 않으니 정적바인딩하는 것일까요?
동적 바인딩 이전에 고려해야 할 단계가 하나 있습니다
동적 바인딩 이전에 고려해야 할 단계가 하나 있습니다.
바로 Overload resolution이죠.
귀하의 코드에서
MiddleC
및 그 파생 클래스에는 가상 메서드가 두 개 있습니다.그런데 이름은
QueryInterface
으로 같지요.따라서 귀하의 다른 코드가
MiddleC
및 그 파생 클래스의 객체에 대해QueryInterface
를 호출하고자 할 때,컴파일러는 둘 중에 어떤
QueryInterface
를 호출하고자 하는지 찾는 과정을 거쳐야 합니다.https://en.cppreference.com/w/cpp/language/overload_resolution
컴파일러는 객체의 (컴파일 타임) 타입으로부터 Candidate functions의 목록을 뽑아내게 되는데,
이 때 하위 클래스의 선언은 기본 클래스의 선언을 모조리 dominate 해 버리게 됩니다.
https://en.wikipedia.org/wiki/Dominance_(C%2B%2B)
즉,
ImpC
에서부터 시작된QueryInterface
이름에 대한 candidate functions은,ImpC
에서QueryInterface
라는 이름으로 단 하나의 (매개변수 없는) 메서드만 선언하였기에, 이것 하나뿐이라는 거죠.반면
MiddleC
에서부터 시작할 경우엔 매개변수 없는 것과const char*
를 받는 것, 둘 다 Candidate functions이 됩니다.Candidate functions이 모두 모이고 나면, 컴파일러는 그 중 Best viable function을 선택하게 되고,
가상 메서드이므로 동적 바인딩이 뒤이어 일어나게 됩니다.
======
링크 드린 문서를 보시면 아시겠지만, C++에서 "함수를 호출하고자 할 때 어떤 함수가 호출되는가"는 꽤 복잡한 주제입니다.
대부분의 경우에는 이런 걸 다 알 필요가 없습니다만...
올려주신 예제와 같이 "기본 클래스에서 오버로딩 된 메서드를 하위 클래스에서 하나만 오버라이드 하는" 상황을 만들 경우엔 골치아픈 현상이 나타나게 됩니다.
> 이 가상함수테이블에는 QueryInterface
> 이 가상함수테이블에는 QueryInterface(const char* name) 라는 함수를 가리키는 열이 없습니다.
없다는 것은 어떻게 확인하셨나요? 실제로 PMiddleC2->QueryInterface("huge") 가 호출되고 있으므로 있다고 보는 것이 합당해 보입니다.
---
정적 바인딩이 되는 것은 아닌가?
PMiddleC2->QueryInterface("huge") 부분이 컴파일 될 때 컴파일러는 PMiddleC2의 내용물을 모르는 상태로 코드를 생성해야 합니다.
하지만 컴파일러 최적화가 적용된다면 코드 맥락을 파악해서 불필요한 동적 바인딩이 제거될 수도 있습니다. PMiddleC2와 PImpC2가 둘 다 지역변수이고 같은 코드블럭에 있으므로 최적화 난이도는 쉬워보입니다.
그렇지만 최적화 적용 전과 후가 최종적으로 같은 결과를 내야 하므로 동적 바인딩이 되었든 정적 바인딩이 되었든 PMiddleC2->QueryInterface("huge") 는 호출 가능하다고 볼 수 있으며, 동적 바인딩으로 동작할 경우(최적화 없이 기본 스펙대로 동작할 경우) 해당 함수는 가상함수 테이블에 존재한다고 볼 수 있습니다.
그 이유는
ImpC2->QueryInterface("glue");
라고 호출하면, 컴파일 오류가 납니다. 그도 그럴 것이 ImpC 클래스에 정의된 QueryInterface와 동일한 이름의 베이스 클래스 함수이기 때문에 이름이 가리워지죠. 그래서 컴파일 오류가 나는 것이고요. 스콧 마이어 선생님의 책을 보면 그렇게 설명이 돼있습니다. 이름가리기 규칙에 따라 ImpC의 가상함수테이블에 실리지 않는 것이죠.
정적이든 동적이든 가상함수테이블일까요?
vptr에 없으면 형식적인 타입에 대응하는 가상함수테이블에서 해당 함수를 얻어오는 것 같은데요. 아닐까요?
동적바인딩과 정적바인딩이 객체를 어떻게 구성 가능하게 하는 것일까요?
객체의 개념화에 대해 탐색해보니, 객체를 그림으로 설명해놓은 것을 보았는데, 그 그림은 순차적으로 다음과 같이 언급을 합니다.
객체
- vptr
- 속성1
- 속성2
- 속성3
- 메서드1
- 메서드2
- 메서드3
- 메서드4
여기서 속성은 멤버변수이고, 메서드는 멤버함수입니다. 그리고 vptr은 가상함수테이블을 가리키는 용도의 포인터입니다. 컴파일이 new ImpC라는 구문을 만날 때, 저 객체의 공간은 어떤 시간에 어떠한 방식으로 채워지는 것일까요? 저 공간 전부가 실행시에 생성되는 것이라면, 저 객체를 가리키는 PMiddleC2는 QueryInterface(const char* name) 함수를 어떻게 호출하는 것인지 궁금합니다. vptr만 유효하겠죠?
정적바인딩과 동적바인딩에서 new ImpC 구문을 컴파일이 만나면, new ImpC와 관련해서 공간을 컴파일시와 실행시에 어떻게 채워가는 것인지? PMiddleC2가 QueryInterface(const char* name)이라는 함수를 어떻게 찾을 수 있는지 궁금합니다.
여러 말씀 감사합니다.
동적 바인딩과 정적 바인딩이 서로 엮이는 것이군요.
댓글 달기