객체 수명

객체 수명 (Object lifetime)

모든 객체와 참조에는 수명(lifetime) 이라는 실행 시간 성질이 있어요. 어떤 객체나 참조에도 프로그램 실행 지점 중 수명이 시작하는 지점과 끝나는 순간이 존재해요.

출처: cppreference

본문

수명의 시작과 끝

객체의 수명은 다음 때 시작돼요.

  • 그 타입에 적절한 정렬과 크기를 가진 저장 공간이 확보되고,
  • 그 초기화(있으면)가 완료될 때 (생성자 없음이나 trivial 기본 생성자를 통한 기본 초기화 포함). 단 다음은 예외예요.
    • 객체가 union 멤버 또는 그 하위 객체이면, 그 union 멤버가 union 에서 초기화된 멤버이거나 활성화될 때만 수명이 시작돼요.
    • 객체가 union 객체에 중첩되어 있으면, 포함하는 union 객체가 trivial 특수 멤버 함수로 대입되거나 생성될 때 수명이 시작될 수 있어요.
    • 배열 객체는 std::allocator::allocate로 할당되면 수명도 시작될 수 있어요.

어떤 연산들은 주어진 저장 영역에 암시적 수명 타입(implicit-lifetime type)의 객체를 암시적으로 생성(implicitly create) 하여 그 수명을 시작시켜요. 암시적으로 생성된 객체의 하위 객체가 암시적 수명 타입이 아니면, 그 하위 객체의 수명은 암시적으로 시작되지 않아요.

객체의 수명은 다음 때 끝나요.

  • 비클래스 타입이면 객체가 파괴될 때 (의사 소멸자 호출을 통해서일 수도 있어요), 또는
  • 클래스 타입이면 소멸자 호출이 시작될 때, 또는
  • 객체가 차지하던 저장 공간이 해제되거나, 그 안에 중첩되지 않은 객체에 의해 재사용될 때

객체의 수명은 그 저장 공간의 수명과 같거나 그 안에 중첩돼요. 저장 기간(storage duration)을 봐요.

참조의 수명은 초기화가 완료될 때 시작되고 스칼라 객체처럼 끝나요.

주의: 가리켜진 객체의 수명이 참조의 수명보다 먼저 끝날 수 있는데, 이게 매달린 참조(dangling reference)를 가능하게 해요.

비정적 데이터 멤버와 기반 하위 객체의 수명은 클래스 초기화 순서에 따라 시작하고 끝나요.

임시 객체 수명 (Temporary object lifetime)

임시 객체(temporary object) 는 prvalue 가 glvalue 로 쓰일 수 있도록 구체화(materialized)될 때 생성돼요. 이는 다음 상황에서 일어나요 (C++17부터).

  • prvalue 에 참조를 바인딩할 때
  • 중괄호로 감싼 이니셜라이저 목록에서 std::initializer_list<T> 타입 객체를 초기화할 때 (C++11부터)
  • 함수에서 prvalue 를 반환할 때
  • prvalue 를 만드는 변환(T(a, b, c)T{} 포함)
  • 람다 표현식 (C++11부터)
  • 이니셜라이저의 변환이 필요한 복사 초기화, 다른 타입이지만 변환 가능한 타입으로의 참조 초기화 또는 비트 필드로의 참조 초기화 (C++17까지)
  • 클래스 prvalue 의 비정적 데이터 멤버에 접근할 때
  • 배열 prvalue 에 배열-포인터 변환을 수행하거나 첨자 연산을 할 때
  • sizeoftypeid에서 prvalue 가 비평가 피연산자일 때
  • prvalue 가 버려진 값 표현식으로 나타날 때

임시 객체의 구체화는 불필요한 임시 객체를 만들지 않기 위해 보통 가능한 한 오래 지연돼요. 복사 생략(copy elision)을 봐요. (C++17부터)

타입 T의 객체가 잠재적으로 평가되는 함수 호출에 전달되거나 반환될 때, T가 다음 중 하나이면 구현은 함수 매개변수나 결과 객체를 담는 임시 객체를 만들 수 있어요.

  • 스칼라 타입 (C++26부터)
  • 다음 조건을 모두 만족하는 클래스 타입:
    • T가 적격한 복사 또는 이동 생성자를 하나 이상 가진다.
    • T의 각 적격 복사/이동 생성자가 trivial 하다.
    • T의 소멸자가 trivial 하거나 삭제되었다. (C++26까지)

