본문 바로가기
프로그래밍/C & C++

[C++] 가상 소멸자

by 별준 2021. 7. 28.

Reference

  • Effective C++ (항목 7)

Contents

  • 다형성을 가진 기본(base) 클래스에서의 소멸자
  • 가상 소멸자
  • 가상 함수 테이블

아래와 같은 TimeKeeper라는 기본(base) 클래스가 있고, 이 클래스를 상속받는 AtomicClock / WaterClock / WristWatch라는 파생(Derived) 클래스가 있다고 가정해봅시다.

그리고 이 객체에 대한 포인터를 얻는 용도로 팩토리 함수(factory function, 새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수)도 생성합니다.

(편의상 AtomicClock의 내용만 채우고, 나머지는 생략하였습니다. 따라서 getTimeKeeper 함수도 AtomicClock의 인스턴스를 반환하도록 구현했습니다.)

class TimeKeeper {
public:
	TimeKeeper() {
		std::cout << "TimeKeeper Constructor\n";
	}
	~TimeKeeper() {
		std::cout << "TimeKeeper Destructor\n";
	}
};

class AtomicClock : public TimeKeeper {
public:
	AtomicClock() {
		std::cout << "AtomicClock Constructor\n";
	}
	~AtomicClock() {
		std::cout << "AtomicClock Destructor\n";
	}
};

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

TimeKeeper* getTimeKeeper() {
	return new AtomicClock();
}

 

getTimeKeeper 함수에서 반환되는 객체는 Heap에 존재하게 되고, 결국 메모리 및 기타 자원의 누출을 막기 위해서는 해당 객체를 적절하게 삭제(delete)해야 합니다. 아래와 같이 main 문을 구현해보도록 하죠.

int main()
{
	TimeKeeper* ptk = getTimeKeeper();	// 객체 생성

	delete ptk;		// 사용이 끝난 객체 해제

	return 0;
}

이러한 상황에서 어떤 문제가 발생하게 될까요 ?

위 코드를 실행해보면 다음과 같은 출력을 볼 수 있습니다.

파생 클래스인 AtomiClock의 소멸자가 호출되지 않았습니다.

 

여기서 문제는 getTimeKeeper 함수가 반환하는 포인터가 파생 클래스(AtomicClock) 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터(TimeKeeper* 포인터)를 통해서 삭제된다는 점, 그리고 결정적으로 기본 클래스(TimeKeeper)의 소멸자가 비가상 소멸자(non-virtual destructor)라는 점입니다.

 

C++ 규정에 의하면, 기본 클래스 포인터를 통해 파생 클래스 객체가 삭제될 때 그 기본 클래스에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이라고 되어 있습니다. 대부분 그 객체의 파생 클래스 부분이 소멸되지 않게 됩니다. 따라서, 위의 경우에서도 파생 클래스의 AtomicClock의 소멸자가 호출되지 않았습니다.

그 결과로 기본 클래스 부분은 소멸 과정이 제대로 끝나지만, AtomicClock 부분은 소멸되지 않는 반쪽자리 소멸이고, 메모리 누수가 발생될 수 있습니다.

 

이 문제를 해결하는 방법은 매우 간단합니다. 

바로 기본 클래스의 소멸자로 가상 소멸자로 만들어주면 됩니다. 즉, TimeKeeper의 소멸자에 virtual 키워드만 붙여주면 됩니다.

class TimeKeeper {
public:
	TimeKeeper() {
		std::cout << "TimeKeeper Constructor\n";
	}
	virtual ~TimeKeeper() {
		std::cout << "TimeKeeper Destructor\n";
	}
};

이제 다시 메인문을 실행해보면 아래의 결과를 얻을 수 있습니다.


TimeKeeper와 비슷한 기본(base) 클래스는 소멸자 외에도 가상 멤버 함수를 더 가지기 마련입니다. 예를 들어서 TimeKeeper 클래스는 현재 시각을 알려주는 getCurrentTime 함수를 가상 함수로 가질 수 있는데, 이 함수는 여러 파생 클래스에서 서로 다르게 동작하므로 다르게 구현될 것입니다.

 

