소멸자

소멸자 (Destructor)

소멸자(destructor)객체의 수명(lifetime)이 끝날 때 호출되는 특별 멤버 함수예요. 목적은 객체가 수명 동안 획득했을 수 있는 자원을 해제하는 거예요. 파일, 메모리, 락 같은 자원을 정리하는 마지막 단계라고 생각하면 돼요. (C++20부터) 소멸자는 코루틴이 될 수 없어요.

출처: cppreference

본문

문법 (Syntax)

소멸자(until C++20) / 예비 소멸자(prospective destructor)(since C++20)는 다음 형태의 멤버 함수 선언자(declarator)로 선언돼요.

class-name-with-tilde ( parameter-list(선택) ) except(선택) attr(선택)
  • class-name-with-tilde — 식별자 표현식. 때로 속성 목록이 뒤따르고, (C++11부터) 괄호 쌍으로 감쌀 수 있어요.
  • parameter-list — 매개변수 목록 (반드시 비어 있거나 void 여야 해요).
  • except — 예외 명세 (동적 예외 명세(until C++11) / 동적 또는 noexcept 명세(since C++11, until C++17) / noexcept 명세(since C++17)).
  • attr — (C++11부터) 속성 목록.

소멸자 선언의 선언 지정자에는 constexpr(C++11부터), friend, inline, virtual 허용돼요 (특히 반환 타입은 허용되지 않아요).

class-name-with-tilde의 식별자 표현식은 다음 형태 중 하나여야 해요.

  • 클래스나 클래스 템플릿의 멤버 명세에 속하지만 friend 선언이 아닌 멤버 선언에서:
    • 클래스면, 식별자 표현식은 ~ 다음에 바로 둘러싼 클래스의 주입 클래스 이름(injected-class-name) 이 붙은 형태예요.
    • 클래스 템플릿이면, ~ 다음에 현재 인스턴스화를 가리키는 클래스 이름(until C++20) / 바로 둘러싼 클래스 템플릿의 주입 클래스 이름(since C++20)이 붙은 형태예요.
  • 그 외에는, 터미널 미한정 식별자가 ~ 다음에, 한정 식별자의 비터미널 부분이 지정하는 클래스의 주입 클래스 이름이 붙은 한정 식별자(qualified identifier) 예요.

설명 (Explanation)

소멸자는 객체의 수명이 끝날 때마다 암시적으로 호출돼요. 여기에는 다음이 포함돼요.

  • 프로그램 종료 — 정적 저장 기간을 가진 객체.
  • (C++11부터) 스레드 종료 — 스레드-로컬 저장 기간을 가진 객체.
  • 스코프 끝 — 자동 저장 기간을 가진 객체와, 참조에 바인딩되어 수명이 연장된 임시 객체.
  • delete 표현식 — 동적 저장 기간을 가진 객체.
  • 전체 표현식(full expression)의 끝 — 이름 없는 임시 객체.
  • 스택 되감기(stack unwinding) — 예외가 그들의 블록을 잡히지 않고 빠져나갈 때, 자동 저장 기간을 가진 객체.

소멸자는 명시적으로 호출할 수도 있어요.

예비 소멸자 (Prospective destructor, C++20부터) — 클래스는 하나 이상의 예비 소멸자를 가질 수 있고, 그중 하나가 클래스의 소멸자로 선택돼요. 어떤 예비 소멸자가 소멸자인지 정하기 위해, 클래스 정의 끝에서 빈 인자 목록으로 선언된 예비 소멸자들 사이에서 오버로드 해석을 수행해요. 오버로드 해석이 실패하면 프로그램은 ill-formed예요. 소멸자 선택은 선택된 소멸자를 odr-사용하지 않고, 선택된 소멸자는 deleted일 수도 있어요. 모든 예비 소멸자는 특별 멤버 함수예요. 클래스 T에 사용자 선언 예비 소멸자가 없다면 컴파일러가 항상 하나를 암시적으로 선언하고, 그 암시적으로 선언된 예비 소멸자가 T의 소멸자이기도 해요.

#include <cstdio>
#include <type_traits>

