53. 범위 기반 for 루프의 임시 객체 수명 함정 (dangling)
난이도 상내 리뷰 · C++
해설 · C++
해설 — 범위 기반 for 루프의 임시 객체 수명 함정 (dangling)
난이도: 상
요약
범위 기반 for는 auto&& __range = <range-init>; 로 시작하도록 탈설탕된다. C++17/20에서 수명 연장(lifetime extension)은 __range가 직접 바인딩한 임시에만 적용된다. loopA는 __range가 MakePacket() 임시(Packet) 자체에 바인딩하므로 루프 동안 임시가 살아있어 안전하다. 반면 loopB는 __range가 payload()가 반환한 임시의 멤버에 대한 참조에 바인딩하는데, 이 참조는 임시를 연장하지 못한다 → MakePacket() 임시가 전체 for 문(초기화식 끝)에서 파괴되고, 루프 본문은 이미 파괴된 벡터를 순회한다(댕글링, UB).
핵심 개념
범위 기반 for for (T x : R) body 는 대략 다음과 같이 확장된다(C++11~20):
{
auto&& __range = R; // (핵심) 여기서만 수명 연장이 적용됨
auto __begin = begin(__range);
auto __end = end(__range);
for (; __begin != __end; ++__begin) {
T x = *__begin;
body;
}
}
R이 prvalue 임시면__range(참조)가 그 임시에 바인딩되어 임시 수명이__range스코프(=for 문 전체)로 연장된다 → 안전.R이 임시의 하위객체/멤버를 반환하는 함수 호출이면,__range는 그 반환 참조에 바인딩될 뿐 **바깥 임시(부모 객체)**를 연장하지 못한다. 전체 표현식 평가가 끝나면 부모 임시가 소멸 →__range는 댕글링.
단계별 내부 동작
loopA:__range = MakePacket().payload_—payload_는 임시 Packet의 멤버지만, C++는 여기서__range가 참조하는 **완전한 임시(Packet)**를 연장한다(멤버 접근을 통한 바인딩이라도 부모 임시가 대상). 루프 내내 유효.loopB:__range = MakePacket().payload()—payload()는const vector&를 반환한다.__range는 이 반환 참조에 바인딩되고, 반환 참조는 부모 임시의 수명을 연장하지 못한다.MakePacket()임시는 초기화식 끝에서 파괴되고, 이후begin/end·역참조는 해제된 메모리 접근.- 실측 증상: 최적화 수준·플랫폼에 따라 정상처럼 보이거나, 쓰레기 값·크래시. ASan을 켜면
heap-use-after-free로 잡힌다.
흔한 오해·함정
- "범위 기반 for가 알아서 임시를 살려준다" — 함수가 참조를 반환하는 경우엔 아니다. getter 체인(
obj().items(),map.at(k).list()등)이 대표적 지뢰. - 표준 라이브러리에서도
for (auto c : std::string("x") + "y")처럼 임시를 만드는 건 안전하지만,for (auto c : getConfig().name())처럼 참조 반환 getter를 임시에 대고 부르면 위험.
수정 방법 (지금 당장)
Packet pkt = MakePacket(); // 이름 있는 객체로 수명 확보
for (int x : pkt.payload()) { /* 안전 */ }
또는 값으로 복사해 순회, 혹은 함수가 참조 대신 값을 반환하도록 설계.
C++23 완화
C++23은 범위 기반 for의 초기화식에서 생성된 임시들의 수명을 루프 전체로 연장하도록 규칙을 바꿔 loopB 같은 패턴의 대표적 댕글링을 없앴다(P2718R0). 다만 여전히 컴파일러/표준 모드 의존적이므로, 이식성 있는 코드는 임시를 이름 있는 변수로 받는 습관이 안전하다.
면접 포인트
- 범위 기반 for의 desugaring과
auto&& __range의 역할. - 수명 연장이 "직접 바인딩된 임시"에만 적용되고 "참조 반환값"에는 적용되지 않는 이유.
- C++23 P2718 변경과, 그 전까지의 안전한 관용구(임시를 named 변수로).