임시 객체는 이렇게 생성돼요.

  • 첫 번째 그런 임시 객체는 각각 함수 인자나 반환 값에서 생성돼요.
  • 그다음 각 임시 객체는 이전 것에서, T가 스칼라 타입이면 직접 초기화로, 그렇지 않으면 적격 trivial 생성자를 써서 초기화돼요.
  • 함수 매개변수나 반환 객체는 마지막 임시 객체에서, T가 스칼라 타입이면 직접 초기화로, 그렇지 않으면 적격 trivial 생성자를 써서 초기화돼요.
  • 모든 경우에서, 그 생성자가 접근 불가능하거나 오버로드 결정이 객체의 복사나 이동을 수행하도록 선택하지 않더라도 적격 생성자가 사용돼요. (C++26부터)

이런 허용은 객체를 레지스터로 함수에 전달하거나 함수에서 반환할 수 있게 하기 위해 주어져요. (C++17부터)

모든 임시 객체는 (어휘적으로) 생성 지점을 포함하는 전체 표현식(full-expression)을 평가하는 마지막 단계로 파괴돼요. 임시 객체가 여러 개 생성되면 생성 순서의 반대 순서로 파괴돼요. 이는 그 평가가 예외를 던지며 끝나도 마찬가지예요.

이 규칙에는 다음 예외가 있어요.

  • 임시 객체의 수명은 참조에 바인딩하여 연장될 수 있어요. 자세한 내용은 참조 초기화를 봐요.
  • 배열의 요소를 초기화하거나 복사하는 데 쓰이는 기본 또는 복사 생성자의 기본 인자를 평가할 때 생성된 임시 객체의 수명은 배열의 다음 요소 초기화가 시작되기 전에 끝나요.
  • (구조적 바인딩 선언의 이니셜라이저가 도입한 고유 이름을 가진 변수의) 구조적 바인딩 선언에서 생성된 임시 객체의 수명은 구조적 바인딩 선언의 끝까지 연장돼요. (C++17부터)
  • range-for 문의 범위 이니셜라이저에서 생성된 임시 객체로서, 그렇지 않으면 범위 이니셜라이저 끝에 파괴될 임시 객체의 수명은 반복문 본문의 끝까지 연장돼요. (C++23부터)

저장 공간 재사용 (Storage reuse)

프로그램은 객체의 소멸자를 명시적으로 호출해 그 객체의 수명을 끝낼 수 있고, 객체가 trivial 소멸자를 가지면 객체의 저장 공간을 할당 해제해 수명을 안전하게 끝낼 수도 있어요. 그러나 변수인 객체의 수명을 명시적으로 끝내면, 소멸자가 암시적으로 호출되기 전에(자동 객체는 범위 종료나 예외로, 스레드 지역 객체는 스레드 종료로,C++11부터, 정적 객체는 프로그램 종료로) 같은 타입의 새 객체를 제자리(예: placement new)에 생성해야 해요. 그렇지 않으면 동작은 미정의예요.

class T {}; // trivial

struct B
{
    ~B() {} // non-trivial
};

void x()
{
    long long n; // automatic, trivial
    new (&n) double(3.14); // reuse with a different type okay
} // okay

void h()
{
    B b; // automatic non-trivially destructible
    b.~B(); // end lifetime (not required, since no side-effects)
    new (&b) T; // wrong type: okay until the destructor is called
} // destructor is called: undefined behavior

정적, 스레드 지역(C++11부터), 또는 자동 저장 기간의 const 완전 객체가 차지했거나 차지하는 저장 공간을 재사용하는 것은 미정의 동작이에요. 그런 객체는 읽기 전용 메모리에 저장될 수 있기 때문이에요.

struct B
{
    B(); // non-trivial
    ~B(); // non-trivial
};
const B b; // const static

void h()
{
    b.~B(); // end the lifetime of b
    new (const_cast<B*>(&b)) const B; // undefined behavior: attempted reuse of a const
}

new 표현식을 평가할 때, 저장 공간은 할당 함수에서 반환된 뒤, new 표현식의 이니셜라이저 평가 전에 재사용된 것으로 간주돼요.

struct S
{
    int m;
};

void f()
{
    S x{1};
    new(&x) S(x.m); // undefined behavior: the storage is reused
}

