시작은 미미하나 끝은 쥬쥬하리라.

Study/Effective C++

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

코딩뚜벅이 2023. 7. 30. 16:01

07. 다형성을 가진 기본 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자

  • 팩토리 함수
  • 가상 소멸자
  • 순수 가상 소멸자
  • 이것만은 잊지 말자 !

팩토리 함수

 

 만약 위와 같이 시간에 관한 정보를 담고 있는 TimeKeeper 객체가 있고, 사용자들은 시간 정보에 접근하고 싶어한다고 가정해보자. 이때, 이 시간 기록 객체에 대한 포인터를 손에 넣는 용도로 함수를 정의하면 이것이 팩토리 함수이다. 팩토리 함수의 정확한 정의는 "새로 생성된 파생 클래스 객체에 대한 기본 클래스 포인터를 반환하는 함수" 이다.

 

 

 위와 같이 코드를 정의하면 TimeKeeper에서 파생된 클래스를 통해 동적으로 할당된 객체의 포인터를 반환 받을 수 있다. 하지만 함수에서 반환되는 객체는 힙에 있게 되므로 메모리의 누수를 막기 위해 해당 객체를 적절히 삭제해야 한다. 아래는 매모리 해제를 위해 소멸자를 호출하는 코드가 추가된 코드이다.

 

 


 

가상 소멸자

 

 팩토리 함수 혹은 팩토리 메서드 패턴을 통해서 객체의 포인터는 반환 받았는데, 문제는 함수가 반환하는 포인터가 파생 클래스 즉, AtomicClock 객체에 대한 포인터라는 점과 이 포인터가 가리키는 객체가 삭제될 때는 기본 클래스 포인터인 TimeKeeper 포인터를 통해 삭제된다는 점, 그리고 기본 클래스의 소멸자가 비가상 소멸자라는 점이다.

 C++의 규정에 따르면 기본 클래스의 포인터를 통해 파생 클래스 객체가 삭제될 때, 그 클래스에 비가상 소멸자가 들어있다면 프로그램 동작을 미정의 사항이라고 명시되어 있다. 대체적으로 객체의 파생 클래스 부분이 소멸되지 않는 결과를 야기한다.

 요약하자면 함수의 포인터를 통해서 넘어온 AtomicClock 객체는 기본 클래스 포인터를 통해 소멸될 때, AtomicClock 객체에 해당하는 부분이 소멸되지 않을 뿐 아니라 AtomicClock 의 소멸자도 실행되지 않는다. 소멸 과정이 원활하게 이루어지지 않은 "부분 소멸 객체:가 되는 것이다.

 

 위 문제의 해결 방법은 기본 클래스에 가상 소멸자를 선언하는 방법이다.

 

 

 위와 같이 가상 소멸자를 사용함으로써, 객체의 완전한 소멸을 유도할 수 있다. 추가로 기본 클래스는 대개 소멸자 외에도 가상 멤버 함수를 갖는 경우가 많고, 이 가상 함수들은 각각의 파생 클래스에서 서로 다른 의미로 구현될 것이다. 그렇기 때문에 가상 함수를 하나라도 가지고 있는 클래스는 가상 소멸자를 사용하는 것이 올바르다고 할 수 있다.

 가상 소멸자를 가지고 있지 않은 클래스에 대해서는 "이 클래스는 기본 클래스로 사용되는 것을 원치 않구나." 라고, 그리고 반대로 가상 소멸자를 가지고 있는 클래스에 대해서는 "이 클래스는 기본 클래스로 사용되겠구나." 라고 생각할 수 있다는 것이다. 기본 클래스로 사용되는 것을 의도하지 않았는데 가상 소멸자를 가지고 있는 클래스는 어떨까 ?

 

 

 

  위 코드는 2차원의 공간에 있는 한 점을 나타내는 클래스이다. 그 이전에 짚고 넘어가야 하는 부분은 C++에서 가상 함수를 구현하려면 클래스에 별도의 자료구조가 필요하다. 이 자료구조는 프로그램 실행 중에 주어진 객체에 대해 어떤 가상 함수를 호출해야하는지 결정하는데 쓰이는 정보이고 포인터의 형태를 취하고 있지만. 대개 vptr[Virtual Table Pointer: 가상 함수 테이블 포인터]라는 이름으로 불린다. 이 vptr은 가상 함수의 주소 즉, 포인터들의 배열을 가리키고 있으며 가상 함수 테이블 포인터의 배열은 vtbl[Virtual Table: 가상 함수 테이블]이라 한다. 가상 함수를 하나라도 갖고 있는 클래스는 반드시 관련된 vtbl을 갖고 있다. 어떤 객체에 대해 어떤 가상 함수가 호출되려 한다면, 호출 되는 실제 함수는 그 객체의 vptr이 가리키는 vtbl에 따라 결정되게 된다.

 

 다시 원래 얘기로 돌아와서 위의 클래스는 int가 32비트를 차지한다고 가정했을때 Point 객체는 64비트 레지스터에 알맞게 들어간다. 하지만 가상 소멸자가 선언되어 vptr과 vtbl이 생성되면 Point 타입의 객체 크기가 증가하게 된다. 이로인해 해당 객체는 64비트 레지스터에 들어가는 것이 제한되고 C등의 다른 언어와의 호환성도 상실하게 된다. 

 

 결론적으로 가상 소멸자를 선언하는 것은 가상 함수가 하나라도 들어가있는 클래스로만 한정하는 것이 좋다.

 

 


 

