가상 함수의 활용 - Virtual Function ch.2
<가상 함수의 함정 - 생성자와 소멸자>

http://wahnfried.net






 자식이 부모가 원하는 길을 가도록 하다가도 자식의 개성을 살려주는 일이란 퍽 어려운 일이라고 막상 적어놓고 보니, 이렇게 마음에 와 닿는 말도 없더군요. 헌데 마음만 그러했지 막상 두 번째 글을 쓰려고 보니 귀찮음에 손이 잘 가지 않아서 아주 오랜만에 업데이트를 합니다. 이 부분이 처음 프로그래밍을 할 적에 - 제대로 봐 두지 않았다면 - 헷갈리기 좋은 부분이었던 터라 퍽 애착이 가는 내용입니다. 이번에도 개성 있게 자식 키우기의 어려움에 대해서 알아볼까 합니다. 물론 C++ 에서의 이야기입니다;










- 생성자와 소멸자


  그냥 간단히 만든 아래의 예제에서는 정말 간단히 부모 클래스인 CAnimal과 CDog을 한 번씩 생성했다가 지워봅니다.




023: class CAnimal
024: {
025: public:
026:         CAnimal()
027:         {
028:                 cout << "Construct animal" << std::endl;
029:         };
030:
031:         ~CAnimal()
032:         {
033:                 cout << "Destruct animal" << std::endl;
034:         };
035:
036:         virtual void Say()
037:         {
038:                 cout << "animal" << std::endl;
039:         };
040: };
041:
042: class CDog : public CAnimal
043: {
044: public:
045:         CDog()
046:         {
047:                 cout << "Construct dog" << std::endl;
048:         };
049:
050:         ~CDog()
051:         {
052:                 cout << "Destruct dog" << std::endl;
053:         };
054:
055:         void Say()
056:         {
057:                 cout << "dog" << std::endl;
058:         };
059: };
060:
061: void DoSomething()
062: {
063:         CAnimal* pAnimal = new CAnimal;
064:         delete pAnimal;
065:
066:         cout << "--------------------" << std::endl;
067:
068:         CDog* pDog = new CDog;                                                                    
069:         delete pDog;                                                                                       
070: }




 예상하시듯이 위 코드에서 중요한 부분은 68번째 줄과 69번째 줄 두 줄 뿐입니다. 자식 클래스인 CDog을 생성할 때 부모 클래스와 자식 클래스의 생성자와 소멸자를 각각 한 번씩 호출하게 됩니다. 아래의 순서로 말입니다.




CDog의 생성시

new CDog -> CAnimal 의 생성자 -> CDog 의 생성자 -> 생성 완료!


CDog의 소멸시

delete pDog -> CDog 의 소멸자 -> CAnimal 의 소멸자 -> 소멸 완료!




  만약 부모 클래스인 CAnimal 을 생성할 때, CAnimal 클래스의 멤버 변수들에 대해 어떠한 처리를 해 주어야 한다거나, 마찬가지로 CAnimal 이 없어질 때 멤버 변수들의 메모리를 해제해주는 등의 처리가 필요하다면 위의 코드에서는 모든 기능들이 적절히 수행됩니다. 적어도 아직은요. 위의 코드에 약간의 장난을 쳐 보겠습니다.




023: class CAnimal
024: {
025: public:
026:         CAnimal()
027:         {
028:                 cout << "Construct animal" << std::endl;
029:         };
030:
031:         ~CAnimal()
032:         {
033:                 cout << "Destruct animal" << std::endl;
034:                 ////    CAnimal must free some memories here
035:         };
036:
037:         virtual void Say()
038:         {
039:                 cout << "animal" << std::endl;
040:         };
041: };
042:
043: class CDog : public CAnimal
044: {
045: public:
046:         CDog()
047:         {
048:                 cout << "Construct dog" << std::endl;
049:         };
050:
051:         ~CDog()
052:         {
053:                 cout << "Destruct dog" << std::endl;
054:                 ////    CDog must free some memories here
055:         };
056:
057:         void Say()
058:         {
059:                 cout << "dog" << std::endl;
060:         };
061: };
062:
063: void DoSomething()
064: {
065:         CAnimal* pAnimal = new CAnimal;
066:         delete pAnimal;
067:
068:         cout << "--------------------" << std::endl;
069:
070:         CAnimal* pDog = new CDog;                                                             
071:         delete pDog;
072: }




  그저 CDog 을 받는 포인터를 부모 클래스의 포인터로 바꾸어 보았습니다. 지난 챕터에서 그러했듯이 말입니다. 당연한 것이지만 생성자나 소멸자도 함수라고 생각한다면 동적 할당에서 벌어지는 문제는 이곳에서도 예외 없이 발생합니다. 무슨 말이냐면




