[Effective C++] 7. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

Effective C++ 제 3판 - Chapter 2 - 3


아래의 기본 클래스에서 파생된 클래스들이 있다고 칩시다.

1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper
{
public:
    TimeKeeper();
    ~TimeKepper();
    ...
};

class AtomicClock: public TimeKeeper {...};
class WaterClock: public TimeKeeper {...};
class WristWatch: public TimeKeeper {...};


이 클래스의 사용자들은 시간 정보에 접근하고 싶습니다. 물론 시간 계산이 어떻게 되는지는 신경쓰고 싶지 않겠지요. 어떤 시간기록 인스턴스에 대한 포인터를 가져오는 용도로 팩토리 함수를 만들어 놓읍시다.

1
TimeKeeper* getTimeKeeper();


팩토리 함수의 기존 규약을 그대로 따라간다면, getTimeKeeper 함수에서 반환되는 객체는 힙 영역에 존재하므로, 결국 메모리 및 기타 자원의 누출을 막기 위해 해당 객체를 적절히 삭제(delete)해야 합니다. 잘 이해가 가지 않으신다구요? 네 그럼 팩토리 함수와 메모리 영역에 대해 먼저 짚고 넘어 가도록 해봅시다. (저도 까먹어서 복습하는 겁니다 ㅎㅎ.. 모른다고 자책하지 마세요)

메모리 구조

OS에서 프로세스에 메모리를 할당할 때 기능에 따라 4가지 영역으로 분류하여 메모리를 할당합니다. 각 영역은 아래의 4가지 영역이고, 각 영역이 언제 할당되고 어떤 기능과 연관이 있는지 알아봅시다.

  • 코드 영역 : 코드 영역에는 실행할 프로그램의 코드가 저장되는 영역으로 텍스트 영역이라고도 불립니다. CPU는 코드 영역에 저장된 명령어를 하나씩 가져가 서 처리하게 됩니다.
  • 데이터 영역 : 메모리의 데이터 영역에는 프로그램의 전역 변수와 정적 변수가 저장됩니다. 데이터 영역의 프로그램의 시작과 함께 할당되며 프로그램이 종료하면 소멸합니다.
  • 스택 영역 : 스택 영역은 함수의 호출과 연관되는 지역 변수와 매개 변수가 저장되는 영역입니다. 스택 영역은 함수의 호출과 함께 할당되며, 함수의 호출이 완료되면 소멸합니다. 이름에서 알 수 있듯이 스택 영역은 스택 자료구조의 원리로 동작합니다.
  • 힙 영역 : 힙 영역은 사용자가 직접 관리할 수 있는 그리고 해야하만 하는 영역 입니다. 사용자에 의해 메모리 공간이 동적으로 할당되고 해제됩니다. 즉 런타임에 메모리 영역의 크기가 동적으로 바뀝니다. 사용자가 동적 할당(malloc, new 등)을 하면 힙 영역에 할당 됩니다.

이 정도면, 메모리 영역에 대한 기초 개념으로는 충분한 것 같네요. 사실 억지로 암기하지 않아도 필요할 때마다 찾아보는 것만으로도 충분한 것 같습니다.

팩토리 메소드

자 이번에는 팩토리 메서드 패턴에 대해 공부해볼까요? 팩토리 메소드는 팩토리 디자인 패턴의 구현 방법 중 하나 입니다.

팩토리 디자인 패턴에 대해 모르시는 분들은 제가 포스팅한 팩토리 디자인 패턴에서 확인하고 오도록 합시다 ^_^. 공부를 한적이 있는 분들도 복습 겸 가볍게 보시는 것도 추천합니다. (단, 위의 링크의 예제들은 스마트 포인터를 사용하고 있으므로 팩터리 메소드의 delete가 필요하지 않을 것 입니다.)

가상 소멸자의 필요성


자, 다시 돌아와서 우리는 메모리 및 기타 자원의 누출을 막기 위해 getTimeKeeper에서 반환되는 함수를 적절히 삭제해야 합니다. 하지만 중대한 문제가 있습니다. 바로 getTimeKeeper가 반환하는 포인터가 파생 클래스 객체에 대한 포인터라는 점입니다.

