항목 7: 다형성을 가진 클래스에서는 소멸자를 반드시 가상 소멸자로 선언하자
시간 기록을 유지하는 방법은 활용에 따라 무궁무진 하다. 그래서 TimeKeeper 라는 기본 클래스를 만들어 놓은 후 적절한 용도에 따라 이것을 상속하도록 설계하면 딱 알맞을 거 같다.
TimeKeeper를 쓰는 사용자들은 시간 정보에 접근하고 싶은데, TimeKeeper에서 시간 계산을 어떻게 하는지는 관심이 없고, 그냥 시간 정보에 접근만 하고 싶다. => 이럴 때 어떤 시간기록 객체에 대한 포인터를 반환하는 용도로 팩토리 함수(factory function, 새로 생성된 derived class 객체에 대한 base class 포인터를 반환하는 함수) 를 만들어 두면 딱 좋을 거 같다.(getTimeKeeper()) [C++] 팩토리 함수 :: IT's me (tistory.com) 참고
팩토리 함수의 특징이 derived class를 new로 만들고, new로 반환된 derived class* => base class*로 type conversion 해 리턴한다.
즉 반환되는 객체는 new로 인해 힙영역에 있음 => 적절한 타이밍에 delete를 해줘야 한다.
근데 객체 삭제를 사용자가 하는건 좀 이상하다. 그리고 사용자가 하면 에러 발생의 소지가 있고, 항목 18을 보면 팩토리 함수의 인터페이스를 수정하면 흔히 발생하는 사용자 에러를 막을 수는 있지만, 어쨌든 저 코드
는 약점이 많음
문제는
- getTimeKeeper 가 반환하는 포인터가 base class*로 type conversion을 하긴 하지만 derived class를 가르키는 점
- new는 derived class로 했는데, 즉 힙에 있는건 dervied class인데 delete는 base class의 소멸자를 통해 된다는 점
- base class(TimeKeeper)에 들어 있는 소멸자가 비가상 소멸자라는 점
이다.
C++ 규정 : base class pointer에 의해 derived class 객체가 삭제될 base class에 비가상 소멸자가 들어 있으면 프로그램 동작은 미정의 사항이다.
보통 이럴 때 derived class 안에 있던 base class는 delete 되지만, 나머지 부분은 소멸하지 않는다. 근데 미정의 사항이기 때문에 모름
=> derived class의 소멸자가 호출 자체가 안되는 것도 모자라, 애매하게 메모리 공간이 남아있어 자원이 줄줄 세고, 자료구조가 오염되고, 보안에도 취약함
문제를 해결하는 가장 간단한 방법!
base class의 소멸자를 가상 소멸자로 만든다.
virtual function 은 클래스에 virtual function table로 가는 포인터를 하나 만들고,
virtual function table에는 overriding 된 함수들의 주소가 적혀 있다.
소멸자는 일반 함수들과 달리 base class에서 virtual로 등록하면 derived class의 소멸자도 자동으로 virtual이 됨, 그래서 derived class의 소멸자에는 vitual 키워드를 생략할 수 있다.
즉 delete ptk => 참조 type이 TimeKeeper 이다. => TimeKeeper의 소멸자 호출 => 가상 소멸자임 =>
virtual function table에서 derived class의 소멸자 호출 => 원래 자신의 소멸자 호출
c++ 생성자 소멸자/(virtual)가상 소멸자를 쓰는 이유 (tistory.com)
소멸자는 derived destructor -> base destructor 순으로 되는데
위 예제에서 base destructor에 virtual 선언이 없다면 delete ptk에서 ptk의 reference type이 TimeKeeper이기 때문에 base destructor가 호출되고 그 뒤에 호출될 소멸자가 없으므로 그대로 끝나게 되는데,
base destructor를 virtual로 등록해놓으면 delete ptk에서 base destructor가 호출 될 때 virtual function table에서 overriding된 derived destructor의 주소를 통해 호출되기 때문에 derived destructor가 호출 되고, derived destructor가 호출되었으므로 그 다음 순서인 base destructor가 자동으로 호출되게 된다.
virtual function을 하나라도 사용하는 클래스라면 virtual destructor를 사용하는 것이 맞다.
왜 why? virtual function 을 쓴다는 건 기본적으로 base class로 쓸 것이다라는 의미임(왜 why, virtual function 자체가 derived class에 대한 polymorphism, 즉 기본 인터페이스를 제공해주고 그걸 여러 목적에 맞게 사용할 수 있게 끔 하기 위함이다.)
virtual destructor를 갖고 있지 않는 클래스 => base class로 쓸 생각이 없다.
바꿔 말하면 base class로 쓸 생각이 없는 함수면 소멸자를 virtual로 선언하는 것은 좋지 않다.
왜 why?
이런 class가 있다 치면 int가 32bit면 Point class는 64bit 레지스터에 딱 맞게 들어갈 수 있겠다.
실제 sizeof(Point)해보면 8bytes 가 나온다.
하지만 소멸자를 가상 소멸자로 만들어버리면
가 나온다.
virtual function => virtual function table이 만들어짐. 그리고 이 table을 가르키는 pointer가 객체에 포함된다. 그래서 +4bytes가 된 것
virtual function table을 가르키는 pointer를 vptr이라고 하고 이 table을 vtbl 이라고 함. 이 table은 함수 포인터들의 배열로 구현되어있다.
가상함수를 하나라도 갖고 있는 클래스는 반드시 그와 관련된 vtbl을 갖고 있다. 어떤 객체에 대해 어떤 가상함수가 호출 => 호출되는 실제 함수는 그 객체의 vptr이 가르키는 vtbl에 따라 결정된다. vtbl에 있는 함수 포인터들 중 적절한 것이 연결되는 것이다.
그리고 cpp에서 vptr과 vtbl은 C++만의 특성이기 때문에, C 등 다른 언어로 선언된 동일한 자료구조와도 호환성이 없어진다.
virtual destructor를 선언하는 것은 그 클래스에 가상 함수가 하나라도 들어 있는 경우에만 한정하자!!
(또는 base class 인 경우, 보통 base 클래스는 가상 함수가 많이 쓰임)
근데 가상함수가 전혀 없는데 통수맞는 경우가 있다. std::string이 대표적인 예인데
이 경우이다. string에는 가상함수를 갖고 있지 않다. 따라서 당연히 가상 소멸자도 없다!!! => 그런 클래스를 상속받는다? 에바임
그냥 보기엔 뭐 어때 싶지만, 이것을 사용한 응용프로그램 어딘가에서 SpecialString의 포인터를 string의 포인터로 어떻게든 전환한 후 이 포인터를 delete 한 순간 '미정의 동작' 행 지옥열차 탑승이다;
또한 STL 컨테이너 타입(vector, list, set, trl::unorderd_map … 등등)도 가상 소멸자가 없는 클래스이다. 이들 클래스를 상속받는 일은 없도록 하자
경우에 따라서는 순수 가상 소멸자(pure virtual destructor)를 두면 편리하게 쓸 수도 있다. 순수 가상 함수는 해당 클래스를 abstract class로 바꿔버린다. ( abstract class = 그 자체로는 인스턴스를 못만드는, 즉 그 타입의 객체를 생성할 수 없는 클래스)
하지만 어떤 클래스가 abstract class 였으면 좋겠는데, 마땅히 넣을 순수 가상 함수가 없다면 어떻게 할까?
=> abstract class는 원래 base class로 쓸 목적으로 만드는 것이다. base class는 가상 소멸자를 가져야 한다. 한편 순수 가상 함수가 있으면 바로 추상 클래스가 된다.
=> abstract class로 만들고 싶은 클래스에 순수 가상 소멸자를 선언하면 된다.
이렇게 하면 되는데 딱 하나 문제가 있다. 바로 순수 가상 소멸자의 정의룰 두지 않으면 안 된다.
소멸자가 동작하는 순서 : 상속 계통 구조에서 가장 말단의 derived class의 소멸자 -> 다음 소멸자 -> .. base class 소멸자
컴파일러는 ~AWOV의 호출 코드 (CALL ~AWOV)을 만들 것인데 이 때 링커 에러가 안날려면 정의를 꼭 만들어줘야 한다.
기본 클래스에 가상 소멸자를 사용하는 규칙은 다형성(polymorphism)을 위해 설계된 기본 클래스, 즉 기본 클래스 인터페이스를 통해 파생 클래스 타입의 조작을 허용하도록 설계된 기본 클래스에서만 적용된다!!
이번 항목의 TimeKeeper가 그런 경우이다. AtomicClock, WaterClock 객체를 보면 TimeKeeper 포인터만 가지고도 이것들을 조작할 수 있을 거라는 생각을 갖게 하기 때문이다.
즉 하나의 인터페이스처럼 사용하도록 설계된 기본클래스에서만 해당된다는 것이다.
모든 기본 클래스가 다형성을 갖도록 설계된 것이 아니기 때문에 저걸 잊으면 안된다. string type도 그렇고, STL 컨테이너 등은 기본 클래스는 커녕 다형성의 흔적조차 없다. 한번 기본 클래스로 사용하되, 다형성은 갖지 않도록 설계된 클래스들, 예를 들어 항목6의 Uncopyable이나 표준 라이브러리의 input_iterator_tag, 항목 47 참조) 등도 기본 클래스의 인터페이스를 통한 파생 클래스 객체의 조작이 허용되지 않는다. 이들에게서 가상 소멸자가 없는 이유가 바로 그것이다.
- 다형성을 가진 기본 클래스는 반드시 가상 소멸자를 선언해야 한다. 즉, 어떤 클래스가 가상함수를 하나라도 갖고 있으면, 이 클래스의 소멸자도 가상 소멸자여야 한다.
- 기본 클래스로 설계되지 않았거나, 다형성을 갖도록 설계되지 않은 클래스에는 가상 소멸자를 선언하지 말아야 한다.