C와 C++의 수명 의미론 차이와 start_lifetime_as/construct_at/배치 new의 역할을 짚으며, 이전 글의 아레나 할당기를 C++20의 배열 배치 new로 수정해 포인터 출처 문제를 해결한다. 또한 컴파일러들의 placement new[] 오버헤드 문제와 소멸자 미호출 상태에서의 수명 재사용 허용 범위를 논의한다.
2025년 9월 30일 nullprogram.com/blog/2025/09/30/
Patrice Roy의 새 책, C++ Memory Management 덕분에 객체 수명에 더 민감해졌다. C++는 C보다 수명에 엄격하며, C에서 건전한(정상) 것으로 여겨지는 교과서적 메모리 관리가 C++에서는 덜 건전하다 — 내가 생각했던 것보다 훨씬. 이 책은 아레나 할당의 한 형태를 다루지만, 너무 희석된 형태라 실제 이점이 전혀 없다. (다른 부분의 정확성에도 불구하고, 후반부는 적절한 검사 없이 가득한 정수 오버플로로 도배되어 있고, 끝부분에는 검사를 무효화하는 포인터 오버플로도 있다.) 그럼에도 나는 새로운 통찰에 감사하며, 내가 만든 C++ 아레나 할당을 다시 돌아보게 되었다. 새 시각에서 보니 나도 미묘하게 잘못했던 부분이 있었다!
대부분의 C++ 프로그래머에게는 놀랍겠지만, 언어 변호사들에게는 놀랍지 않은 사실: 관용적인 C의 메모리 할당은 최근까지 C++에서 잘못된 형태였다:
int *newint(int v)
{
int *r = (int *)malloc(sizeof(*r));
if (r) {
*r = v; // <-- C++20 이전에는 정의되지 않은 동작
}
return r;
}
이 프로그램은 객체를 위한 메모리를 할당하지만 수명을 시작시키지 않는다. 수명이 없는 상태에서의 대입은 무효다. 포인터 캐스트는 C++에서 훨씬 더 의심스러우며, 수명 의미론 때문에 많은 경우 잘못된 코드임을 시사한다. (명확히 하자면, 나는 이런 의미론을 옹호하는 것이 아니라, 주어진 사실 위에서 추론하고 있을 뿐이다.) C++20은 malloc 등 몇몇에 대한 특별한 예외를 만들어 주었지만, 일반적으로 이런 문제를 해결하기 위한 것이 바로 최신의 start_lifetime_as (및 유사 함수), 조금 더 오래된 construct_at, 혹은 고전적인 배치 new다. 이들은 모두 수명을 시작시킨다. 마지막 방법은 다음과 같다:
int *newint(int v)
{
void *r = malloc(sizeof(int));
if (r) {
return new(r) int{v};
}
return nullptr;
}
이는 C/C++ 폴리글럿으로서는 별로 좋지 않은데, 어차피 예전의 서로 다른 의미론상 매크로 없이는 불가능했기 때문이다. 사실상 꼼수에 가깝다. 중요한 디테일: 수정된 버전은 캐스트가 없고, new의 결과를 반환한다. 이것이 중요한 이유는 new가 반환한 포인터만 새 수명에 결부된 포인터가 되며, r는 그렇지 않기 때문이다. r의 출처(provenance)에 영향을 미치는 부수효과는 전혀 없어서, 언어 차원에서는 여전히 r는 원시 메모리를 가리키는 것으로 간주된다.
이를 염두에 두고 지난번의 내 아레나를 다시 보자. 이는 최근 변경의 혜택을 받지 못한다. C 표준 라이브러리의 특례 함수들에 속하지 않기 때문이다.
struct Arena {
char *beg;
char *end;
};
template<typename T>
T *alloc(Arena *a, ptrdiff_t count = 1)
{
ptrdiff_t size = sizeof(T);
ptrdiff_t pad = -(uintptr_t)a->beg & (alignof(T) - 1);
assert(count < (a->end - a->beg - pad)/size); // OOM 정책
T *r = (T *)(a->beg + pad);
a->beg += pad + count*size;
for (ptrdiff_t i = 0; i < count; i++) {
new((void *)&r[i]) T{};
}
return r;
}
봐라, 배치 new다! 더 멋진 인터페이스를 만들려고 그랬는데, 운 좋게도 수명을 적절히 시작시키고 있었다. 하지만 잘못된 포인터를 반환하고 있다. 이 할당기는 새 수명으로 ‘축복’된 포인터를 버리고 있는 셈이다. 두 포인터는 같은 주소를 갖지만 출처(provenance)가 다르다. 그 차이는 중요하다. 그런데 new를 여러 번 호출하고 있는데, 이걸 어떻게 고쳐야 할까? 배열 new를 쓰면 되잖아.
template<typename T>
T *alloc(Arena *a, ptrdiff_t count = 1)
{
ptrdiff_t size = sizeof(T);
ptrdiff_t pad = -(uintptr_t)a->beg & (alignof(T) - 1);
assert(count < (a->end - a->beg - pad)/size); // OOM 정책
void *r = a->beg + pad;
a->beg += pad + count*size;
return new(r) T[count]{};
}
와… 이게 전반적으로 훨씬 더 낫다. 명시적 캐스트도 없고, 루프도 없다. 처음부터 왜 이렇게 생각하지 못했을까? 단점이라면 emplace 스타일로 생성자 인자를 전달(forward)할 수 없다는 것 — 완벽 전달(perfect forwarding) 때문에 애먹었던 바로 그 부분 — 하지만 그게 오히려 더 낫다. 한 번 이상 전달하는 건 안전하지 않았고, new[]를 쓰니 그 점이 더 분명해졌다.
주의: 이것은 C++20부터만 동작하며, 엄밀히는 다음 연산자와 함께일 때만 해당한다:
operator
new[](size_t, void *)
그 밖의 배치 new[]는 _배열 오버헤드_가 필요할 수도 있다. 예컨대 delete[]가 비자명 소멸자를 호출할 수 있도록 배열 크기를 앞에 붙인다. 이 오버헤드는 알 수 없고, 따라서 제공하거나 올바르게 정렬하는 것이 불가능하다. 배치 new[]에 오버헤드가 붙는 건 말이 안 되지만, 이 글을 쓰는 시점 현재 주요 3대 C++ 컴파일러 모두 그렇게 하고 있으며, 사실상 사용자 정의 배치 new[]를 망가뜨리고 있다.
이제 수명에 대해 생각하고 있으니 반대쪽 끝은 어떨까? 내 아레나는 설계상 소멸자를 호출하지 않고, 기술적으로는 아직 살아 있는 객체 위에 새 수명을 시작한다. 이것이 정의되지 않은 동작일까? 내가 보기엔 이는 허용된다. 비자명 소멸자의 경우에도 마찬가지인데, 자원을 누수할 수 있다는 단서가 붙는다. 이 경우 자원은 아레나가 관리하는 메모리이므로, 물론 괜찮다.
결과적으로 포인터 출처 문제를 해결하는 과정에서 더 깔끔한 정의까지 얻게 되었다. 책을 읽고 얻은 멋진 성과다! 조사하는 동안, 이전 글에 훌륭한 조언과 피드백을 직접 주셨던 Jonathan Müller가 수명에 대해 이야기한 것이 불과 몇 주 뒤였다는 것도 눈에 띄었다. 둘 다 추천한다.