7.다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
이 책에서 기본 클래스는 아마도 부모 클래스를 의미하는 것 같다. 파생 클라스는 자식 클래스 일것이다.
만약 시간 기록하는 코드가 있고 이 코드를 활용하여 용도에 맞게 파생 시킨다고 생각해 보자.
class TimeKeeper
{
public:
TimeKeeper();
~TimeKeeper();
};
class AtomicClock : public TimeKeeper { }; //TimeKeeper을 부모로 하는 자식 클래스
class WaterClock : public TimeKeeper { }; //TimeKeeper을 부모로 하는 자식 클래스
TimeKeeper를 상속받는 클래스는 시간 정보에 접근하고 싶어 한다. 이때 시간 기록 객체에 대한 포인터를 손에 넣는 용도로 팩토리 함수(factory function)(*주 1)를 만들어 두면 좋을 것이다.
TimeKeeper* GetTimeKeeper(); //파생된 클라스를 통해 동적 할당된 객체의 포인터 반환
동적 할당된 객체는 힙에 저장되므로 사용자가 명시적으로 삭제해 줘야 한다.
여기서 문제가 발생한다.
1.GetTimeKeeper함수가 반환하는 포인터가 파생 클래스 객체에 대한 포인터라는 점
2.이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터(TimeKeeper의 포인터)를 통해 삭제된다는 점
3.기본 클래스에 들어 있는 소멸자가 비가상 소멸자라는 점
C++의 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이라고 한다.
간단하게 위의 말을 정리해 보자면 기본 클래스인 TimeKeeper은 소멸자에 의해 삭제 되지만 TimeKeeper를 상속중인 AtomicClock이나 WaterClock의 파생 클래스의 고유 부분은 소멸자도 실행되지 않아 메모리누수가 발생한다.
고유 부분이라 하면 멤버 변수나 멤버 함수를 의미힌다.
기본 클래스에서 가상 소멸자를 선언하면 위 문제를 해결할 수 있다.
class TimeKeeper
{
public:
TimeKeeper();
virtual ~TimeKeeper(); //virtual 선언
};
기본 클래스에 소멸자 외에도 가상 멤버 함수들이 필요로 할 것이다. 파생 클래스를 구현할 때 해당 파생 클래스의 역할에 맞게 작업을 하겠다는 뜻이다(34장 참고).(다형성의 의미)
만약 가상 소멸자를 갖고 있지 않은 클래스를 만나면 그 클래스는 기본 클래스로 쓰일 의지를 상실한 것이구나 라고 생각하면 된다(누군가에게 상속시킬 클래스가 아니구나 라고 생각하면 된다).
반대로 부모 클래스로 사용할 클래스가 아닌데 소멸자를 가상으로 하는 것도 정신나간 것이다.
class Point
{
public:
Point(int xCoord, int yCoord);
~Point();
private:
int x, y;
};
int가 32bit를 차지한다고 가정하면, 이 Point 객체는 64bit 레지스터에 딱 맞게 들어갈 수 있다.
하지만 Point클래스의 소멸자가 가상 소멸자로 만들어지는 순간 사정이 변하기 시작한다. 가상 함수를 C++에서 구현하면 클래스에 별도로 자료구조가 추가 되는데, 이 자료구존느 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지 결정하는데 쓰이고, 실제로 포인터의 형태를 취하는 것이 대부분이고, 대개 가상 함수 테이블 포인터(vptr : virtual table pointer)라고 불린다. vptr은 가상 함수의 주소, 즉 포인터들의 배열을 가리키고 있으며 이 가상 함수 테이블 포인터의 배열은 가상 함수 테이블(vtbl : virtual table)이라 부른다.
가상 함수를 하나라도 갖고 있으면 클래스는 반드시 vtbl을 갖고 있다. 어떤 객체에 대해 어떤 가상 함수가 호출되면, 호출되는 실제 함수는 그 객체의 vptr이 카리키는 vtbl에 따라 결정되어 vtbl에 있는 함수 포인터로 연결되는 것이다.
결론적으로 Point클래스에 가상 함수가 들어가게 되면 Point클래스 객체의 크기가 커진다는 것이다.
OS 환경이 32bit라면 64bit(int 2개)에서 96bit(int 2개 + vptr 1개)가 될 것이고, 64bit환경이라면 64bit(int 2개)에서 128bit(int 2개 + vptr 1개)로 커질것이다.
"가상 함수가 하나라도 들어 있는 경우에만 가상 소멸자로 선언한다." 라고 정리하면 된다.
가상 함수가 전혀 없는데 비가상 소멸자 때문에 뒤통수 맞는 경우가 존재한다.
class SpecialString : public : std::string { };
이런 식으로 코드를 짠다는 것은 생각해 본적이 없지만, 요즘 STL구현을 해보면서 이런 생각을 할 법도 하다 라는 생각은 든다.
string은 가상 함수를 갖고 있지 않다. SpecialString의 포인터를 string의 포인터로 변환한 후 그 string포인터에 delete를 적용하면 그 순간 부터 앞서 말했던 오류가 발생할 것이다.
SpecialString* spstr = new SpecialString("ABCD");
std::string* str;
str = spstr; // SpecialString* -> std::string*
delete str; //spstr의 고유 부분이 삭제 되지 않는다.
//string의 소멸자만 호출되고 SpecialString의 소멸자는 호출되지 않기 때문
이 현상은 가상 소멸자가 없는 클래스면 어떤 것이든 전부 적용된다.
vector, list, set unordered_map등 STL컨테이너 타입 전부다 여기에 속한다(54장 참고).
자바의 final 클래스, C#의 sealed 클래스와 같은 파생 방지 매커니즘이 C++에 없어 조심해야한다.
순수 가상 소멸자
순수 가상 함수는 해당 클래스를 추상 클래스(그 자체로 인스턴스(객체)를 못만드는 클래스)로 만든다.
어떤 클래스가 추상 클래스였으면 좋겠는데 마땅히 넣을 만한 순수 가상 함수가 때가 생기기 마련인데, 이때 사용할 수 있다. 추상 클래스는 기본 클래스로 쓰일 목적으로 만들어진 것이고, 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 한다. 여기에 순수 가상 함수가 있으면 바로 추상 클래스가 된다.
class AWOV
{
public:
virtual ~AWOV() = 0; //순수 가상 소멸자 선언
};
AWOV클래스는 순수 가상 함수를 갖고 있으므로, 추상 클래스 조건을 만족한다. 또한 가상 소멸자를 갖고 있으므로, 파생 클래스의 소멸자 호출 문제로 고민할 필요가 없다.
하지만 문제가 있는데, 순수 가상 소멸자의 정의를 해야 한다는 것이다.
AWOV::~AWOV() {} //순수 가상 소멸자의 정의
소멸자가 동작하는 순서를 보면
1.상속 계통에서 가장 말단에 있는 파생 클래스의 소멸자가 호출
2.기본 클래스 쪽으로 거쳐 올라가면서 기본 클래스의 소멸자가 하나씩 호출
3.컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것
만약 순수 가상 소멸자를 정의하지 않았다면 링커 에러가 발생할 것이다.
이것만은 잊지 말자
다형성을 가진 기본 클래스(부모 클래스)에는 반드시 가상 소멸자를 선언하자
어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 소멸자는 가상 소멸자여야 한다.
기본 클래스(부모 클래스)로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에 가상 소멸자를 선언하지 말자
(*주 1) 팩토리 함수(factory function)
팩토리 함수, 팩토리 메소드, 팩토리 패턴이라 불린다. 객체를 생성하기 위한 인터페이스를 제공하는데, 어떤 클래스의 인스턴스를 만들지는 파생 클래스에서 결정한다. 즉, 클래스의 인스턴스를 만드는 일은 파생 클래스가 한다는 말.
팩토리 패턴은 추상클래스에서 객체를 만드는 인터페이스만 제공한다.
이름도 팩토리니 공장으로 비유를 해보자면 기본 클래스는 오븐이고 파생 클래스는 재료라고 생각해보자.
재료가 초콜릿이면 초코빵, 크림이면 크림빵 이런식이라고 생각하면 된다.