부모 클래스 CAnimal* 로 받은 CDog의 소멸시

delete pDog -> CAnimal 의 소멸자 호출 -> 소멸 완료(?!?)




  라는 험한 상황이 벌어진다는 이야기입니다. 만약 CDog의 소멸자에서 풀어줘야 했던 메모리가 있다거나, 해야 할 다른 처리가 있었다면 위의 경우는 분명히 Memory leak 이나 여러 알 수 없는 문제를 일으키게 됩니다. 습관처럼 생성자와 소멸자에 여러 기능들을 넣어서 만들곤 하는 프로그래머라면 더더욱 신경 써야 하는 부분이겠지요. 지난 편을 꼼꼼하게 읽고 생각해 본 분이라면 예상했을 이야기이지만 복습 겸 해서 다시 한 번 강조해보기로 합니다 Effective C++ 에서도 초판부터 강조했던 이야기입니다. 놓칠 수 없겠죠. :-)



Class 의 소멸자는 반드시 virtual 로 선언한다!














- 생성 & 소멸시의 또다른 함정


  역시 다른 말 필요 없이 다음 코드로 가 보겠습니다. 요점은, Base Class 의 생성자에서 Say 를 한 번 하고 생성하고, 소멸자에서 Finalize 함수를 불러서 정리 작업을 마치고 클래스를 지우고 싶다.. 라는 것입니다.



023: class CAnimal
024: {
025: public:
026:         CAnimal()
027:         {
028:                 Say();                                                                                     
029:                 cout << "Construct animal" << std::endl;
030:         };
031:
032:         virtual ~CAnimal()
033:         {                      
034:                 Finalize();                                                                                  
035:                 cout << "Destruct animal" << std::endl;
036:         };
037:
038:         virtual void Say()
039:         {
040:                 cout << "animal" << std::endl;
041:         };
042:
043:         virtual void Finalize()
044:         {
045:                 cout << "Finalize animal" << std::endl;
046:         };
047: };
048:
049: class CDog : public CAnimal
050: {
051: public:
052:         CDog()
053:         {
054:                 cout << "Construct dog" << std::endl;
055:         };
056:
057:         ~CDog()
058:         {
059:                 cout << "Destruct dog" << std::endl;
060:         };
061:
062:         void Say()
063:         {
064:                 cout << "dog" << std::endl;
065:         };
066:
067:         void Finalize()
068:         {
069:                 cout << "Finalize dog" << std::endl;
070:         };
071: };
072:
073: void DoSomething()
074: {
075:         CAnimal* pAnimal = new CAnimal;
076:         delete pAnimal;
077:
078:         cout << "--------------------" << std::endl;
079:
080:         CAnimal* pDog = new CDog;
081:         delete pDog;
082: }




  생성자와 소멸자에서 불린 Say 와 Finalize 는 둘 다 virtual 로 선언되어 있으니까, 아마도 이 코드의 작성자 - 저겠죠; - 는 아래와 같은 의도로 코드를 작성했을 테죠. 하지만 예상과 실제는 다릅니다.



(예상) new CDog 을 하면

CDog::Say 를 호출해서 "dog" 을 출력하고 생성 완료!


(실제) new CDog 을 하면

CAnimal::Say 를 호출해서 "animal" 을 출력하고 생성 완료(?!)




  어째서인지는 앞서 알아보았던 부모 클래스와 자식 클래스의 생성자와 소멸자가 불리던 순서를 떠올려보면 알 수 있습니다. 생성-소멸의 프로세스와 함께 위의 flow를 따라가 보겠습니다.




(실제) new CDog 을 하면

CAnimal::CAnimal() 생성자가 호출
CAnimal::CAnimal() 안에서 Say 를 호출하려고 함.
                   CAnimal::Say() 가 virtual 이기는 하나,
                   CDog::Say() 를 호출하려니 아직 CDog은 생성 안 된 상태이므로..
CAnimal::Say() 를 호출
CDogg::CDog() 생성자 호출

생성 완료!




  마찬가지로 소멸자가 불렸을 때의 상황도 생각해 보면 다음과 같습니다.




(실제) delete pDog 을 하면

CDog::~CDog() 소멸자가 먼저 호출 - CDog은 사라졌음
CAnimal::~CAnimal() 소멸자 호출
CAnimal::~CAnimal() 안에서 Finalize 를 호출하려고 함.
                  CAnimal::Finalize 가 virtual 이지만,
                  CDog::Finalize 를 호출하려니 이미 CDog은 소멸된 상태이므로..
CAnimal::Finalize() 를 호출

