가상함수를 공부하면서 알게 된 내용을 적어보았다. 글의 내용과 흐름은 윤성우의 열혈 C++ 프로그래밍을 참고해서 객체가 멤버함수를 어떻게 호출하게 되는지부터 따라가 보았다.
멤버변수는 객체의 메모리 공간에 존재한다. 그런데, 멤버함수도 멤버변수처럼 객체의 메모리 공간에 위치할까?
객체가 멤버변수를 참조할 때는 해당 객체의 메모리 공간에 접근해서 멤버변수를 가져오면 된다. 그런데 멤버함수를 호출할 때는 어떻게 할까? 멤버함수도 객체의 메모리 공간에 위치해 있어서, 멤버변수처럼 해당하는 메모리 공간에 접근해서 실행하는 것일까?
그런데 멤버함수가 객체의 메모리 공간에 포함된다면 객체의 크기는 어떻게 계산해야 할까? 여간 번거로운 작업이 아닐 것 같다. 그나마 쉽게 생각해보자면, 객체가 함수의 포인터만 들고 있으면 되지 않을까? 어차피 멤버함수의 구현부는 멤버변수처럼 각 객체가 다른 내용을 가지지도 않는데 한 곳에 한 번만 만들어두고 모든 객체가 공유해서 사용하는게 더 효율적일 것 같다. 그러면 객체의 크기를 계산할 때도 각 함수 포인터의 크기만 계산하면 된다.
실제로 C++에서는 객체가 이런 식으로 동작한다. 객체의 메모리 공간에 실제로 함수 포인터를 두는 것은 아니지만, 객체가 생성되면 멤버함수는 객체의 메모리 공간과는 별도의 메모리 공간에 위치하고, 이 함수가 정의된 클래스의 모든 객체가 이를 공유한다. (따라서 다행히 객체의 크기를 계산할 때는 멤버변수만 신경쓰면 된다)
그리고 객체가 멤버함수를 호출하면 해당 함수의 구현부가 위치한 메모리 공간의 주소로 이동해서 코드를 실행하게 된다. 이러한 방식을 정적 바인딩이라고 한다.
함수를 사용하는 호출부와 함수의 내용이 위치한 구현부를 연결시키는 작업을 바인딩이라고 한다. 즉, 함수가 호출되는 부분을 기계어로 옮길 때 어느 메모리 공간으로 점프할 것인지를 적어두는 것이다. 바인딩 방식은 정적 바인딩과 동적 바인딩으로 나뉜다.
일반적으로 사용하는 바인딩 방식으로, 컴파일할 때 함수의 호출부에 어느 메모리 공간으로 점프할 것인지를 박아두는 방식이다. 따라서 이 방식은 컴파일 시간에 호출될 함수가 정해지게 된다.
컴파일러는 객체의 자료형을 보고 이를 판단하기 때문에, 실제 객체의 자료형이 아니라 선언된 자료형을 기준으로 이를 판단해서 호출될 함수를 결정한다. 그래서 일반적으로 유도 클래스의 객체가 기초 클래스의 자료형으로 선언되었을 경우, 객체가 호출하는 멤버함수가 오버라이딩되어 있다면 기초 클래스의 멤버함수를 호출하게 되는 것이다.
실행 시간에 호출할 함수가 결정되는 바인딩 방식이다. 동적 바인딩 방식으로 동작하는 함수를 가상함수라고 하며, virtual 키워드의 선언을 통해 구현할 수 있다.
가상함수는 어떻게 동적으로 바인딩을 할까?
하나 이상의 가상 함수가 포함된 클래스의 객체는 자기 메모리 공간의 가장 첫 번째 자리(offset 0번)에 가상함수 테이블(V-table, Virtual Table)의 주소를 두게 된다. 가상함수 테이블이란 실제로 호출되어야 할 함수의 위치 정보를 담고 있는 테이블로, 함수의 시그니처와 함수의 구현부가 위치한 메모리 주소가 key-value 쌍으로 담겨 있다.
컴파일할 때, 객체가 가상함수를 호출하면 그 자리에 컴파일러가 판단한 함수의 구현부 메모리 주소 대신 객체의 가상함수 테이블의 주소를 둔다. 이후 실행할 때는 가상함수 테이블의 주소를 타고 가서 실제로 호출되어야 할 멤버함수의 위치로 점프해서 코드를 실행한다. 따라서 이런 방식으로 가상함수는 컴파일러가 객체의 자료형이 아닌, 자료형이 실제로 가리키는 객체를 참조해서 호출 대상을 결정할 수 있도록 하는 것이다.
가상함수 테이블이 동작하는 방식은 아래 예시와 같다.
class B
{
public:
virtual void bar() {}
virtual void qux() {}
};
class C
{
public:
virtual void bar() {}
};
이미지와 같이 가상함수 테이블에서 클래스 C의 qux() 함수는 기초 클래스인 B의 qux() 함수를 가리키는 반면, C에서 오버라이딩된 bar() 함수는 C에서 정의한 bar() 함수의 메모리 공간을 가리키게 된다.
가상함수 호출에 드는 비용 때문에 C++이 C보다 느리다고 할 수 있다. 정적 바인딩은 실행 시 함수의 메모리 주소를 따라가 그대로 코드를 실행하기만 하면 되지만, 동적 바인딩 가상함수 테이블의 주소를 따라가 참조하는 작업이 포함되기 때문에 비교적 실행 속도가 감소하게 된다.
정적 바인딩과 동적 바인딩은 각각 직접 분기와 간접 분기로 구현된다. 참조해야 하는 최종 도착지의 메모리 주소가 A라고 할 때, 직접 분기는 메모리 A의 주소로 분기하는 것이고, 간접 분기는 메모리 A의 주소를 가리키고 있는 중간 지점인 메모리 B로 분기하는 것을 의미한다.
그런데 하드웨어 측면에서 간접 분기는 직접 분기에 비해 비용이 많이 드는 작업이다. 분기문을 만났을 때 CPU는 분기문의 결과를 예측해서 성능을 높인다. 직접 분기는 분기할 주소가 명확하기 때문에 다음으로 실행할 명령어의 예측이 쉬운 반면, 간접 분기는 다음 명령어가 무엇인지 곧바로 알기가 어렵다. 다음 명령어를 알기 위해서는 분기문을 타고 간 주소에서 한 번 더 타고 가야 하기 때문이다. 따라서 가상 함수 호출로 인한 간접 분기에 드는 비용 때문에 C++이 C에 비해 느리다고 할 수 있다.