template<typename T>
struct A
{
    ~A() requires std::is_integral_v<T> { std::puts("~A, T is integral"); }
    ~A() requires std::is_pointer_v<T>  { std::puts("~A, T is a pointer"); }
    ~A()                                { std::puts("~A, T is anything else"); }
};

int main()
{
    A<int> a;
    A<int*> b;
    A<float> c;
}

출력:

~A, T is anything else
~A, T is a pointer
~A, T is integral

잠재적으로 호출되는 소멸자 (Potentially-invoked destructor)

클래스 T의 소멸자는 다음 상황에서 잠재적으로 호출돼요.

  • 명시적으로 또는 암시적으로 호출된다.
  • new 표현식이 타입 T의 객체 배열을 만든다.
  • return 문장의 결과 객체가 타입 T다.
  • 배열이 애그리게이트 초기화 중이고 그 원소 타입이 T다.
  • 클래스 객체가 애그리게이트 초기화 중이고, 타입 T의 멤버가 있는데 T가 익명 union 타입이 아니다.
  • (C++11부터) 비위임(non-delegating) 생성자에서 잠재적 생성 부분 객체가 타입 T다.
  • 타입 T의 예외 객체가 생성된다.

잠재적으로 호출되는 소멸자가 deleted이거나 (C++11부터) 호출 문맥에서 접근 불가하면, 그 프로그램은 ill-formed예요.

암시적으로 선언된 소멸자

클래스 타입에 사용자 선언 예비(since C++20) 소멸자가 없으면, 컴파일러는 항상 소멸자를 그 클래스의 인라인 public 멤버로 선언해요.

다른 암시적으로 선언된 특별 멤버 함수와 마찬가지로, 암시적으로 선언된 소멸자의 예외 명세는, 잠재적으로 생성되는 기반이나 멤버의 소멸자가 잠재적으로 던질 수 있지 않는 한(until C++17: 암시적 정의가 다른 예외 명세를 가진 함수를 직접 호출하지 않는 한) 비던짐(non-throwing) 이에요. 실제로 암시적 소멸자는, 기반이나 멤버의 소멸자가 noexcept(false)인 "중독된(poisoned)" 클래스가 아니면 noexcept예요.

암시적으로 정의된 소멸자

암시적으로 선언된 소멸자가 deleted가 아니면, 그것이 odr-사용될 때 컴파일러가 암시적으로 정의(즉 함수 본문을 생성·컴파일)해요. 이 암시적으로 정의된 소멸자는 빈 본문을 가져요. (C++20부터) 이것이 constexpr 소멸자(until C++23) / constexpr 함수(since C++23)의 요구사항을 만족하면, 생성된 소멸자는 constexpr이 돼요.

삭제된 소멸자 (Deleted destructor) — 클래스 T의 암시적으로 선언되거나 명시적으로 defaulted된 소멸자는 다음 조건 중 하나라도 만족하면 deleted로 정의돼요.

  • (until C++26) T가 클래스 타입 M(또는 그 다차원 배열)의 잠재적 생성 부분 객체를 가지고, M의 소멸자가 T의 소멸자에서 deleted이거나 접근 불가하거나, (부분 객체가 변형 멤버인 경우) 비자명(non-trivial)이다.
  • (since C++26) T가 union이 아니고, 클래스 타입 M(또는 그 다차원 배열)의 비변형 잠재적 생성 부분 객체를 가지며, M의 소멸자가 T의 소멸자에서 deleted이거나 접근 불가하다.
  • (since C++26) T가 union이고, 다음 중 하나라도 만족한다.
    • 타입 T의 객체를 기본 초기화할 생성자를 선택하는 오버로드 해석이 실패하거나, deleted이거나 비자명한 생성자를 선택한다.
    • T가 클래스 타입 M(또는 그 다차원 배열)의 변형 멤버 V를 가지는데, V가 기본 이니셜라이저를 가지고 M의 소멸자가 비자명하다.
  • 소멸자가 virtual이고, 할당 해제 함수의 검색이 모호성을 만들거나, 소멸자에서 deleted이거나 접근 불가한 함수를 만든다.
  • (C++20부터) T에 대한 명시적으로 defaulted된 예비 소멸자가 T의 소멸자가 아니면 deleted로 정의돼요.

