[C++] 가상 함수 포인터에 관한 고찰

1 minute read

가상 함수는 포인터나 참조자의 타입에 구애받지 않고 실제 객체의 타입에 따라 함수가 호출되도록 하는 기능이다:

class base {
public:
    virtual void f() { cout << "base::f\n"; }
};

class derived : public base {
public:
    void f() override { cout << "derived::f\n"; }
};

int main() {
    base* obj = new derived{};
    obj->f();
    delete obj;
}

만약 위와 같은 상황에서 base::f()를 호출하고 싶다면 다음과 같이 클래스를 직접 명시하면 된다:

obj->base::f();

그럼 만약 아래처럼 하면 어떻게 될까?

auto ptr = &base::f;
(obj->*ptr)();

위 코드는 base::f()derived::f() 둘 중에 무엇을 호출할까? 생각해보면 base::f()를 직접 할당했기 때문에 당연히 base::f()가 호출되는 것이 직관적이다. 그러나 실제로는 derived:f()가 호출된다. 동적 바인딩이 정상적으로 작동한 것이다. 대체 어떻게 이런 게 가능할까? 함수의 주소를 직접 취해서 호출했는데 말이다! 디스어셈블리를 통해 그 과정을 자세히 알아보자. (Windows 10에서 Visual Studio 2019로 진행했다)

1

먼저 ecx에 객체의 주소를 넣고, ptr에 담긴 함수를 호출한다.

2

이부분이 의외인데, 위에서 호출되는 함수는 base::f()가 아니라 base::`vcall'{4}' 라는 이름의 생소한 함수다.

3

여기가 핵심이다. ecx(객체)에서 vfptr를 구해 eax에 넣고 vfptr[0]로 점프한다. 이 부분 때문에 ptr&base::f를 넣어서 호출했는데도 derived::f()가 호출되었던 것이다.

4

5

이런 과정을 통해 최종적으로 derived::f()가 호출되는 것이다. 사실 이 과정은 가상 함수를 평범하게 호출하는 과정과 다르지 않다. 그저 가상 함수 테이블을 참조하는 부분이 vcall이라는 함수로 따로 분리되어있을 뿐이다.

그림으로 정리하자면 다음과 같다.

6

즉, C++에서 가상 함수의 주소를 취하는 것은 사실 실제 함수의 주소가 아니라 그 함수를 호출해주는 일종의 헬퍼 함수(vcall)의 주소다. 이 때문에 &base::f를 호출하든 &derived::f를 호출하든 다를 게 없이 실제 객체에 해당하는 함수가 호출되는 것이다.

Categories:

Updated:

Comments