그게 왜 문제가 되냐구요? 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터를 통해 삭제된다는 점, 그리고 결정적으로 기본 클래스에 들어 있는 소멸자가 비가상 소멸자라는 점입니다. c++ 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이라고 되어 있습니다. 그리고 거의 대부분 객체의 파생 클래스 부분이 소멸되지 않게 되지요.

정리하면, getTimeKeeper 함수에서 포인터를 통해 날아오는 AtomicClock 클래스에서 정의된 데이터 멤버들이 삭제되지 않을뿐만 아니라, AtomicClock의 소멸자도 호출되지 않습니다. 즉, 반쪽자리 부분 소멸만 되는 것이고 수많은 문제를 야기하는 원인이 될 것이 분명합니다.

이 문제를 없애는 방법은 지극히 단순 합니다. 기본 클래스의 소멸자를 가상 소멸자로 선언합시다. 기본 클래스의 소멸자 앞에 virtual 키워드 한개를 선물함으로서 파생 클래스 부분까지 모두 소멸이 가능해집니다.

1
2
3
4
5
6
7
8
9
10
11
class TimeKeeper
{
public:
  TimeKeeper();
  virtual ~TimeKeeper();
  ...
};

TimeKeeper *ptk = getTimeKeeper();
...
delete ptk;

TimeKeeper 비슷한 기본 클래스에는 대개 소멸자 외에도 가상 멤버 함수들이 더러 들어 있습니다. TimeKeeper 함수를 역할에 따라 맞추는 작업을 허용한다는 것이지요. 예를 들어, TimeKeeper 클래스는 현재 시각을 알려 주는 getCurrentTime 함수를 가상 함수로 가질 수 있습니다. 이 함수는 여러 파생 클래스에서 다른 의미로 구현되겠지요. 어쨌든, 가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 하는게 대부분 맞습니다.

즉, 가상 소멸자를 가진 함수가 한개도 없을 경우 “아 저 클래스는 기본 클래스로 쓰일 의지를 상실한 것이구나”라고 말입니다. 그리고 입장을 바꾸어 의도하지 않은 기본 클래스에 소멸자를 가상으로 선언하는 것도 좋지 않은 정신 입니다. 어디 한번 같이 볼까요?

기본 클래스에 소멸자를 가상으로 선언하는 것이 항상 좋은 것은 아니다.


자, 다음의 클래스를 같이 한번 보시죠.

1
2
3
4
5
6
7
8
9
class Point
{
public:
  Point(int xCoord, int yCoord);
  ~Point();

private:
  int x, y;
};

int가 32비트를 차지한다고 가정하면, 이 Point 객체는 64비트 레지스터에 딱 맞게 들어갈 수 있겠지요. 그리고 C나 포트란(FORTRAN) 등의 다른 언어로 작성된 함수에 넘길 일이 생길 때도 64비트 크기의 자료로 넘어갈 것 입니다. 그런데 Point 클래스의 소멸자가 가상 소멸자로 만들어지는 순간 사정이 변하기 시작 합니다.

가상 함수를 C++에서 구현하려면 클래스에 별도의 자료구조가 하나 들어가야 합니다. 이 자료구조는 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는데 쓰이는 중요한 정보인데, 실제로는 포인터의 형태를 취하는 것이 대부분이고, 대게 vptr이라는 이름으로 불립니다. 즉, Point 클래스에 가상 함수가 들어가게 되면, Point 타입 객체의 크기가 커진다는 사실입니다.

즉, 프로그램 실행 환경이 32비트 아키텍처라면, 크기가 64비트에서 96비트로 커집니다(int 2개, vprt 1개로 총 4바이트 변수가 3개). 가상 함수 테이블 포인터가 하나 추가됐을 뿐인데 크기가 무려 50%에서 100%까지 커진 것이죠. 64비트 레지스터에 들어가긴 글렀습니다. 게다가 다른 언어로의 호환성도 없이집니다. 왜냐하면 다른 언어로 Point와 겉보기가 같은 객체를 똑같은 데이터 배치를 써서 선언했다고 해도 vptr만큼은 만들 수 없기 때문입니다.

자, 정리하면 어느 경우를 막론하고 소멸자를 전부 virtual로 선언하는 일은 virtual로 절대 선언하지 않는 것만큼이나 편찮은 마인드 입니다. 가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어 있는 경우에만 한정하도록 합시다.