만약 가상 소멸자를 가지고 있지 않은 클래스를 본다면, '이 클래스는 base 클래스로 사용되지 않는구나(이 클래스를 상속받는 클래스는 없구나)'라는 의미로 생각하면 됩니다.

 

하지만, 유의해야할 것은 기본 클래스로 읜도하지 않은 클래스, 즉, 상속을 생각하지 않은 클래스에 대해서 소멸자를 가상으로 선언하는 것은 좋지 않은 습관입니다. 예를 들어, 아래의 Point 클래스를 보도록 합시다.

class Point {
public:
	Point(int xCoord, int yCoord);
	~Point();

private:
	int x, y;
};

int가 32비트를 차지한다고 가정한다면, 이 Point 객체는 64비트 레지스터에 딱 맞게 들어갈 수 있습니다. 그리고 C 등의 다른 언어로 작성된 함수에 넘길 일이 생길 때도 64비트 크기의 자료로 넘어갈 것입니다. 

 

하지만, Point 클래스의 소멸자가 가상 소멸자로 만들어지는 순간, 상황이 달라집니다.

가상 함수를 C++에서 구현하려면 클래스에 별도의 자료구조가 하나 추가되어야 합니다. 이 자료구조는 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야 하는지를 결정하는 데 사용되는데, 실제로는 포인터의 형태를 취하는 것이 대부분입니다. 이 포인터는 '가상 함수 테이블 포인터(virtual function table pointer;vptr)'이라는 이름으로 불립니다. 이 가상 함수 테이블 포인터의 배열은 '가상 함수 테이블(virtual function table;vtbl)'이라고 불립니다.

어떤 객체에 대해 어떤 가상 함수가 호출되려고 하면, 호출되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정이 됩니다. 즉, vtbl에 있는 함수 포인터들 중 적절한 것이 연결되어 호출되는 것입니다.

 

가상 함수 호출이 어떻게 구현되는지에 관해서는 일단 넘어가고, 여기서 중요한 것은 Point 클래스에 가상 함수가 존재하면 Point 타입 객체의 크기가 커진다는 것입니다. 프로그램 실행환경이 32비트 아키텍처라면, 크기가 64비트에서 96비트로 커지게 됩니다. 만약 64비트 아키텍처라면, 64비트에서 128비트로 커질 수 있습니다. 

 

또한, C 등의 다른 언어로 선언된 동일한 자료구조와도 호환성이 없어집니다. 왜냐면 다른 언어로 Point와 겉보기가 똑같은 데이터 배치를 써서 선언했다고 해도 vptr만은 어떻게 만들 수 없기 때문입니다. 다른 언어로 작성된 함수에 Point 객체를 전달하고 또 그 함수로부터 전달받을 수 있게 하려면 vptr 부분을 보충해 주어야 하는데, 이 부분은 구현환경에 따라 세부사항이 달라지는 문제이기 때문에 이식성에 대한 기대는 접는 것이 좋습니다.


위 내용을 정리하자면, 어느 경우를 막론하고 소멸자를 전부 virtual로 선언하는 것은 좋지 않은 습관입니다. 

가상 소멸자를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어있는 경우로만 한정해야합니다.

 

가상 함수가 전혀 없는데도 비가상 소멸자 때문에 문제가 발생하는 경우도 있습니다.

한 예가 표준 string 타입입니다. 이 string 타입은 가상 함수를 가지고 있지 않지만, 그 부분을 무시하고 이 타입을 기본 클래스로 사용하는 경우가 가끔 있습니다.

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

int main(void)
{
	SpecialString* pss = new SpecialString("Impending Doom");
    std::string *ps;
    ...
    ps = pss;
    ...
    delete ps;	// 정의되지 않은 동작 발생
					// SpecialString 부분의 소멸자가 호출되지 않음
	return 0;
}