자명한 소멸자 (Trivial destructor)

클래스 T의 소멸자는 다음 조건을 모두 만족하면 trivial이에요.

  • 소멸자가 암시적으로 선언됐거나(until C++11) 사용자 제공(user-provided)이 아니다(since C++11).
  • 소멸자가 virtual이 아니다.
  • 모든 직접 기반 클래스가 trivial 소멸자를 가진다.
  • (until C++26) 클래스 타입(또는 클래스 타입의 배열)의 모든 비정적 데이터 멤버가 trivial 소멸자를 가진다. (since C++26) T가 union이거나, 클래스 타입(또는 그 배열)의 모든 비변형 비정적 데이터 멤버가 trivial 소멸자를 가진다.

trivial 소멸자는 아무 동작도 수행하지 않는 소멸자예요. trivial 소멸자를 가진 객체는 소멸자를 호출하지 않고도 수명을 안전하게 끝낼 수 있어요(예: 객체의 저장 공간을 해제함으로써), 자원 누수 위험이 없어요. 비자명 소멸자를 가진 객체를 그 소멸자를 호출하지 않고 파괴하는 것은 undefined behavior가 아니지만, 프로그램의 올바른 동작은 보통 그 소멸자에 의존해요. C 언어와 호환되는 모든 데이터 타입(POD 타입)은 trivially destructible이에요.

파괴 순서 (Destruction sequence)

사용자 정의든 암시적으로 정의된 소멸자든, 소멸자 본문을 실행하고 본문 안에서 할당된 자동 객체를 파괴한 다음, 컴파일러는 클래스의 모든 비정적 비변형 데이터 멤버의 소멸자를 선언의 역순으로 호출해요. 그다음 모든 직접 비가상 기반 클래스의 소멸자를 생성 순서의 역순으로 호출하고(그 기반들은 차례로 자기 멤버와 기반의 소멸자를 호출하죠), 마지막으로 이 객체가 가장 파생된 클래스라면 모든 가상 기반의 소멸자를 호출해요.

소멸자를 직접 호출하더라도(예: obj.~Foo()), ~Foo() 안의 return 문장은 즉시 호출자에게 제어를 돌려주지 않아요. 그 모든 멤버와 기반 소멸자를 먼저 호출하고 나서야 돌아가요.

가상 소멸자 (Virtual destructors)

기반에 대한 포인터를 통해 파생 객체를 삭제하면, 기반 클래스의 소멸자가 가상이 아니면 정의되지 않은 동작이에요.

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

class Derived : public Base {};

Base* b = new Derived;
delete b; // 안전

흔한 지침으로, 기반 클래스의 소멸자는 public이면서 virtual이거나 protected이면서 nonvirtual이어야 한다고 해요.

순수 가상 소멸자 (Pure virtual destructors)

예비(since C++20) 소멸자를 순수 가상으로 선언할 수 있어요. 예를 들어 추상으로 만들어야 하는데 다른 순수 가상으로 선언할 만한 적절한 함수가 없는 기반 클래스에서 그런 경우가 있어요. 순수 가상 소멸자도 정의가 있어야 해요. 파생 클래스가 파괴될 때 모든 기반 클래스 소멸자는 항상 호출되기 때문이에요.

class AbstractBase
{
public:
    virtual ~AbstractBase() = 0;
};
AbstractBase::~AbstractBase() {}

class Derived : public AbstractBase {};

// AbstractBase obj; // 컴파일 오류
Derived obj;         // OK

예외 (Exceptions)

다른 함수와 마찬가지로 소멸자도 예외를 던지며 종료할 수 있어요 (보통 noexcept(false)로 명시적으로 선언해야 해요, C++11부터). 다만 이 소멸자가 스택 되감기 중에 호출되면 대신 std::terminate가 호출돼요.