소멸 완료!




  이런 상황 때문에 생성자 - 소멸자 내에서 virtual 함수를 사용한다는 것은 일단 눈으로 보기에도 명쾌하지 않은 코드를 만드는 일이기도 하고 - 본인은 위 사항을 정확히 알고 만든 코드라고 해도 누군가는 왜 CDog의 Say 나 Finalize 가 불리지 않느냐에 대해 고민하게 될 지도 모르니까요 - 이것은 버그로 이어질 수도 있습니다. 때문에 아래의 사항을 기억해야 합니다.




Class 의 생성자와 소멸자 내에서는 virtual 함수를 호출하지 않는다!













- 조금 더 생각해보기


  그렇다면 다음과 같은 것은 어떨까요? 아예 부모 클래스인 CAnimal 에는 Say() 라는 함수를 순수 가상함수로 선언해 놓고 똑같이 사용해 보는 것입니다.




023: class CAnimal
024: {
025: public:
026:         CAnimal()
027:         {
028:                 Say();
029:                 cout << "Construct animal" << std::endl;
030:         };
031:
032:         virtual ~CAnimal()
033:         {                      
034:                 Finalize();
035:                 cout << "Destruct animal" << std::endl;
036:         };
037:
038:         virtual void Say() = 0;                                                                      
039:
040:         virtual void Finalize()
041:         {
042:                 cout << "Finalize animal" << std::endl;
043:         };
044: };
045:
046: class CDog : public CAnimal
047: {
048: public:
049:         CDog()
050:         {
051:                 cout << "Construct dog" << std::endl;
052:         };
053:
054:         ~CDog()
055:         {
056:                 cout << "Destruct dog" << std::endl;
057:         };
058:
059:         void Say()
060:         {
061:                 cout << "dog" << std::endl;
062:         };
063:
064:         void Finalize()
065:         {
066:                 cout << "Finalize dog" << std::endl;
067:         };
068: };
069:
070: void DoSomething()
071: {
072:         CDog* pDog = new CDog;
073:         delete pDog;
074: }

 


  결과는? 링크 에러입니다. Base Class 의 생성자/소멸자에서는 자신이 자식 클래스인지 부모인지에 관계 없이 무조건(!) Base Class 기준으로 함수를 호출한다는 걸 예상해볼 수 있는 테스트입니다.


  조금만 더 고집을 부려볼까요? 생성자와 소멸자에서 위와 같은 문제가 벌어진다면, CAnimal의 생성자에서 보통의 함수인 Init() 함수를 호출하고, Init() 함수 안에서 순수 가상 함수인 Say() 를 호출하는 겁니다. 아래와 같이요.




023: class CAnimal
024: {
025: public:
026:         CAnimal()
027:         {
028:                Init();                                                                                      
029:                 cout << "Construct animal" << std::endl;
030:         };
031:
032:         virtual ~CAnimal()
033:         {                      
034:                 Finalize();
035:                 cout << "Destruct animal" << std::endl;
036:         };
037:
038:         void Init()
039:         {
040:                 Say();
041:         };
042:
043:         virtual void Say() = 0;                                                                  
044:
045:         virtual void Finalize()
046:         {
047:                 cout << "Finalize animal" << std::endl;
048:         };
049: };
050:
051: class CDog : public CAnimal
052: {
053: public:
054:         CDog()
055:         {
056:                 cout << "Construct dog" << std::endl;
057:         };
058:
059:         ~CDog()
060:         {
061:                 cout << "Destruct dog" << std::endl;
062:         };
063:
064:         void Say()
065:         {
066:                 cout << "dog" << std::endl;
067:         };
068:
069:         void Finalize()
070:         {
071:                 cout << "Finalize dog" << std::endl;
072:         };
073: };
074:
075: void DoSomething()
076: {
077:         CDog* pDog = new CDog;
078:         delete pDog;
079: }




  거의 최후의 발악(?) 수준의 노력이고, 결과 역시 최후의 한 방 수준의 것이 등장합니다. 멋지게 빌드되어버리지만, 결과는 Runtime Error !! 말 그대로 돌려 봐야 아는 멋진 버그를 - 게다가 어플리케이션이 죽어 버리는 - 만들어 낸 셈입니다. 물론 이것을 우회할 수 있는 방법이 있기는 합니다만, 앞서 말씀드린 오늘의 요점을 반드시 기억해보도록 합니다.



Class 의 생성자와 소멸자 내에서는 절대로 virtual 함수를 호출하지 않는다!










- Outro


  겪다 보면 예전에는 마냥 막연하다고만 생각했던 것들을 '아차!' 하며 뇌리에 떠올리게 될 때가 있습니다. 이 부분은 몇 년 전 제게 너무나 쿵(!) 했던 내용이었답니다 시간이 난다면 가상 함수를 썼을 때와 그렇지 않을 때의 메모리상의 주소 차이나, 함수 콜 시간의 차이 등도 한 번 정리해서 적어보았으면 좋겠군요. 아, 타입 캐스팅도 좋은 주제가 되겠네요.