다른 객체가 차지했던 주소에 새 객체가 생성되면, 원래 객체의 모든 포인터, 참조, 이름은 자동으로 새 객체를 가리켜요. 그리고 새 객체의 수명이 시작되면 그 새 객체를 조작하는 데 쓸 수 있어요. 단 원래 객체가 새 객체로 투명하게 대체 가능(transparently replaceable) 할 때만 그래요.

다음 조건을 모두 만족하면 객체 x는 객체 y로 투명하게 대체 가능해요.

  • y의 저장 공간이 x가 차지했던 저장 위치를 정확히 덮는다.
  • yx와 같은 타입이다 (최상위 cv-한정자 무시).
  • x가 완전한 const 객체가 아니다.
  • xy도 기반 클래스 하위 객체가 아니고, [[no_unique_address]]로 선언된 멤버 하위 객체도 아니다 (C++20부터).
  • 다음 조건 중 하나를 만족한다.
    • xy가 모두 완전 객체이다.
    • xy가 각각 객체 oxoy의 직접 하위 객체이고, oxoy로 투명하게 대체 가능하다.
struct C
{
    int i;
    void f();
    const C& operator=(const C&);
};

const C& C::operator=(const C& other)
{
    if (this != &other)
    {
        this->~C();          // lifetime of *this ends
        new (this) C(other); // new object of type C created
        f();                 // well-defined
    }
    return *this;
}

C c1;
C c2;
c1 = c2; // well-defined
c1.f();  // well-defined; c1 refers to a new object of type C

위 조건이 만족되지 않으면, 포인터 최적화 장벽인 std::launder를 적용해 새 객체에 대한 유효한 포인터를 얻을 수 있어요.

struct A
{
    virtual int transmogrify();
};

struct B : A
{
    int transmogrify() override { ::new(this) A; return 2; }
};

inline int A::transmogrify() { ::new(this) B; return 1; }

void test()
{
    A i;
    int n = i.transmogrify();
    // int m = i.transmogrify(); // undefined behavior:
    // the new A object is a base subobject, while the old one is a complete object
    int m = std::launder(&i)->transmogrify(); // OK
    assert(m + n == 3);
}

(C++17부터)

마찬가지로, 클래스 멤버나 배열 요소의 저장 공간에 객체가 생성되면, 그 생성된 객체는 원래 객체의 포함 객체의 하위 객체(멤버 또는 요소)일 뿐이야. 단:

  • 포함 객체의 수명이 시작되었고 끝나지 않았고,
  • 새 객체의 저장 공간이 원래 객체의 저장 공간을 정확히 덮고,
  • 새 객체가 원래 객체와 같은 타입일 때 (cv-한정 무시).

저장 공간 제공 (Providing storage)

특수한 경우로, 객체는 unsigned char 또는 std::byte(C++17부터) 배열 안에 생성될 수 있는데(이때 배열이 객체의 저장 공간을 제공한다고 말해요), 단:

  • 배열의 수명이 시작되었고 끝나지 않았고,
  • 새 객체의 저장 공간이 배열 안에 완전히 들어맞고,
  • 배열 안에 이 제약을 만족하는 다른 배열 객체가 중첩되어 있지 않을 때.

배열의 그 부분이 이전에 다른 객체의 저장 공간을 제공했다면, 그 객체의 수명은 저장 공간이 재사용되었으므로 끝나요. 그러나 배열 자체의 수명은 끝나지 않아요 (그 저장 공간은 재사용된 것으로 간주되지 않아요).

template<typename... T>
struct AlignedUnion
{
    alignas(T...) unsigned char data[max(sizeof(T)...)];
};

int f()
{
    AlignedUnion<int, char> au;
    int* p = new (au.data) int;     // OK, au.data provides storage
    char* c = new (au.data) char(); // OK, ends lifetime of *p
    char* d = new (au.data + 1) char();
    return *c + *d; // OK
}

수명 밖 접근 (Access outside of lifetime)

객체의 수명이 시작되기 전이지만 그 객체가 차지할 저장 공간이 할당된 후, 또는 객체의 수명이 끝난 후이지만 그 객체가 차지하던 저장 공간이 재사용되거나 해제되기 전에, 그 객체를 지정하는 glvalue 표현식의 다음 사용의 동작은 미정의예요. 단 객체가 생성되거나 파괴되는 중인 경우는 예외예요 (별도의 규칙 집합이 적용돼요).

  • lvalue-to-rvalue 변환 (예: 값을 받는 함수에 대한 함수 호출)
  • 비정적 데이터 멤버 접근 또는 비정적 멤버 함수 호출
  • 가상 기반 클래스 하위 객체에 참조 바인딩
  • dynamic_cast 또는 typeid 표현식

