C++의 클래스에는 여느 객체지향 언어와 마찬가지로, 정적 바인딩이 아닌 실행 시간 동적 바인딩을 거쳐 호출되는 가상 함수라는 개념이 있다.
일반 함수들은 선언만 해 놓고 정의를 안 했다면, 그 함수를 실제로 호출한 곳이 있을 때만 링크가 시도되고 링크 에러가 난다. (물론 DLL을 만들 때처럼 외부로 export된다고 명시된 함수라면, 우리 모듈 내부에서 당장 안 쓰이더라도 당연히 몸체가 존재해야 함. 이건 논외로 한다.)
이런 일반 함수와는 달리 가상 함수는 해당 클래스에 속하는 개체가 생성되어 가상 함수 테이블이 만들어질 때 이미 참조가 발생한다. 그렇기 때문에 일단 선언을 한 이상, 코드 내부에서 직접적으로 호출을 안 하더라도 반드시 정의가 돼 있어야 한다. 그래야 링크 에러가 안 난다.
가상 함수이지만 자기 몸체가 존재하지 않아도 되는 예외적인 경우는 단 하나, 순수 가상 함수뿐이다.
순수 가상 함수가 존재하는 클래스는 반쯤은 불완전한 타입과 비슷하다. 불완전하기 때문에 그 클래스의 인스턴스를 직접 만들 수 없다. 누군가가 얘를 상속하여 파생 클래스를 만든 뒤, 모든 순수 가상 함수들에 대한 몸체를 구현해 줘야만 한다. 몸체를 안 만들고 선언만 해 놓으면 컴파일은 되지만 그제서야 링크 에러가 나게 된다. 즉, 순수 가상 함수는 가상 함수의 링크와 관련된 문제를 파생 클래스로 보류하는 역할을 한다.
순수 가상 함수가 들어있는 추상 클래스의 인스턴스를 직접 만드는 것은 컴파일러에 의해 원천 봉쇄되고 금지되어 있다. 그런데 아주 특수한 상황에서는 이렇게 몸체가 없는 추상 클래스에 대한 직접 호출이 일어날 때가 있다. 예를 들어 아래와 같은 경우이다.
class CDerived;
class CBase {
CDerived *m_pt;
public:
CBase(CDerived *p);
virtual void Pure() = 0;
};
class CDerived: public CBase {
public:
CDerived(): CBase(this) {}
void Pure() { puts("Ahh"); }
};
CBase::CBase(CDerived *p): m_pt(p) { p->Pure(); }
int main() { CDerived obj; }
생성자에서 자기 자신이 아직 완전히 초기화되지 않은 상태일 때 Pure()을 호출해 버리니, 마치 열차가 탈선한 것처럼, 지뢰찾기에서 지뢰를 밟은 것처럼 허를 찔리는 것이다.
사실 forward 선언까지 동원해 가면서 굳이 m_pt를 CDerived의 포인터로 지정하지 않아도 된다. 그냥 기반 클래스와 동일한 CBase *로 지정해도 에러가 나는 건 마찬가지이다.
참고로, CBase의 생성자에서 저렇게 번거롭게 p->Pure()을 하지 않고 그냥 this에 대해서 Pure()을 호출하려 시도하면 그건 순수 가상 함수임에도 불구하고 동적 바인딩이 아니라 정적 바인딩이 일어난다. CBase에 Pure 함수가 정의돼 있지 않다고 링크 에러가 난다. 그걸 별도의 포인터 값으로 억지로 우회함으로써 아직 몸체가 없는 순수 가상 함수에 도달할 수 있다. 위의 코드는 굉장히 인위적인 예이지만, 멀티스레드 race condition 같은 데서도 드물게 이런 일이 벌어질 수 있다.
C#은 생성자에서도 동적 바인딩이 잘 처리되는 게 보장되는 반면, C++은 옛날에 만들어지기도 해서 성능 보전을 위해서인지 설계 관점이 다소 보수적이다. 기반 클래스의 생성자는 자신이 파생 클래스의 생성 과정의 일부라 하더라도 파생 클래스에 대한 단서를 전혀 얻을 수 없고 언제나 기반 클래스의 형태로만 동작한다.
여러 모로 생성자에서 가상 함수를 호출하는 건 좋지 않다는 걸 알 수 있다. DllMain 안에서 또 DLL을 로딩하지 말고 가능한 한 거기서는 몸을 사리고 있어야 하는 것과 비슷한 맥락이다.
초기화가 되기 전에 가상 함수 테이블이 가리키는 주소값이 NULL이었다면, 그냥 더 생각할 것도 없이 운영체제 차원에서 '잘못된 연산' 에러가 나고 그걸로 끝이 날 것이다. 그러나 그렇게만 하는 건 재미(?)가 없고 데이터를 조작하다가 발생한 여느 null pointer 에러와 구분도 어려우니 컴파일러는 가상 함수 테이블에다가 순수 가상 함수에 대한 디폴트 핸들러의 주소를 넣어 준다. 주소를 한 번만 넣어 주면 끝이니 딱히 성능에 영향을 받을 일이 없고, 따라서 debug뿐만 아니라 release 빌드에도 못 넣을 이유가 없다.
개체가 정상적으로 초기화됐다면 그 주소는 사용자가 작성한 파생 클래스의 함수로 당연히 대체된다. 그러나 그게 아직 바뀌지 않았다면 핸들러 함수로 간다. 얘는 assert(0)과 비슷한 급의 예외를 발생시키는 것 말고 딱히 하는 일은 없다. 다만, 이 일이 벌어졌을 때 사용자가 지정해 준 함수가 호출되게 할 수는 있다.
가상 함수가 몸체가 온전하지 않으면 컴파일 에러(순수 가상 함수에 대한 클래스 선언)와 링크 에러(...)뿐만 아니라 이렇게 런타임 에러까지 드물게나마 발생할 수 있다는 걸 알 수 있다. 런타임 에러라니 왠지 C++답지 않아 보이지만, 이건 그래도 운영체제나 컴퓨터 차원으로 갈 런타임 에러를 언어 시스템 차원에서 수습 가능한 런타임 에러로 바꿔 주는 체계까지는 갖춰져 있다. 배열 첨자 초과나 division by 0 에러는 전자로 바꾸려면 매번 값을 사전에 점검해야 하기 때문에 오버헤드가 큰 반면, 순수 가상 함수 호출은 그냥 디폴트 함수 주소만 설정해 주면 되니까 말이다.
Posted by 사무엘