std::uncaught_exceptions가 진행 중인 스택 되감기를 감지하는 데 쓰일 수 있긴 하지만, 어떤 소멸자도 예외를 던지며 종료하게 두는 건 일반적으로 나쁜 관행으로 여겨져요. 그럼에도 이 기능은 SOCI나 Galera 3 같은 일부 라이브러리에서 사용돼요. 그들은 이름 없는 임시 객체의 소멸자가 그 임시를 만드는 전체 표현식 끝에서 예외를 던질 수 있는 능력에 의존해요. Library fundamental TS v3의 std::experimental::scope_success는 잠재적으로 던지는 소멸자를 가질 수 있는데, 스코프가 정상적으로 종료됐는데 종료 함수가 예외를 던질 때 예외를 던져요.

참고 (Notes)

지역 변수 같은 보통 객체의 소멸자를 직접 호출하면, 스코프 끝에서 소멸자를 다시 호출할 때 정의되지 않은 동작이 돼요.

제네릭 문맥에서 비클래스 타입의 객체에 소멸자 호출 문법을 쓸 수 있는데, 이를 의사 소멸자 호출(pseudo-destructor call) 이라고 해요. 멤버 접근 연산자 문서를 참고해요.

기능 검사 매크로 표준 기능
__cpp_trivial_union 202502L (C++26) union의 특별 멤버 함수에 대한 trivial성 요구 완화

예제 (Example)

객체들이 어떤 순서로 생성되고 파괴되는지 확인해 볼게요. 특히 자동 객체가 스코프를 벗어나고, delete가 호출되고, 정적 객체가 프로그램 끝에서 파괴되는 순서를 봐요.

#include <iostream>

struct A
{
    int i;

    A(int num) : i(num)
    {
        std::cout << "ctor a" << i << '\n';
    }

    (~A)() // 보통은 ~A()라고 씀
    {
        std::cout << "dtor a" << i << '\n';
    }
};

A a0(0);

int main()
{
    A a1(1);
    A* p;

    { // 중첩 스코프
        A a2(2);
        p = new A(3);
    } // a2가 스코프를 벗어남

    delete p; // a3의 소멸자 호출
}

출력:

ctor a0
ctor a1
ctor a2
ctor a3
dtor a2
dtor a3
dtor a1
dtor a0

생성은 a0 → a1 → a2 → a3 순서로 일어나지만, 파괴는 역순이에요. a2는 중첩 스코프를 벗어날 때, a3는 delete로, a1은 main 종료 시, a0는 프로그램 종료 시 파괴돼요. 자동·동적·정적 객체의 파괴 시점이 서로 다르다는 걸 이 출력이 그대로 보여줘요.

결함 보고 (Defect reports)

다음의 동작을 바꾸는 결함 보고가 이전에 공표된 C++ 표준에 소급 적용됐어요.

DR 적용 대상 공표된 동작 올바른 동작
CWG 193 C++98 소멸자의 자동 객체가 클래스의 기반·멤버 부분 객체 파괴보다 먼저인지 나중인지 불특정이었음 그 부분 객체들을 파괴하기 전에 파괴
CWG 344 C++98 소멸자의 선언자 문법에 결함이 있었음 특수화된 함수 선언자 문법으로 변경
CWG 1241 C++98 정적 멤버가 소멸자 실행 직후 파괴될 수 있었음 비정적 멤버만 파괴
CWG 1353 C++98 암시적으로 선언된 소멸자가 삭제되는 조건이 다차원 배열 타입을 고려하지 않았음 이 타입들을 고려
CWG 1435 C++98 소멸자 선언자 문법에서 "클래스 이름"의 의미가 불명확했음 특수화된 함수 선언자 문법으로 변경
CWG 2180 C++98 가장 파생된 클래스가 아닌 클래스의 소멸자가 가상 직접 기반 클래스의 소멸자를 호출했음 그 소멸자들을 호출하지 않음
CWG 2807 C++20 선언 지정자에 consteval이 포함될 수 있었음 금지

더 알아보기 (Learn more)

  • 복사 생략 (Copy elision) — prvalue의 임시 객체 materialize를 생략하는 규칙. 소멸자 호출 횟수에 영향을 줘요. (cppreference)
  • new 표현식 — 동적 객체 할당. delete와 짝을 이뤄 소멸자를 호출해요. (cppreference)
  • delete 표현식 — 동적 객체 파괴와 메모리 해제. (cppreference)