- 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다.
어떤 클래스가 가상 함수를 하나라도 갖고 있으면 이 클래스의 소멸자도 가상 소멸자이어야 한다. - 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.
다형성과 가상 소멸자
다형성을 갖는 클래스를 사용하는 상황을 가정해 보자.
시간 기록을 유지하는 클래스를 제작할 것인데 기본 클래스를 만들고 용도에 따라 파생시키는 구조를 설계한 상황이다.
class TimeKeeper {
public:
TimeKeeper();
~TimeKeeper();
...
};
class AtomicClocK: public TimeKeeper { ... };
class WaterClocK: public TimeKeeper { ... };
class WristClocK: public TimeKeeper { ... };
사용자들은 시간 계산이 어떻게 이루어지는지에는 관심이 없고 간편하게 사용하고 싶어 할 것이다.
그렇기 때문에 시간기록 객체에 대한 포인터를 주는 용도로 팩토리 함수를 만들어 사용한다고 가정하자.
(팩토리 함수: 새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수)
TimeKeeper* getTimeKeeper(); // TimeKeeper에서 파생된 클래스의 포인터 반환
팩토리 함수의 규약에 따르면 getTimeKeeper 함수에서 반환되는 객체는 힙(Heap)에 있게 되므로, 메모리 및 자원의 누출을 막기 위해 해당 객체를 삭제해야 한다. (팩토리 함수의 인터페이스를 수정하여 에러를 방지할 수도 있다.)
TimeKeeper *ptk = getTImeKeeper(); // 객체 획득
... // 사용
delete ptk; // 삭제
getTimeKeeper 함수가 반환하는 포인터가 파생 클래스 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터를 통해 삭제된다는 점, 기본 클래스에 들어 있는 소멸자가 비가상 소멸자라는 점에서 문제가 발생할 수 있다.
C++의 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이라고 되어있다. (파생 클래스 부분이 삭제되지 않음)
이러한 문제를 없애기 위해서는 기본 클래스의 소멸자를 가상 소멸자로 만들면 된다.
class TimeKeeper {
public:
TimeKeeper();
virtual ~TimeKeeper();
...
};
TimeKeeper *ptk = getTimeKeeper();
...
delete ptk;
소멸자 외에도 가상 멤버 함수들이 있을 수 있다. 파생 클래스를 구현할 때 해당 함수를 역할에 따라 맞추는 작업을 허용한다는 의미이다. 가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 하는 게 대부분 맞다.
하지만, 기본 클래스로 의도하지 않은 클래스에 대해 소멸자를 가상으로 선언하는 것은 좋지 않다.
class Point {
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
int가 32비트를 차지한다고 가정하면, 이 Point 객체는 64비트 레지스터에 딱 들어갈 수 있다.
그런데 Point 클래스의 소멸자가 가상 소멸자로 만들어지는 순간 그렇지 않게 된다.
가상 함수를 C++에서 구현하려면 클래스에 별도로 자료구조가 하나 들어간다.
이 자료구조는 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는 데 쓰이는 정보인데, 실제로는 포인터의 형태를 취하는 것이 대부분이고 vptr(가상 함수 테이블 포인터)라고 불린다.
vptr은 가상 함수의 주소(포인터들의 배열)를 가리키고 있으며 가상 함수 테이블 포인터의 배열은 vtbl(가상 함수 테이블)이라고 불린다. 어떤 객체에 대해 어떤 가상함수가 호출되려고 하면, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정된다.
위 설명과 같이 Point클래스에 가상 함수가 들어가게 되면 Point 타입 객체의 크기가 커진다.
다른 언어로 선언된 자료구조와도 호환성이 없어진다. (데이터 배치를 똑같게 한다고 vptr을 만들 수 없다)
즉, 무조건적으로 소멸자를 전부 virtual로 선언해서는 안된다.
가상 소멸자를 선언하는 것은 그 클래스에 가상함수가 하나라도 들어 있는 경우에만 해야 한다.
가상 함수가 없는데 기본 클래스로 설정하여 파생시키면 다음과 같은 문제가 발생한다.
class SpecialString: public std::string{
...
};
SpecialString *pss = new SpecialString("Impending Doom");
std::string *ps;
...
ps = pss; // SpecialString* -> std::string*
...
delete ps; // 미정의 동작(SpecialString 소멸자 호출X)
이 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 전부 적용된다.
STL 컨테이너 타입(vector, list, set, tr1::unordered_map 등) 전부가 여기에 속한다.
비가상 소멸자를 가진 표준 컨테이너 등의 클래스를 써서 새로운 클래스를 만들 수 없다는 뜻이다.
순수 가상 소멸자
순수 가상 함수는 함수의 정의가 이뤄지지 않고 함수만 선언한 것이다.
순수 가상 함수는 파생 클래스에게 인터페이스를 전달할 목적으로 사용된다.
순수 가상 함수는 해당 클래스를 추상 클래스로 만든다. (추상 클래스: 그 자체로는 인스턴스 생성 불가)
하지만 어떤 클래스가 추상 클래스였으면 좋겠는데 마땅히 넣을 만한 순수 가상 함수가 없을 때도 생긴다.
추상 클래스는 기본 클래스로 쓰일 목적으로 만들어진 것이고, 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 한다. 한편 순수 가상 함수가 있으면 추상 클래스가 된다.
종합하면 추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하는 것이다.
class AWOV { // AWOV = "Abstract w/o Virtuals"
public:
virtual ~AWOV() = 0; // 순수 가상 소멸자 선언
};
AWOV 클래스는 순수 가상 함수를 갖고 있으므로 추상 클래스이다. 동시에 순수 가상 함수가 가상 소멸자이므로 소멸자 호출 문제를 고민할 필요가 없다.
소멸자가 동작하는 순서는 상속 계통 구조에서 가장 말단에 있는 파생 클래스의 소멸자가 먼저 호출되며 기본 클래스 쪽으로 거쳐 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출된다. 컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로 잊지 말고 이 함수의 본문을 준비해 두어야 한다.
기본 클래스에 가상 소멸자를 넣는 규칙은 다형성을 가진 기본 클래스에만 적용된다.
(기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 하는 설계된 구조)
모든 기본 클래스가 다형성을 갖도록 설계된 것은 아니다. 또한, 기본 클래스로 쓰일 수 있지만 다형성은 갖지 않도록 설계된 클래스도 있다. 이런 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않는다.