07: 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
class TimeKeeper
{
public:
TimeKeeper();
~TimeKeeper();
};
class AtomicClock : public TimeKeeper {};
class WaterClock : public TimeKeeper {};
class WristWatch : public TimeKeeper {};
TimeKeeper* getTimeKeeper(); // TimeKeeper에서 파생된 클래스를
// 통해 동적으로 할당된 객체의
// 포인터를 반환
TimeKeeper* ptk = getTimeKeeper();
...
delete ptk;
TimeKeeper에서 파생된 클래스의 객체에 대한 포인터를 얻는 팩토리 함수인 getTimeKeeper를 만들었다. 팩토리 함수의 기존 규약을 그대로 따라간다면 반환되는 객체는 힙에 있게 되므로, 메모리 및 기타 자원의 누출을 막기 위해 해당 객체를 적절히 삭제해야 한다.
문제는 getTimeKeeper 함수가 반환하는 포인터가 파생 클래스 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터를 통해 삭제된다는 점, 그리고 결정적으로 기본 클래스에 들어 있는 소멸자가 비가상 소멸자(non-virtual destructor)라는 점이다. C++의 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있다면 프로그램 동작은 미정의 사항이다. 대개 그 객체의 파생 클래스 부분이 소멸되지 않게 된다. 기본 클래스 부분은 제대로 소멸되어 부분 소멸 객체가 되고 자원은 새어나가고, 자료구조가 오염된다.
기본 클래스에 가상 소멸자를 선물하자
기본 클래스에 가상 소멸자를 선물하면 파생 클래스 객체를 기본 클래스 포인터로 삭제할 때, 우리가 원하는 쪽으로 동작하게 될 것이다.
class TimeKeeper
{
public:
TimeKeeper();
virtual ~TimeKeeper();
};
TimeKeeper* getTimeKeeper();
TimeKeeper* ptk = getTimeKeeper();
...
delete ptk;
가상 함수를 하나라도 가지는 클래스는 가상 소멸자를 가져야 하는게 대부분 맞다.
기본 클래스로 의도하지 않은(다형성 X) 클래스에는 가상 소멸자를 선언하지 말자
기본 클래스로 의도하지 않은 클래스에 대해 소멸자를 가상으로 선언하는 것은 좋지 않다.
class Point // 2D 공간의 한 점
{
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
int가 32비트를 차지한다면, 이 Point 객체는 64비트 레지스터에 딱 맞게 들어갈 수 있다. 그리고 C나 포트란(FORTRAN) 등의 다른 언어로 작성된 함수에 넘길 일이 생길 때도 64비트 크기의 자료로 넘어갈 것이다. 그런데 Point 클래스의 소멸자가 가상 소멸자로 만들어 버리는 순간, 사정이 변한다.
가상 함수를 C++에서 구현하려면 클래스에 별도의 자료구조가 하나 들어가야 한다. 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는데 사용되며 vptr(virtual table pointr)로 불린다. vptr은 가상 함수의 주소, 즉 포인터들의 배열을 가리키고 있는데 이 배열은 vtbl(virtual table)이라고 불린다. 가상 함수를 하나라도 갖고 있는 클래스느 반드시 그와 관련된 vtbl을 갖고 있다.
중요한 것은 Point 클래스에 가상 함수가 들어가게 되면 Point 타입 객체의 크기가 커진다. 64비트 아키텍처에서 포인터 크기는 64비트이므로 크기가 무려 100%커진다. 이젠 64비트 레지스터에 들어갈 수 없게 되며 다른 언어로 선언된 동일한 자료구조와도 호환성이 없어진다.
가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어있는 경우에만 한정하자.
가상 함수가 전혀 없는데도 비가상 소멸자에 뒤통수 맞는 경우
class SpecialString : public std::string {};
SpecialString* pss = new SpecialString("Impending Doom");
std::string* ps;
...
ps = pss;
...
delete ps; // 자원 누출
// SpecialString 소멸자 없음
std::string에는 가상 소멸자가 없다. std::string에서 파생된 클래스인 SpecialString의 포인터를 string의 포인터로 어떻게든 변환한 후에 그 string 포인터에 delete를 적용하면 '미정의 동작' 급행열차다. SpecialString의 소멸자가 호출되지 않기 때문이다.
이런 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 전부 적용된다. STL 컨테이너 타입 전부가 바로 여기에 속한다.
순수(pure) 가상 소멸자를 쓰면 편리하다
추상 클래스는 본래 기본 클래스로 쓰일 목적으로 만들어진 것이고, 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 한다. 한편 순수 가상 함수가 있으면 바로 추상 클래스가 된다. 즉, 추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하는 것이다!
class AWOV
{
public:
virtual ~AWOV() = 0; // 순수 가상 소멸자
};
AWOV::~AWOV() {} // 순수 가상 소멸자 정의
이제 앞에서 말한 소멸자 호출 문제로 고민할 필요가 없다. 하지만 이 순수 가상 소멸자의 정으를 두지 않으면 안 된다.
소멸자가 동작하는 순서는 이렇다.
- 상속 계통 구조에서 가장 말단에 있는 파생 클래스의 소멸자가 먼저 호출
- 기본 클래스 쪽으로 거쳐 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출
컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로, 잊지 말고 이 함수의 본문을 준비해 두어야 한다. 이 부분을 잊는다면 링커 에러가 발생한다.
기본 클래스의 손에 가상 소멸자를 쥐어 주자는 규칙은 다형성을 가진 기본 클래스, 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용된다.
이것만은 잊지 말자!
- 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다! 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있다면, 이 클래스의 소멸자도 가상 소멸자이어야 한다.
- 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 09: 객체 생성 및 소멸 과정 중에는 절대로 가상 함수를 호출하지 말자 (1) | 2023.10.05 |
---|---|
[Effective C++] 08: 예외가 소멸자를 떠나지 못하도록 붙들어 놓자 (1) | 2023.10.05 |
[Effective C++] 06: 컴파일러가 만들어낸 함수가 필요 없으면 확실히 이들의 사용을 금해 버리자 (0) | 2023.10.02 |
[Effective C++] 05: C++가 은근슬쩍 만들어 호출해 버리는 함수들에 촉각을 세우자 (1) | 2023.10.02 |
[Effective C++] 04: 객체를 사용하기 전에 반드시 그 객체를 초기화하자 (0) | 2023.09.30 |