const char* p = make_path().c_str(); 한 줄은 컴파일러 경고 없이 통과하고, 대개 출력도 멀쩡하게 나온다. 하지만 p가 가리키는 메모리는 이미 해제된 상태다. make_path()가 반환한 std::string은 이름 없는 임시 객체(temporary object)이고, 그 줄이 끝나는 순간 소멸하기 때문이다. 임시 객체는 암시적 변환, 값 반환, 연산 결과 등 코드에 드러나지 않는 곳에서 수시로 생겼다 사라진다. 이 글에서는 생성·소멸을 출력하는 추적용 클래스와 AddressSanitizer로 임시 객체가 언제 생기고 언제 사라지는지, 수명 연장(lifetime extension)과 복사 생략(copy elision)이 어떻게 동작하는지 g++ 13.3에서 확인한다.
이 시리즈의 다른 글
- C++ 고급 문법 (3) — inline 함수(Inline Function)
- C++ 고급 문법 (4) — 함수 객체(Function Object)
- C++ 고급 문법 (6) — 명시적 캐스팅(Explicit Casting)
- C++ 고급 문법 (7) — 생성자(Constructor)
추적용 클래스
복사하면 id에 100을, 이동하면 200을 더해 어느 객체에서 파생됐는지 출력에서 바로 보이게 했다.
#pragma once
#include <cstdio>
// 생성·복사·이동·소멸을 출력하는 추적용 클래스
struct Trace {
int id;
Trace(int i) : id(i) { std::printf(" ctor #%d\n", id); }
Trace(const Trace& o) : id(o.id + 100) { std::printf(" copy #%d -> #%d\n", o.id, id); }
Trace(Trace&& o) noexcept : id(o.id + 200) { std::printf(" move #%d -> #%d\n", o.id, id); }
~Trace() { std::printf(" dtor #%d\n", id); }
};임시 객체가 생기는 곳과 소멸 시점
#include "trace.h"
void take(const Trace& t) { std::printf(" take(#%d)\n", t.id); }
Trace make(int i) { return Trace(i); }
int main()
{
std::puts("[1] 암시적 변환");
take(1); // int -> Trace 임시 객체
std::puts("[2] 명시적 생성");
take(Trace(2));
std::puts("[3] 값 반환");
take(make(3));
std::puts("[4] 문장 끝");
}$ g++ -std=c++20 -Wall -o when when.cpp
$ ./when
[1] 암시적 변환
ctor #1
take(#1)
dtor #1
[2] 명시적 생성
ctor #2
take(#2)
dtor #2
[3] 값 반환
ctor #3
take(#3)
dtor #3
[4] 문장 끝
세 경우 모두 함수 호출이 끝나고 문장이 끝나는 지점에서 소멸한다. 임시 객체는 자신을 만든 완전 표현식(full-expression)이 끝날 때 소멸한다.
#include "trace.h"
int value(const Trace& t) { return t.id; }
int main()
{
int sum = value(Trace(1)) + value(Trace(2)); // 하나의 완전 표현식(full-expression)
std::printf(" sum = %d\n", sum);
std::puts("end of main");
}$ g++ -std=c++20 -Wall -o fullexpr fullexpr.cpp
$ ./fullexpr
ctor #1
ctor #2
dtor #2
dtor #1
sum = 3
end of main
두 임시 객체는 덧셈이 끝날 때까지 살아 있다가 생성의 역순으로 소멸한다. 두 피연산자 중 어느 쪽을 먼저 평가할지는 표준이 정하지 않는다.
수명 연장
임시 객체를 const T&나 T&&에 직접 바인딩하면 임시 객체가 참조의 수명만큼 살아남는다.
#include "trace.h"
Trace make(int i) { return Trace(i); }
int main()
{
std::puts("[const& 바인딩]");
{
const Trace& r = make(1); // 임시 객체 수명이 r의 수명으로 연장된다
std::printf(" use #%d\n", r.id);
}
std::puts("[&& 바인딩]");
{
Trace&& r = make(2);
std::printf(" use #%d\n", r.id);
}
std::puts("[값으로 받기]");
{
Trace v = make(3); // 연장이 아니라 그냥 객체 (복사·이동 없음)
std::printf(" use #%d\n", v.id);
}
}$ g++ -std=c++20 -Wall -o extend extend.cpp
$ ./extend
[const& 바인딩]
ctor #1
use #1
dtor #1
[&& 바인딩]
ctor #2
use #2
dtor #2
[값으로 받기]
ctor #3
use #3
dtor #3
세 경우 모두 블록 끝에서 소멸한다. 값으로 받아도 복사·이동이 없으므로 참조로 받을 이득이 없다(아래 복사 생략 참고).
연장되지 않는 경우
수명 연장은 임시 객체를 참조에 “직접” 묶을 때만 일어난다. 포인터를 꺼내거나, std::string_view 같은 뷰 타입에 담거나, 함수를 거쳐 참조를 돌려받으면 연장되지 않는다.
#include <cstdio>
#include <string>
std::string make_path() { return "/var/log/application/service.log"; }
int main()
{
const char* p = make_path().c_str(); // 임시 std::string은 이 줄 끝에서 소멸
std::printf("%s\n", p);
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o dangle_cstr dangle_cstr.cpp
$ ./dangle_cstr # ASan 보고 중 에러 종류와 main 위치만 발췌
ERROR: AddressSanitizer: heap-use-after-free
in main dangle_cstr.cpp:9
#include <iostream>
#include <string>
#include <string_view>
std::string make_path() { return "/var/log/application/service.log"; }
int main()
{
std::string_view sv = make_path(); // string_view는 수명을 연장하지 않는다
std::cout << sv << '\n';
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o dangle_sv dangle_sv.cpp
$ ./dangle_sv # ASan 보고 중 에러 종류와 main 위치만 발췌
ERROR: AddressSanitizer: heap-use-after-free
in main dangle_sv.cpp:10
#include <iostream>
#include <string>
const std::string& longer(const std::string& a, const std::string& b)
{
return a.size() >= b.size() ? a : b;
}
int main()
{
// 인수로 넘긴 임시 객체는 연장되지 않고 이 줄 끝에서 소멸
const std::string& r = longer(std::string(40, 'a'), std::string(20, 'b'));
std::cout << r.size() << '\n';
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o dangle_ret dangle_ret.cpp
$ ./dangle_ret # ASan 보고 중 에러 종류와 main 위치만 발췌
ERROR: AddressSanitizer: stack-use-after-scope
in main dangle_ret.cpp:13
g++ 13은 세 경우 중 함수를 거치는 마지막 경우만 -Wextra로 경고한다.
$ g++ -std=c++20 -Wall -Wextra -c dangle_cstr.cpp dangle_sv.cpp
$ g++ -std=c++20 -Wall -Wextra -c dangle_ret.cpp 2>&1 | head -3
dangle_ret.cpp: In function ‘int main()’:
dangle_ret.cpp:12:24: warning: possibly dangling reference to a temporary [-Wdangling-reference]
12 | const std::string& r = longer(std::string(40, 'a'), std::string(20, 'b'));
범위 기반 for의 함정
범위 기반 for는 범위 표현식 결과를 auto&&에 바인딩하는데, 연장되는 것은 마지막 결과(get_ports()가 반환한 참조)뿐이고 중간 임시 객체 Config는 연장되지 않는다.
#include <cstdio>
#include <vector>
struct Config {
std::vector<int> ports{80, 443, 8080};
const std::vector<int>& get_ports() const { return ports; }
};
Config load() { return Config{}; }
int main()
{
for (int p : load().get_ports()) // Config 임시 객체는 범위 초기화 직후 소멸
std::printf("%d\n", p);
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o rangefor rangefor.cpp
$ ./rangefor # ASan 보고 중 에러 종류와 main 위치만 발췌
ERROR: AddressSanitizer: stack-use-after-scope
in main rangefor.cpp:13
$ g++ -std=c++23 -Wall -g -fsanitize=address -o rangefor rangefor.cpp
$ ./rangefor # ASan 보고 중 에러 종류와 main 위치만 발췌
ERROR: AddressSanitizer: stack-use-after-scope
in main rangefor.cpp:13
C++23(P2718R0)부터는 중간 임시 객체도 루프 끝까지 연장되도록 바뀌었지만, g++ 13은 아직 구현하지 않아 -std=c++23에서도 결과가 같다. C++20의 범위 for 초기화문(init-statement)으로 임시 객체에 이름을 붙이면 어느 버전에서든 안전하다.
#include <cstdio>
#include <vector>
struct Config {
std::vector<int> ports{80, 443, 8080};
const std::vector<int>& get_ports() const { return ports; }
};
Config load() { return Config{}; }
int main()
{
for (auto cfg = load(); int p : cfg.get_ports()) // C++20 범위 for 초기화문
std::printf("%d\n", p);
}$ g++ -std=c++20 -Wall -g -fsanitize=address -o rangefor_fix rangefor_fix.cpp
$ ./rangefor_fix
80
443
8080
복사 생략
값으로 반환할 때 생기는 임시 객체는 컴파일러가 아예 만들지 않을 수 있다(copy elision). C++17부터는 prvalue 반환에 대해 이것이 보장된다.
#include "trace.h"
Trace rvo(int i) { return Trace(i); } // prvalue 반환
Trace nrvo(int i) { Trace t(i); return t; } // 이름 있는 지역 변수 반환
Trace pick(bool c)
{
Trace a(10), b(20);
return c ? a : b; // 조건 연산자 결과는 lvalue
}
Trace pick2(bool c)
{
Trace a(10), b(20);
if (c) return a; // 지역 변수 이름 반환 -> 암시적 이동
return b;
}
int main()
{
std::puts("[rvo]"); { Trace x = rvo(1); }
std::puts("[nrvo]"); { Trace y = nrvo(2); }
std::puts("[pick]"); { Trace z = pick(true); }
std::puts("[pick2]"); { Trace w = pick2(true); }
}-fno-elide-constructors로 선택적 생략을 끄고 C++14로 빌드하면 반환값 임시 객체와 그 이동이 그대로 드러난다.
$ g++ -std=c++14 -fno-elide-constructors -o elide elide.cpp
$ ./elide
[rvo]
ctor #1
move #1 -> #201
dtor #1
move #201 -> #401
dtor #201
dtor #401
[nrvo]
ctor #2
move #2 -> #202
dtor #2
move #202 -> #402
dtor #202
dtor #402
[pick]
ctor #10
ctor #20
copy #10 -> #110
dtor #20
dtor #10
move #110 -> #310
dtor #110
dtor #310
[pick2]
ctor #10
ctor #20
move #10 -> #210
dtor #20
dtor #10
move #210 -> #410
dtor #210
dtor #410
C++17 기본 빌드에서는 rvo(RVO, 보장됨)와 nrvo(NRVO, 컴파일러 재량) 모두 생성자 한 번으로 끝난다.
$ g++ -std=c++17 -o elide elide.cpp
$ ./elide
[rvo]
ctor #1
dtor #1
[nrvo]
ctor #2
dtor #2
[pick]
ctor #10
ctor #20
copy #10 -> #110
dtor #20
dtor #10
dtor #110
[pick2]
ctor #10
ctor #20
move #10 -> #210
dtor #20
dtor #10
dtor #210
pick은 반환할 객체가 실행 시점에 정해져 생략이 불가능하고, 조건 연산자 결과가 lvalue라 이동이 아닌 복사가 일어난다. pick2처럼 지역 변수 이름을 그대로 반환하면 최소한 암시적 이동(implicit move)은 적용된다.
컨테이너에 넣을 때의 임시 객체
#include "trace.h"
#include <vector>
int main()
{
std::vector<Trace> v;
v.reserve(4); // 재할당으로 인한 이동은 제외
std::puts("[push_back(Trace(1))]");
v.push_back(Trace(1)); // 임시 객체 생성 -> 이동 -> 임시 소멸
std::puts("[push_back(2)]");
v.push_back(2); // 암시적 변환도 같은 경로
std::puts("[emplace_back(3)]");
v.emplace_back(3); // 벡터 메모리에 바로 생성
std::puts("[end]");
}$ g++ -std=c++20 -Wall -o emplace emplace.cpp
$ ./emplace
[push_back(Trace(1))]
ctor #1
move #1 -> #201
dtor #1
[push_back(2)]
ctor #2
move #2 -> #202
dtor #2
[emplace_back(3)]
ctor #3
[end]
dtor #201
dtor #202
dtor #3
push_back은 인수로 받을 객체가 먼저 있어야 하므로 임시 객체 생성 → 이동 → 소멸을 거친다. emplace_back은 생성자 인수를 전달받아 벡터 메모리에 바로 생성한다.
주의사항
| 코드 패턴 | 문제 | 대응 |
|---|---|---|
f().c_str(), f().data()를 변수에 저장 | 문장 끝에서 원본 소멸, 경고 없음 | 반환값을 먼저 변수에 받는다 |
std::string_view sv = f(); | 뷰는 수명을 연장하지 않음 | 임시 문자열을 뷰에 담지 않는다 |
| 참조를 받아 참조를 반환하는 함수에 임시 객체 전달 | 반환된 참조가 dangling | 결과를 값으로 받거나 -Wextra(-Wdangling-reference) 경고 확인 |
for (x : f().member()) | C++20 이하·g++ 13에서 중간 임시 객체 소멸 | 범위 for 초기화문으로 이름을 붙인다 |
return c ? a : b; | 복사 생략·암시적 이동 모두 불가, 복사 발생 | if로 나눠 지역 변수 이름을 반환 |
return std::move(local); | NRVO를 막는다 | 지역 변수는 이름만 반환 |
| 디버그 빌드에서만 재현되지 않는 크래시 | 해제된 메모리가 우연히 그대로 남아 있음 | -fsanitize=address로 테스트 |
마무리
- 임시 객체는 자신을 만든 완전 표현식이 끝날 때 생성 역순으로 소멸한다.
const T&/T&&에 직접 바인딩할 때만 수명이 연장되며, 포인터·뷰·함수 반환 참조는 연장되지 않는다.- C++17부터 prvalue 반환은 임시 객체 없이 바로 생성되고, NRVO는 컴파일러 재량이다.
- dangling 대부분은 경고가 없으므로 AddressSanitizer로 잡는다.