가상 함수를 사용하지 않았는데 비가상 소멸자 때문에 문제가 발생하는 경우


가상 함수가 전혀 없는데도 비가상 소멸자 때문에 문제가 발생하는 경우가 있습니다. 한 예가 표준 string 타입입니다. 이 타입은 가상 함수를 가지고 있지 않지만, 전후 사정을 무시하고 이 타입을 기본 클래스로 잡아버리는 일부 몰지각한 프로그래머들이 가끔 있습니다. 아래의 예제를 한번 보시죠.

1
2
3
4
class SpecialString:public std::string
{
  ...
};

그냥 보기엔 아무 문제가 없어보이지만, 이것을 사용한 응용 프로그램 어딘가에서 SpecialString의 포인터를 string의 포인터로 어떻게든 변환한 후에 그 string 포인터에 delete를 적용하면 그 순간부터 ‘미정의 동작’행 급행열차를 갈아타시게 됩니다.

1
2
3
4
5
6
7
SpecialString *pss = new SpecialString("Impending Doom"); // SpecialString* => std::string*

std::string *ps;
...
ps = pss;
...
delete ps;

이 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 적용 됩니다. 그런데 가상 소멸자가 없는 클래스는 우리 주변에 아주 가까이 있습니다. STL 컨테이너 타입 전부가 바로 여기에 속합니다, 표준 컨테이너 등의 클래스를 써서 쓸모 있는 나만의 클래스를 만들고 싶었던 분들은 명심하시기 바랍니다.

순수 가상 소멸자


경우에 따라서는 순수 가상 소멸자를 두면 편리하게 쓸 수도 있습니다. 예를 들어 어떤 클래스가 추상 클래스였으면 좋겠는데, 마땅히 넣을만한 가상함수가 없다면 어떻게 하는 것이 좋을까요?

추상 클래스는 본래 기본 클래스로 쓰여질 목적으로 만들어진 것이고, 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 합니다. 한편 순수 가상 함수가 있으면 바로 추상 클래스가 되지요. 종합하면 추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하면 마땅히 넣을만한 가상함수가 없을 때 추상 함수를 쉽게 만들 수 있습니다. 다음 예제를 같이 봅시다.

1
2
3
4
5
class AWOV             // AWOV = "Abstract w/o Virtuals"
{
public:
  virtual ~AWOV() = 0; // 순수 가상 소멸자를 선언합니다.
};

AWOV 클래스는 순수 가상 함수를 갖고 있으므로, 우선 추상 클래스 입니다. 동시에 이 순수 가상 함수가 가상 소멸자이므로 앞서 말한 소멸자 호출 문제로 고민할 필요도 없지요. 단 이 순수 가상 소멸자의 정의를 두지 않으면 안됩니다.

1
AWOV::~AWOV() {} // 순수 가상 함수 소멸자 정의

소멸자가 동작하는 순서는 이렇습니다. 상속 계통 구조의 가장 밑단의 파생클래스의 소멸자가 먼저 호출되는 것을 시작으로, 상위 계층으로 올라옵니다. 올라오면서 각 클래스의 소멸자가 하나씩 호출 되지요. 컴파일러는 AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로 잊지 말고 이 함수의 정의를 준비해두어야 한다는 것 입니다. 그렇지 않다면 링크 에러를 만나게 되겠지요.

가상 소멸자를 사용하는 규칙

결국 정리하자면, 기본 클래스에 가상 소멸자를 사용하는 규칙은 다형성을 가진 기본 클래스, 그러니까 기본클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용된다는 것 입니다. 하지만 모든 기본 클래스가 다형성을 갖도록 설계된 것은 아니지요. 심지어 STL 컨테이너 타입은 기본 클래스는커녕 다형성의 흔적조차 볼 수 없습니다. 한편, 기본 클래스로는 쓰일 수 있지만 다형성은 갖지 않도록 설계된 클래스도 있지요 (e.g noncopyable class, 표준 라이브러리의 input_iterator_tag). 이들에게서 가상 소멸자를 사용할 수 없는 것은 바로 기본 클래스의 인터페이스를 통해 파생 클래스의 객체 조작이 불가능하기 때문이지요.

End Note


  • 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 합니다.
  • 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 합니다.

Reference


Updated:

Leave a comment