시간 기록을 유지하는 TimeKeeper란 이름을 가진 기본 클래스를 만들어 놓은 후에 적절한 용도에 따라 이것을 파생시키도록 설계했다고 칩시다.
1 2 3 4 5 6 7 8 9 10 | class TimeKeeper { public: TimeKeeper(); ~TimeKeeper(); ... }; class AtomicClock: public TimeKeeper { ... }; class WaterClock: public TimeKeeper { ... }; class WristWatch: public TimeKeeper { ... }; | cs |
어떤 시간기록 객체에 대한 포인터를 손에 넣는 용도로 팩토리 함수(factory function, 새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수)를 만들어 두면 딱 좋을 것 같습니다. 팩토리 함수의 기존 규약을 그대로 따라간다면, getTimeKeeper 함수에서 반환되는 객체는 힙에 있게 되므로, 결국 메모리 및 기타 자원의 누출을 막기 위해 해당 객체를 적절히 삭제(delete)해야 합니다.
1 2 3 4 | TimeKeeper *ptk = getTimeKeeper(); // TimeKeeper 클래스 계통으로부터 // 동적으로 할당된 객체를 얻습니다. ... // 이 객체를 사용합니다. delete ptk; // 자원 누출을 막기 위해 해제(삭제)합니다. | cs |
문제는 getTimeKeeper 함수가 반환하는 포인터가 파생 클래스(그러니까 AtomicClock) 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스(즉, TimeKeeper* 포인터)를 통해 삭제된다는 점, 그리고 결정적으로 기본 클래스(TimeKeeper)에 들어 있는 소멸자가 비가상 소멸자(non-virtual destructor)라는 점입니다. C++ 규약에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이라고 되어 있습니다. 대개 그 객체의 파생 클래스 부분이 소멸되지 않게 됩니다.
정리하면, getTimeKeeper 함수에서 포인터를 통해 날아오는 AtomicClock 객체는 기본 클래스 포인터를 통해 삭제될 때 AtomicClock 클래스에 정의된 데이터 멤버들이 남아있게 되고, AtomicClock의 소멸자도 실행되지 않습니다. 그러나 TimeClock 부분은 소멸 과정이 제대로 끝나므로 결국 반쪽짜리 '부분 소멸(partially destroyed)' 객체의 신세로 전락하는 거죠.
이 문제를 없애는 방법은 지극히 간단합니다. 기본 클래스의 소멸자 앞에 virtual 키워드를 붙여 가상 소멸자로 만들면 객체 전부가 소멸됩니다. 파생 클래스 부분까지 몽땅 말이죠.
1 2 3 4 5 6 7 8 9 10 | class TimeKeeper { public: TimeKeeper(); virtual ~TimeKeeper(); ... }; TimeKeeper *ptk = getTimeKeeper(); ... delete ptk; // 이제 제대로 동작합니다. | cs |
TimeKeeper와 비슷한 기본 클래스에는 대개 소멸자 외에도 가상 멤버 함수들이 더러 들어 있기 마련입니다. 파생 클래스를 구현할 때 해당 함수를 역할에 따라 맞추는 작업을 허용한다는 의미이겠지요. 예를 들어 TimeKeeper 클래스는 현재 시각을 알려 주는 getCurrentTime 함수를 가상 함수로 가질 수 있습니다. 가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 하는 게 대부분 맞습니다.
가상 함수를 C++에서 구현하려면 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는 데 쓰이는 자료구조가 클래스에 별도로 하나 들어가야 합니다. 클래스에 가상 함수가 들어가게 되면 해당 클래스 타입 객체의 크기가 커질 뿐만 아니라, 이식성 또한 기대하기 어렵습니다. 따라서 어느 경우를 막론하고 소멸자를 전부 virtual로 선언하는 일은 virtual로 절대 선언하지 않는 것만큼이나 좋지 않은 마인드입니다. 가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어 있는 경우에만 한정하세요.
가상 함수가 전혀 없는데도 비가상 소멸자 때문에 뒤통수를 맞는 경우도 있습니다. 한 예가 표준 string 타입입니다. 따라서 string 타입은 기본 클래스로 잡으면 안 됩니다.
1 2 3 4 5 6 7 8 9 10 11 12 | class SpecialString: public std::string { // std::string은 가상 소멸자가 없습니다. ... }; SpecialString *pss = new SpecialString("Impending Doom"); std::string *ps; ... ps = pss; // SpecialString* => std::string* ... delete ps; // 정의되지 않은 동작이 발생합니다! *ps의 SpecialString 부분에 있는 // 자원이 누출되는데, SpecialString의 소멸자가 호출되지 않기 때문입니다. | cs |
이 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 전부 적용됩니다. STL 컨테이너 타입(예를 들면 vector, list, set, tr1::unordered_map 등) 전부가 바로 여기에 속합니다.
경우에 따라서는 순수(pure) 가상 소멸자를 두면 편리하게 쓸 수도 있습니다. 순수 가상 함수는 해당 클래스를 추상 클래스(abstract class, 그 자체로는 인스턴스를 못 만드는(그 타입의 객체를 생성할 수 없는) 클래스)로 만들지요. 어떤 클래스를 추상 클래스로 만들고 싶다면 클래스에 순수 가상 소멸자를 선언하면 됩니다.
1 2 3 4 | class AWOV { public: virtual ~AWOV() = 0; // 순수 가상 소멸자를 선언합니다. }; | cs |
AWOV 클래스는 순수 가상 함수를 갖고 있으므로, 우선 추상 클래스입니다. 동시에 이 순수 가상 함수가 가상 소멸자이므로, 앞에서 말한 소멸자 호출 문제로 고민할 필요가 없습니다. 그런데 이 순수 가상 소멸자의 정의를 두지 않으면 안 됩니다.
AWOV::~AWOV() {} // 순수 가상 소멸자의 정의
소멸자는 상속 계통 구조에서 가장 말단에 있는 파생 클래스의 소멸자가 먼저 호출되는 것을 시작으로, 기본 클래스 쪽으로 거쳐 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출됩니다.
컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로, 잊지 말고 이 함수의 본문을 준비해 두어야 합니다.
기본 클래스의 손에 가상 소멸자를 쥐어 주자는 규칙은 다형성(polymorphic)을 가진 기본 클래스, 그러니까 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용됩니다. 표준 string 타입이나 STL 컨테이너 타입처럼 다형성의 흔적조차 볼 수 없는 클래스도 있습니다. 한편, 기본 클래스로는 쓰일 수 있지만 다형성은 갖지 않도록 설계된 클래스도 있는데, 이런 클래스(예를 들면 Uncopyable과 표준 라이브러리의 input_iterator_tag)는 기본 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않습니다. 이런 이유 때문에 이들에게서 가상 소멸자를 볼 수 없습니다.
다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다.
즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 합니다.
기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다.