소멸자란
이전 페이지에서는 생성자를 보았습니다. 객체가 태어날 때 실행되어 초기 상태를 설정하는 특별한 함수였죠. 소멸자는 그 거울상입니다. 객체가 죽을 때 실행되어 뒷정리를 하는 특별한 함수입니다.
소멸자는 클래스 이름 앞에 물결표(~)를 붙여 선언합니다. 매개변수를 받지 않고, 아무것도 반환하지 않으며, 클래스는 정확히 하나만 가질 수 있습니다. 직접 손으로 호출하는 일은 거의 없습니다. C++가 적절한 시점에 대신 호출해 줍니다.
소멸자 메시지가 main이 함수 본문을 끝낸 뒤, 프로그램이 종료되기 전에 출력된다는 점에 주목하세요. log가 닫는 중괄호에서 스코프를 벗어나면 C++가 대신 ~Logger()를 실행합니다.
소멸자가 실행되는 시점
정확한 시점은 객체가 어디에 사는지에 따라 달라집니다.
- 스택(지역) 객체는 스코프를 벗어날 때 - 블록의 닫는
}에서 - 소멸됩니다. - 힙 객체(
new로 생성)는delete를 호출할 때 소멸됩니다.delete를 잊으면 소멸자가 절대 실행되지 않고 누수가 발생합니다.
다음 예제는 그 차이를 눈에 보이게 합니다.
객체는 생성의 역순으로 소멸됩니다. a가 가장 먼저 만들어졌으므로 가장 나중에 죽습니다. 이 LIFO(last-in, first-out, 후입선출) 순서는 객체들이 서로 의존할 때 중요합니다.
소멸자가 중요한 이유: RAII
소멸자의 진정한 힘은 정리를 자동적이고 예외에 안전하게 만든다는 점입니다. 모든 코드 경로에서 자원을 해제하는 것을 기억하는 대신, 해제를 소멸자에 넣고 언어가 그것이 실행되도록 보장하게 합니다. 이 패턴을 RAII(Resource Acquisition Is Initialization, 자원 획득은 초기화다)라고 부르며, 현대 C++의 근간입니다.
여기서 한 클래스가 힙 버퍼를 소유합니다. 생성자에서 할당하고 소멸자에서 해제하므로, 호출하는 쪽은 new/delete를 직접 건드릴 일이 없습니다.
핵심 통찰은 이것입니다. squares가 생성된 뒤에 예외가 던져지더라도, 스택이 풀리면서 ~IntArray()는 여전히 실행됩니다. 바로 이 보장이 RAII를 그토록 믿을 만하게 만들며, 좋은 C++ 코드에서 맨몸의 delete를 거의 작성하지 않는 이유이기도 합니다.
3의 법칙 (그리고 5의 법칙)
사용자 정의 소멸자를 가진 클래스는 거의 항상 원시 자원을 소유하며, 이는 숨은 위험을 만듭니다. 컴파일러가 생성한 복사 생성자와 복사 대입은 얕은 복사를 합니다. 즉 가리키는 버퍼가 아니라 포인터 자체를 복사합니다. 이제 두 객체가 같은 포인터를 쥐게 되고, 두 소멸자가 모두 그것을 delete 하여 이중 해제 충돌을 일으킵니다.
IntArray a(5);
IntArray b = a; // 얕은 복사: a.data와 b.data는 같은 포인터
// 스코프 종료 시: b의 소멸자가 버퍼를 해제하고,
// 이어서 a의 소멸자가 그것을 다시 해제한다 -> 정의되지 않은 동작 (이중 해제)
이로부터 3의 법칙이 나옵니다. 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 작성한다면 거의 확실히 셋 모두가 필요합니다. C++11 이후로는 이동 생성자와 이동 대입을 더한 5의 법칙으로 확장됩니다.
그러나 더 나은 법칙이 있습니다. 바로 0의 법칙입니다. 원시 자원을 아예 관리하지 않도록 클래스를 설계하는 것입니다. 대신 std::vector, std::string, 또는 스마트 포인터를 보유하면 컴파일러가 생성한 소멸자가 공짜로 올바르게 처리합니다.
기본적으로 0의 법칙을 택하세요. 사용자 정의 소멸자는 어떤 표준 타입도 대신 감싸 주지 못하는 원시 자원을 정말로 소유할 때만 작성하세요.
가상 소멸자
기반 클래스 포인터를 통해 객체를 delete할 때, 소멸자는 반드시 virtual이어야 합니다. 그렇지 않으면 기반 부분만 소멸되고 파생 부분이 누수됩니다. 이것은 다형성 코드에서 가장 흔한 버그 중 하나이며, 컴파일러는 기본적으로 이에 대해 경고해 주지 않습니다.
~Base에 virtual이 없으면 delete p는 ~Base()만 호출합니다. 이는 정의되지 않은 동작이며, 객체의 Derived 부분은 결코 정리되지 않습니다. 경험칙: 가상 함수를 가진 모든 클래스(다형성 기반 클래스)는 가상 소멸자가 필요합니다. 클래스를 파생하기 시작하면 이것이 왜 중요한지 정확히 알게 될 것입니다.
흔한 실수와 함정
거의 모든 사람을 걸려 넘어지게 하는 몇 가지 함정이 있습니다.
짝이 맞지 않는 new/delete. new[]로 할당했다면 delete[]로 해제하세요. new[]를 일반 delete와 섞는 것(또는 그 반대)은 정의되지 않은 동작입니다.
기반 소멸자에서 virtual을 잊는 것. 위에서처럼, 가상 소멸자 없이 기반 포인터를 통해 파생 객체를 delete하면 파생 부분이 누수됩니다. 상속될 의도로 클래스를 작성한다면 소멸자를 가상으로 만드세요.
소멸자에서 예외가 빠져나가게 두는 것. 스택 되감기 중에 예외를 던지는 소멸자는 프로그램을 종료시킵니다. 현대 C++에서 소멸자는 암묵적으로 noexcept입니다. 정리 코드가 예외를 던지지 않게 하거나, 소멸자 내부에서 예외를 삼키세요.
필요 없는 소멸자를 작성하는 것. 멤버가 이미 스스로 정리된다면, 빈 ~ClassName() {}은 잡음을 더할 뿐 아니라 이동 연산을 조용히 비활성화할 수 있습니다. 정리할 것이 없다면 소멸자를 아예 작성하지 마세요.
다음: 상속
이제 객체의 전체 수명 주기를 보았습니다. 생성자가 생명을 불어넣고, 소멸자가 뒷정리를 하며, virtual 소멸자는 한 클래스가 다른 클래스 위에 세워질 때도 그 정리를 올바르게 유지합니다. 그 마지막 점은 다음의 큰 개념에 대한 예고입니다. 바로 상속으로, 한 클래스가 다른 클래스의 데이터와 동작을 재사용하고 확장하는 것입니다. 다음 페이지에서는 한 클래스를 다른 클래스에서 파생하는 방법, 생성과 소멸이 계층 구조를 따라 어떻게 연쇄되는지, 그리고 방금 배운 조각들이 어떻게 맞물리는지를 보여 줍니다.
자주 묻는 질문
C++에서 소멸자란 무엇인가요?
소멸자는 ~ClassName()이라는 이름의 특별한 멤버 함수로, 객체가 소멸될 때 - 즉 스코프를 벗어나거나 delete할 때 - 자동으로 실행됩니다. 그 역할은 정리입니다. 메모리 해제, 파일 닫기, 또는 객체가 소유한 모든 자원의 해제를 수행합니다. 매개변수를 받지 않고 반환 타입도 없으며, 클래스는 단 하나만 가질 수 있습니다.
C++에서 소멸자는 언제 실행되나요?
지역(스택) 객체의 경우 소멸자는 스코프를 벗어날 때, 즉 닫는 }에서 실행됩니다. new로 만든 힙 객체의 경우 delete를 호출할 때 실행됩니다. 멤버와 기반 클래스는 그 뒤에 생성과 역순으로 자동으로 소멸됩니다.
C++에서 항상 소멸자를 작성해야 하나요?
아니요. 클래스가 스스로 정리되는 멤버(std::string, std::vector, 스마트 포인터 등)만 가지고 있다면 컴파일러가 생성한 소멸자로 충분하므로 직접 작성하지 마세요. 사용자 정의 소멸자가 필요한 경우는 new로 할당한 메모리나 열린 파일 핸들 같은 원시 자원을 클래스가 직접 소유할 때뿐입니다.