36: 상속받은 비가상 함수를 파생 클래스에서 재정의하는 것은 절대 금물!
D라는 이름의 클래스가 B라는 이름의 클래스로부터 public 상속에 의해 파생되었고, B 클래스에는 mf라는 이름의 public 멤버 함수가 정의되어 있다고 가정하자.
class B {
public:
void mf();
};
class D : public B {};
B나 D, 혹은 mf에 대해 전혀 모르는 상태에서 D 타입의 객체인 x가 다음처럼 있다고 할 때,
D x; // x는 D타입으로 생성된 객체
다음과 같이 작성한 코드가,
B* pB = &x; // x에 대한 포인터를 얻어낸다.
pB->mf(); // 이 포인터를 통해 mf를 호출한다.
다음처럼 동작하지 않으면 꽤나 황당할 것이다.
D* pD = &x; // x에 대한 포인터를 얻어낸다.
pD->mf(); // 이 포인터를 통해 mf를 호출한다.
그 이유는 간단하다. 양쪽의 경우에서 한결같이 x 객체로부터 mf 멤버 함수를 호출하고 있기 때문이다. 그런데 다를 수도 있다는 게 문제이다. 특히, mf가 비가상 함수이고 D 클래스가 자체적으로 mf 함수를 또 정의하고 있으면 위와 같은 황당한 동작이 나오게 된다.
class D : public B {
public:
void mf(); // mf를 가려 버린다. 항목 33
};
pB->mf(); // B::mf를 호출
pD->mf(); // D::mf를 호출
이렇게 동작하는 이유는 비가상 함수는 정적 바인딩(static binding)으로 묶이기 때문이다. pB는 'B에 대한 포인터' 타입으로 선언되었기 때문에, pB를 통해 호출되는 비가상 함수는 항상 B 클래스에 정의되어 있을 것이라고 결정해 버린다는 말이다. 심지어 B에서 파생된 객체를 pB가 가리키고 있다 해도 마찬가지다.
반면, 가상 함수의 경우엔 동적 바인딩(dynamically binding)으로 묶인다. 만약 mf가 가상 함수 였다면, mf가 pB, pD어디에서 호출되든 D::mf가 호출된다. 진짜로 가리키는 대상은 D 타입의 객체기 때문이다.
만약 D 클래스를 만드는 도중에 B 클래스로부터 물려받은 비가상 함수인 mf를 재정의해 버리면, D 클래스는 일관성 없는 동작을 보이는 이상한 클래스가 된다. 분명히 D 객체인데도, 이 객체에서 mf 함수를 호출하면 B와 D 중 어느 mf를 호출할지 모른다는 뜻이다. 게다가 B냐 D냐를 좌우하는 요인이 그 객체를 가리키는 포인터의 타입이라는 점이 심각하다.
public 상속의 의미는 "is-a"이다. 그리고 비가상 멤버 함수는 클래스 파생에 관계없는 불변동작을 정해 두는 것이다. 이제 D에서 mf를 재정의한다면, 우리의 설계에는 모순이 생겨 버린다. mf의 재정의로 인해 '모든 D는 B의 일종'이란 명제는 거짓이 된다. 이런 상황이라면, D는 B로부터 pbulic 상속을 받으면 안 된다. 그리고 'mf는 클래스 파생에 상관없이 B에 대한 불변동작을 나타낸다'라는 점도 참이 되지 않는다. 이런 경우라면 mf는 가상 함수로 만들어지는 것이 맞다. 불변동작에 해당된다면 재정의할 시도조차 하면 안 된다.
어떤 상황에서도 상속받은 비가상 함수를 재정의하는 것은 절대 금물이다.
항목 7의 내용은 다형성을 부여한 기본 클래스의 소멸자는 반드시 가상 함수로 선언해야 한다는 내용이였다. 이 내용도 마찬가지이다. 소멸자를 비가상 함수로 선언한다면, 파생 클래스에서는 상속받은 비가상 함수, 즉 비가상 소멸자를 재정의할 것이 뻔하기 때문이다. 심지어 소멸자를 선언하지 않은 경우에도 이런 불상사가 발생할 수 있다. 항목 5에서 말했듯이, 컴파일러가 자동으로 소멸자를 생성하기 때문이다. 결과적으로 항목 7은 이번 항목의 특수한 한 경우라는 것을 알 수 있다.
이것만은 잊지 말자!
- 상속받은 비가상 함수를 재정의하는 일은 절대로 하지 말자.