위의 현상은 가상 소멸자가 없는 클래스이면 어떤 것에든 전부 적용됩니다. 

특히 STL 컨테이너 타입은 모두 가상 소멸자가 없기 때문에 STL 컨테이너는 상속받으면 안됩니다.


경우에 따라서는 순수 가상 소멸자를 두면 편리하게 쓸 수도 있습니다. 순수 가상 함수는 해당 클래스를 추상 클래스(abstract class, 그 자체로는 인스턴스를 만들지 못하는 클래스)로 만들게 됩니다. 하지만 어떤 클래스가 추상 클래스였으면 좋겠지만 마땅히 넣을 만한 순수 가상 함수가 업을 때도 종종 생기기 마련입니다.

이런 경우에는 어떻게 하면 좋을까요?

 

추상 클래스는 본래 기본 클래스로 사용할 목적으로 만들어진 클래스이며, 기본 클래스로 사용하려는 클래스는 가상 소멸자를 가져야 합니다. 따라서 추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하면 됩니다.

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

위에서 AWOV 클래스는 순수 가상 함수를 갖고 있으므로, 우선 추상 클래스입니다. 동시에 이 순수 가상 함수가 가상 소멸자이므로 앞에서 말한 소별자 호출 문제로 고민할 필요가 없습니다. 

하지만, 한 가지 문제가 있는데, 이 순수 가상 소멸자의 정의를 하지 않으면 안된다는 것입니다.

class TEMP : public AWOV {
public:
	~TEMP() {
		std::cout << "TEMP destructor\n";
	}
};

int main()
{
	TEMP* temp = new TEMP();
	
	delete temp;

	return 0;
}

(위 클래스를 상속받는 클래스를 임시로 만들어서 인스턴스를 만들고 메모리를 해제해보면 링크에러가 발생합니다.)

 

따라서 아래와 같이 가상 소멸자를 정의해야 합니다.

class AWOV {
public:
	virtual ~AWOV() {};
};

 

소멸자가 동작하는 순서는 다음과 같습니다. 상속 계통 구조에서 가장 밑단에 있는 파생(Derived) 클래스의 소멸자가 먼저 호출되는 것을 시작으로, 기본(base) 클래스 쪽으로 올라가면서 각 기본 클래스의 소멸자가 하나씩 호출됩니다. 

위의 예시에서는 컴파일러는 ~AWOV의 호출 코드를 만들기 위해 파생 클래스의 소멸자를 사용할 것이고 그 다음에 ~AMOV()가 호출됩니다. 따라서 이 함수의 정의를 준비해두어야 합니다.


기본 클래스에 가상 소멸자를 만들어야 한다는 규칙은 다형성(polymorphic)을 가진 기본 클래스, 즉, 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에만 적용됩니다. 본문의 처음에 예제로 본 TimeKeeper가 이 클래스에 속하게 됩니다. AtomicClock이나 WaterClock 객체를 보면 TimeKeeper 포인터만 가지고도 이 객체들을 조작할 수 있기 때문입니다.

 

하지만 모든 기본 클래스가 다형성을 갖도록 설계된 것은 아닙니다. std::string 타입이나 STL 컨테이너 타입은 기본 클래스뿐만 아니라 다형성이 조금도 고려되지 않은 클래스입니다. 

그리고, 기본 클래스로는 쓰일 수 있지만 다형성은 갖지 않도록 설계된 클래스도 있는데, 이런 클래스는 기본 클래스의 인터페이스를 통해 파생 클래스 객체의 조작이 허용되지 않습니다.

 

 


본문을 통해서 알아본 내용을 정리하자면,

  • 다형성을 가진 기본 클래스, 즉 가상 함수를 하나라도 가지고 있다면, 이 클래스의 소멸자는 가상 소멸자로 선언해야 한다.
  • 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에서는 가상 소멸자를 선언하면 안된다.

이렇게 두 가지로 정리할 수 있습니다.

댓글