순수 가상 소멸자

 

 

 만약 위와 같이 가상 함수를 가지고 있지 않은 클래스가 string 클래스를 상속 받는 경우를 생각해보자. 

 

 

 클래스 선언 후에 위와 같이 생성과 소멸을 하려한다면 어떠한 결과가 나올까 ? 정답은 "정의되지 않은 동작이 발생한다." 이다. string 클래스는 가상 소멸자를 가지고 있지 않다 즉, 파생 클래스를 가지는 기본 클래스로 사용되는 것을 의도하지 않았다는 얘기이다. 실질적으로는 SpecailString 객체의 소멸자는 호출되지 않고 해당 객체 부분의 자원이 누출되는 결과가 발생한다. 이러한 현상은 가상 소멸자가 없는 클래스라면 모두 적용되고 대표적으로 STL의 컨테이너가 여기에 해당한다.

 

 이를 해결하기 위해 우리는 순수 가상 소멸자를 사용할 수 있다. 순수 가상 함수는 해당 클래스를 추상 클래스(자체로 인스턴스를 만들지 못하는 클래스)로 만들고, 이 추상 클래스는 기본 클래스로 쓰일 목적으로 만들어진 것이다. 기본 클래스로 쓰이려는 클래스는 가상 소멸자를 가져야하고, 순수 가상 함수가 하나라도 있다면 추상 클래스가 된다. 요약하자면, 추상 클래스로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하면 된다.

 

 

 위의 AWOV 클래스는 순수 가상 함수인 가상 소멸자를 가지고 있는 추상 클래스이다. 소멸자 호출 문제는 해결했지만, 이 순수 가상 소멸자의 정의를 해야한다는 점에 유의해야 한다. 소멸자는 가장 말단의 파생 클래스의 소멸자부터 기본 클래스쪽으로 거쳐 올라가면서 소멸자를 하나씩 호출하는데 파생 클래스에서 소멸자의 정의를 잊는다면 링커 에러가 나오게 된다. 

 

 마지막으로 기본 클래스에 가상 소멸자를 선언하는 규칙은 다형성을 가진 기본 클래스에만 해당되는 것으로 string 타입, STL 컨테이너 타입 등은 다형성을 충족하지 않아 위 규칙을 사용할 수 없다.

 


 

이것은 잊지 말자 !

1. 다형성을 가진 기본 클래스에는 반드시 가상 소멸자를 선언해야 한다. 즉, 어떤 클래스가 가상 함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자이어야 한다.

 

2. 기본 클래스로 설계되지 않았거나 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.