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

Jangmanbo·2023년 2월 21일
0

Effective C++

목록 보기
7/33
class TimeKeelper {
public:
	TimeKeeper();
	~TimeKeeper();
    ...
};

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

소프트웨어에서 시간을 기록해야 하는 일이 종종 발생한다.
따라서 시간 관리를 담당하는 기본 클래스(TimeKeeper)를 만들어 이를 용도에 따라 적절히 파생하여 설계하는 것이 좋다.

TimeKeeper* getTimeKeeper();

사용자는 시간 계산이 어떻게 되는지에 대해서는 궁금하지 않고, 단지 시간 정보에 접근하고 싶을 뿐이다. 그러므로 시간 기록 객체에 대한 포인터를 얻기 위한 팩토리 함수를 선언한다.

팩토리 함수
새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수
참고:팩토리함수

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

팩토리 함수의 기본 규약에 따라 getTimeKeeper 함수에서 반환되는 객체는 힙에 존재하게 되므로, 메모리 및 기타 자원의 누출을 막기 위해 해당 객체를 적절히 delete해야 한다.


getTimeKeeper()의 문제점

  • 반환값이 파생 클래스(AtomicClock) 객체에 대한 포인터
  • 포인터가 가리키는 객체가 삭제될 때 기본 클래스 포인터를 통해 삭제됨
  • 기본 클래스에 들어있는 소멸자가 비가상 소멸자

C++ 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어있으면 프로그램 동작은 미정의 사항이다.

즉, getTimeKeeper 함수에서 포인터를 통해 날아오는 AtomicClock 객체는 기본 클래스 포인터를 통해 삭제될 때 TimeKeeper를 제외한 부분(AtomicClock 클래스에서 정의한 멤버들)은 해제되지 않으며, AtomicClock의 소멸자도 실행되지 않는다.
그러나 기본 클래스(TimeKeeper)의 소멸 과정은 제대로 끝나기 때문에 반만 소멸되는 문제가 생기게 된다.


해결법 : 가상 소멸자

class TimeKeelper {
public:
	TimeKeeper();
	virtual ~TimeKeeper();	// 가상 소멸자!
    ...
};

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

기본 클래스의 소멸자를 virtual로 선언하면 모든 문제를 해결할 수 있다.
가상 소멸자로 선언하면 파생 클래스 객체를 기본 클래스 포인터로 삭제할 때 파생 클래스의 멤버들까지 소멸될 것이다.

TimeKeeper와 같은 기본 클래스에는 대개 소멸자 외에도 가상 멤버 함수들이 있기 마련이다.
대부분 이렇게 가상 함수를 하나라도 가진 클래스는 가상 소멸자를 가져야 한다.
어떠한 클래스가 가상 소멸자를 갖고 있지 않다면, 기본 클래스로 쓰이지 않는다고 생각해도 된다.


그렇다고 가상 소멸자를 남용해서는 안된다.
다음은 기본 클래스로 의도하지 않은 클래스에 가상 소멸자를 선언했을 때의 문제를 보여주는 예시이다.

// 2D 공간의 점을 나타내는 클래스
class Point {
public:
	Point(int xCoord, int yCoord);
    virtual ~Point();	// 가상 소멸자!
    
private:
	int x, y;	// int는 32비트라면 Point 객체는 64비트?
}

비가상 소멸자를 가질 경우 int는 32비트라면 Point 객체는 64비트일 것이다. 또한 C나 포트란 등 다른 언어로 작성된 함수에 넘길 때도 64비트 크기의 자료로 넘어갈 것이다.

그러나 가상 소멸자가 선언되었다면, 가상 함수를 호출하기 위한 자료구조가 추가로 필요하다.
대개 포인터로 존재하며 vptr라고 불린다.

vptr(virtual table pointer) : vtbl을 가리키는 포인터

vtbl(virtual table) : 가상 함수 포인터의 배열

가상 함수를 하나라도 갖고 있는 클래스는 반드시 그와 관련된 vtbl을 가진다.
만약 어떤 객체의 가상 함수가 호출되려고 하면, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정된다. (vtbl에 있는 함수 포인터들 중 적절한 것이 연결된다.)

vptr과 vtbl에 대해 더 자세히 알고 싶다면

가상함수가 존재하는 클래스 단점

1. 객체의 크기 증가

프로그램 실행 환경Point 객체 크기
32비트 아키텍처96비트 (int 2개 + vptr 1개)
64비트 아키텍처128비트 (int 2개 + vptr 1개)

가상 함수가 존재하지 않는다면 vptr이 없으므로 Point 객체의 크기는 64비트이다.
(포인터의 크기는 32비트 아키텍처면 32비트, 64비트 아키텍처면 64비트이다.)

2. 다른 언어와의 호환성 X

C 등의 다른 언어로 선언된 동일한 자료구조와의 호환성이 없어진다.
다른 언어로 Point와 겉보기가 똑같은 데이터 배치를 써서 선언하더라도, vptr만은 구현할 수 없기 때문이다.


정리하자면,
클래스에 가상 함수가 하나라도 들어있는 경우에만 가상 소멸자를 선언하자!


가상 함수가 없는 클래스여도 비가상 소멸자로 인해 미정의 동작이 발생할 수 있다.

class SpecialString: public std::string {
	...
};

SpecialString *pss = new SpecialString("Impending Doom");

std::string *ps;
...
ps = pss;
...
delete ps;	// delete 시도

가상 함수가 없는 string 클래스를 기본 클래스로 잡고, SpecialString를 선언한다.
이후 기본 클래스 포인터를 통해 SpecialString 객체의 delete를 시도하면 미정의 동작이 발생한다.
SpecialString의 소멸자가 호출되지 않아 SpecialString 부분의 자원이 누출되기 때문이다.

이러한 현상은 가상 소멸자가 없는 모든 클래스에 적용된다.
참고로 STL 컨테이너 타입은 모두 비가상 소멸자를 가지므로, STL 컨테이너 타입을 기본 클래스로 사용하여 유저 클래스를 만드는 일은 자제해야 한다.

정리하자면,
비가상 소멸자를 가지는 클래스를 상속받지 말자!


순수 가상 소멸자

순수 가상 함수
해당 클래스를 추상 클래스(그 자체로는 인스턴스를 만들 수 없음)로 만듦

어떤 클래스를 추상 클래스로 만들고 싶은데 딱히 순수 가상 함수로 만들만한 함수가 없을 수 있다.

  1. 추상 클래스는 기본 클래스로 쓰일 목적으로 만들어진 것이며, 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야 한다.
  2. 순수 가상 함수를 가진 클래스는 추상 클래스가 된다.

=> 소멸자를 순수 가상 함수, 즉 순수 가상 소멸자를 선언하자!

class AMOV {
public:
	virtual ~AMOV() = 0;		// 순수 가상 소멸자 선언
}

AWOV 클래스는 순수 가상 함수를 가지므로 추상 클래스이다.
또한 가상 소멸자를 가지므로 앞서 언급한 소멸자 호출에 대한 문제도 걱정할 필요가 없다.

단, 반드시 순수 가상 소멸자를 정의해야 한다.

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

상속 계통 구조가 AMOV->AMOV2->AMOV3라면, 소멸자는 AMOV3->AMOV2->AMOV 순서대로 기본 클래스 쪽으로 거쳐 올라가면서 호출된다.
따라서 컴파일러가 ~AMOV()의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이므로, 소멸자를 반드시 정의해야 한다.

0개의 댓글