위 규칙은 포인터에도 적용돼요 (가상 기반에 참조 바인딩은 가상 기반 포인터로의 암시적 변환으로 대체돼요). 여기에 추가 규칙 두 개가 있어요.

  • 객체가 없는 저장 공간에 대한 포인터의 static_cast는 (가능하면 cv-한정된) void*로 캐스팅할 때만 허용돼요.
  • 객체가 없는 저장 공간에 대한 포인터로서 (가능하면 cv-한정된) void*로 캐스팅된 것은, (가능하면 cv-한정된) char, (가능하면 cv-한정된) unsigned char, 또는 (가능하면 cv-한정된) std::byte(C++17부터) 포인터로만 static_cast할 수 있어요.

생성과 파괴 중에는 일반적으로 비정적 멤버 함수를 호출하고, 비정적 데이터 멤버에 접근하고, typeiddynamic_cast를 쓰는 것이 허용돼요. 그러나 수명이 아직 시작되지 않았거나(생성 중) 이미 끝났기 때문에(파괴 중) 특정 연산만 허용돼요. 한 가지 제약으로 생성·파괴 중 가상 함수 호출을 봐요.

참고 (Notes)

CWG 이슈 2256의 해결 전까지, 수명 종료 규칙은 비클래스 객체(저장 기간의 끝)와 클래스 객체(생성 순서의 역순) 사이에서 달랐어요.

struct A
{
    int* p;
    ~A() { std::cout << *p; } // undefined behavior since CWG2256: n does not outlive a
                              // well-defined until CWG2256: prints 123
};

void f()
{
    A a;
    int n = 123; // if n did not outlive a, this could have been optimized out (dead store)
    a.p = &n;
}

RU007의 해결 전까지는, const-한정 타입이나 참조 타입의 비정적 멤버가 포함 객체가 투명하게 대체 가능해지는 것을 막았고, 이는 std::vectorstd::deque를 구현하기 어렵게 만들었어요.

struct X { const int n; };
union U { X x; float f; };

void tong()
{
    U u = { {1} };
    u.f = 5.f;                          // OK: creates new subobject of 'u'
    X *p = new (&u.x) X {2};            // OK: creates new subobject of 'u'
    assert(p->n == 2);                  // OK
    assert(u.x.n == 2);                 // undefined until RU007:
                                        // 'u.x' does not name the new subobject
    assert(*std::launder(&u.x.n) == 2); // OK even until RU007
}

결함 보고 (Defect reports)

동작 변경 결함 보고로 주요하게 조정된 내용은 이래요. 비trivial 생성자 없이도 다른 초기화에서 수명이 시작되게 함(CWG 119), 기본 인자의 임시 객체 수명이 배열의 다음 요소 초기화 전에 끝나게 함(CWG 201, CWG 2107, CWG 124), 수명 밖 객체에 대한 cv-한정 char&/unsigned char& static_cast 허용(CWG 274), 수명 밖 객체 사용의 여러 미정의 동작을 정의함으로 만듦(CWG 597), 참조 수명이 초기화 시점에 시작되도록 함(CWG 2012), trivial 소멸자 객체 수명 규칙 일관화(CWG 2256), 한 객체의 저장 공간을 제공하는 배열은 하나뿐이게 함(CWG 2470), char[] 안에서는 객체가 암시적으로 생성되지 않게 함(CWG 2489), 저장 재사용으로 소멸자가 호출되지 않을 때의 미정의 동작을 정의함으로 만듦(CWG 2523), placement new 의 저장 재사용 시점 명확화(CWG 2721), range-for 수명 연장에서 함수 매개변수 객체가 임시 객체로 간주되지 않게 함(CWG 2849), 예외 객체가 임시 객체가 아니게 함(CWG 2854), 구조적 바인딩 선언의 임시 객체 수명 연장(CWG 2867), unsigned char 배열 안 객체 생성이 저장 재사용이 아니게 함(P0137R1), 의사 소멸자 호출이 객체를 파괴하도록 함(P0593R6), 투명 대체 가능성 제한 제거(P1971R0), 투명 대체 가능성이 원래 구조 유지를 요구하도록 함(P2103R0) 등이에요.

더 알아보기 (Learn more)