C++14 초기화 캡처: move 캡처와 unique_ptr을 람다에 넘기는 패턴
이 글의 핵심
C++14 초기화 캡처([x = expr])의 초기화 규칙, 이동 캡처와 unique_ptr, 클로저의 복사 가능성과 std::function 제약, 완벽 전달과 C++20 팩 캡처, 이동이 섞인 호출의 평가 순서 함정까지 정리합니다.
초기화 캡처(init-capture)란?
C++14부터 람다의 캡처 목록에 이름 = 표현식 형태를 쓸 수 있습니다. 이를 초기화 캡처(init-capture)라고 부릅니다. 클로저 객체 안에 지정한 이름의 멤버를 만들고, 오른쪽 표현식의 결과로 그 멤버를 초기화합니다.
int factor = 10;
auto f = [factor = factor * 2]() { return factor; }; // 멤버 factor는 20
std::cout << f() << '\n'; // 20
std::cout << factor << '\n'; // 10 (바깥 변수는 그대로)
컴파일러가 만드는 클로저 타입을 손으로 풀어 쓰면 대략 다음과 같습니다. 이름과 생성 방식은 구현마다 다르지만 동작을 이해하는 데는 이 모델로 충분합니다.
// 개념적 모델
class __lambda_closure {
int factor; // 캡처 목록 왼쪽의 이름
public:
explicit __lambda_closure(int v) : factor(v) {}
int operator()() const { return factor; }
};
int factor = 10;
__lambda_closure f(factor * 2); // 오른쪽 표현식은 람다가 만들어지는 시점에 평가
여기서 알아 둘 점은 세 가지입니다. 캡처 목록 왼쪽의 이름은 클로저 멤버의 이름이고, 오른쪽 표현식은 람다 본문이 실행될 때가 아니라 람다 표현식이 평가되는 시점에 한 번 계산되며, 바깥 factor와 멤버 factor는 이름만 같은 별개의 변수입니다. 오른쪽 표현식은 람다 바깥 스코프에서 해석되므로 factor * 2의 factor는 바깥 변수입니다.
초기화 규칙과 타입 추론
표준은 [x = expr]을 “auto x = expr;로 선언한 변수를 캡처한 것처럼” 동작한다고 정의합니다. 그래서 두 가지가 따라 나옵니다.
첫째, 타입은 auto 규칙으로 추론됩니다. 참조와 최상위 const가 벗겨지므로 const std::string&을 넘겨도 멤버는 std::string 값입니다. 참조 멤버가 필요하면 [&r = expr]처럼 &를 붙입니다.
둘째, = 형태는 복사 초기화이고 [x(expr)], [x{expr}] 형태는 직접 초기화입니다. 차이는 explicit 생성자가 필요한 경우에 드러납니다. 복사 초기화에서는 explicit 변환 생성자가 후보에서 빠지기 때문입니다. 또 [x = {1, 2}]는 auto 규칙에 따라 std::initializer_list<int>로 추론된다는 점도 주의해야 합니다.
복사인지 이동인지는 캡처라서 정해지는 것이 아니라, 일반 변수 초기화와 똑같이 expr의 값 범주로 생성자가 선택되어 결정됩니다.
std::vector<int> v{1, 2, 3};
auto fa = [x = v]() { return x.size(); }; // lvalue → 복사 생성자
auto fb = [x = std::move(v)]() { return x.size(); }; // xvalue → 이동 생성자
auto fc = [x = std::vector<int>{9, 8, 7}]() { return x[0]; };
// prvalue → C++17부터는 복사 생략이 보장되어 멤버가 바로 생성됨
std::move는 아무것도 옮기지 않습니다. 인자를 rvalue 참조로 캐스팅해서 이동 생성자가 선택되게 할 뿐이고, 실제 이동은 멤버 초기화에서 일어납니다. 이동 후 v는 “유효하지만 지정되지 않은 상태”이므로 다시 값을 대입하기 전에는 내용을 가정하면 안 됩니다.
C++11 캡처와 비교
C++11의 캡처는 바깥 이름을 그대로 복사하거나 참조하는 것만 가능했습니다.
| 문법 | 의미 |
|---|---|
[x] | 바깥 x를 복사 |
[&x] | 바깥 x를 참조 |
[=] | 사용한 변수를 모두 복사 |
[&] | 사용한 변수를 모두 참조 |
그래서 “x를 두 배 한 값만 클로저에 넣고 싶다”면 지역 변수를 하나 더 만들어야 했고, unique_ptr처럼 복사할 수 없는 객체를 클로저 안으로 옮길 방법은 아예 없었습니다. C++11에서는 shared_ptr로 감싸거나 std::bind에 이동된 인자를 넘기는 우회로를 썼습니다.
int x = 5;
// C++11
int doubled = x * 2;
auto f11 = [doubled]() { return doubled; };
// C++14
auto f14 = [value = x * 2]() { return value; };
Move 캡처와 unique_ptr
초기화 캡처가 가장 많이 쓰이는 곳은 이동 전용 자원(unique_ptr, std::thread, 파일 핸들 래퍼 등)을 클로저로 옮기는 경우입니다. 복사 캡처 [ptr]은 unique_ptr의 복사 생성자가 삭제되어 있어 컴파일되지 않고, 참조 캡처 [&ptr]은 람다가 원래 스코프보다 오래 살면 댕글링이 됩니다.
그림: 실제 실행 결과 — 초기화 캡처로 unique_ptr을 람다 안으로 옮기면 원래 변수는 비고, 람다는 복사 불가 타입이 되어 std::function에는 담을 수 없다. (환경: Ubuntu 24.04 · g++ 13.3)
auto ptr = std::make_unique<int>(42);
auto work = [p = std::move(ptr)]() { return *p; };
work(); // 42
assert(!ptr); // 바깥 ptr은 비어 있음 (unique_ptr의 이동 후 상태는 nullptr로 보장)
unique_ptr은 이동 후 반드시 nullptr이 된다고 표준이 보장하므로 if (ptr) 검사가 의미 있습니다. 반면 std::vector나 std::string은 이동 후 상태가 지정되어 있지 않으니 비어 있다고 가정하면 안 됩니다.
람다를 함수 밖으로 내보낼 때
// ❌ 참조 캡처: 함수가 끝나면 ptr이 파괴되어 댕글링
auto make_dangerous() {
auto ptr = std::make_unique<int>(42);
return [&ptr]() { return *ptr; };
}
// ✅ 이동 캡처: 람다가 소유권을 가짐
auto make_safe() {
auto ptr = std::make_unique<int>(42);
return [p = std::move(ptr)]() { return *p; };
}
스레드로 넘길 때
void start_worker() {
std::vector<int> data = load_large_data();
// ❌ 스레드가 함수보다 오래 살면 data는 이미 파괴됨
// std::thread([&data] { process(data); }).detach();
// ✅ 버퍼 소유권을 스레드로 옮김 (원소 복사 없음)
std::thread([data = std::move(data)] { process(data); }).detach();
}
같은 예를 std::async로 만들면 결과가 달라진다는 점을 알아 두면 좋습니다. std::async(std::launch::async, ...)가 돌려준 future를 변수에 받지 않으면 그 임시 객체의 소멸자가 작업이 끝날 때까지 블록합니다. 그래서 참조 캡처를 써도 당장은 크래시가 나지 않지만, 대신 작업이 전혀 비동기로 돌지 않습니다. 반환된 future를 저장해서 진짜 비동기로 만드는 순간 참조 캡처는 다시 위험해집니다.
여러 자원을 한 번에 옮기기
auto conn = std::make_unique<Connection>();
auto cache = std::make_unique<Cache>();
std::thread worker([conn = std::move(conn), cache = std::move(cache)]() {
auto rows = conn->fetch();
cache->store(rows);
});
worker.join();
캡처 목록에서 같은 이름을 써도(conn = std::move(conn)) 문법상 문제는 없습니다. 오른쪽 conn은 바깥 변수이고, 람다 본문에서 conn은 클로저 멤버를 가리킵니다. 다만 람다 바깥에서는 여전히 이동된 바깥 conn이 보이므로, 코드가 길다면 c = std::move(conn)처럼 다른 이름을 쓰는 편이 이동 후 사용 실수를 줄입니다.
커스텀 삭제자와 함께 쓰기
struct FileCloser {
void operator()(std::FILE* f) const noexcept { if (f) std::fclose(f); }
};
using FilePtr = std::unique_ptr<std::FILE, FileCloser>;
void dump_file_async(const char* path) {
FilePtr file(std::fopen(path, "r"));
if (!file) return;
std::thread([f = std::move(file)]() {
char buf[256];
while (std::fgets(buf, sizeof buf, f.get())) std::fputs(buf, stdout);
}).join(); // 스레드 함수 객체가 파괴될 때 fclose 호출
}
삭제자가 상태 없는 함수 객체라면 대부분의 구현에서 unique_ptr<FILE, FileCloser>의 크기는 포인터 하나와 같습니다. 함수 포인터를 삭제자로 쓰면(unique_ptr<FILE, int(*)(FILE*)>) 그 포인터만큼 커집니다.
클로저의 복사 가능성과 std::function
클로저 타입의 복사·이동 가능성은 멤버의 복사·이동 가능성을 그대로 따릅니다. unique_ptr 멤버가 하나라도 있으면 클로저는 이동만 가능합니다.
auto task = [p = std::make_unique<int>(42)]() { return *p; };
// auto copy = task; // 컴파일 에러: 복사 생성자 삭제
auto moved = std::move(task); // OK
이것이 “unique_ptr을 캡처한 람다는 std::function에 안 들어간다”는 문제의 원인입니다. std::function은 그 자체가 복사 가능한 타입이라, 담는 대상의 복사 연산을 미리 만들어 둬야 하고 따라서 대상에 CopyConstructible을 요구합니다. 이 상황에서는 에러가 캡처한 줄이 아니라 <functional> 내부에서 use of deleted function ... unique_ptr(const unique_ptr&) 같은 메시지와 긴 인스턴스화 스택으로 나오기 때문에, 원인을 처음 보면 찾기 어렵습니다.
해결책은 상황에 따라 고릅니다.
std::move_only_function(C++23)은 복사 요구를 없앤 버전으로 의도에 가장 잘 맞지만, 표준 라이브러리 지원이 필요합니다. libstdc++는 GCC 12, MSVC는 VS 2022 17.2부터 제공하지만 libc++는 지원이 늦었으므로, 여러 플랫폼을 빌드한다면 가장 오래된 툴체인 기준으로 확인해야 합니다.
#include <functional>
std::vector<std::move_only_function<void()>> tasks;
tasks.push_back([p = std::make_unique<int>(1)]() { std::cout << *p << '\n'; });
std::packaged_task도 이동 전용 호출 가능 객체를 담을 수 있어, 결과를 future로 받아야 하는 경우에 자연스럽습니다. 대신 공유 상태를 힙에 할당합니다.
std::future<int> launch(std::unique_ptr<int> p) {
std::packaged_task<int()> task([owned = std::move(p)]() { return *owned * 2; });
auto fut = task.get_future();
std::thread(std::move(task)).detach();
return fut;
}
shared_ptr로 바꾸면 기존 std::function 기반 코드를 그대로 쓸 수 있지만, 참조 카운트를 원자적으로 갱신하는 비용이 생기고 “누가 이 자원을 소유하는가”가 다시 흐려집니다. 자원이 예상보다 오래 살아남는 문제가 생겼을 때 남은 소유자를 찾기도 어려워집니다.
auto shared = std::make_shared<int>(42);
std::function<int()> fn = [p = shared]() { return *p; };
완벽 전달과 팩 캡처
템플릿 함수가 받은 전달 참조(forwarding reference)를 클로저 안에 저장할 때도 초기화 캡처를 씁니다. 왼값이 들어오면 복사, 오른값이 들어오면 이동됩니다.
template <class T>
auto wrap(T&& t) {
return [v = std::forward<T>(t)]() { return v.size(); };
}
std::vector<int> a{1, 2, 3};
auto f1 = wrap(a); // 복사: a는 그대로
auto f2 = wrap(std::vector<int>{9, 8}); // 이동
std::forward는 캡처 시점에 한 번만 의미가 있습니다. 그 순간 값이 멤버로 옮겨지므로, 같은 함수 안에서 t를 다시 std::forward하면 이미 이동된 객체를 넘기게 됩니다. 또 T가 왼값 참조로 추론되더라도 auto 규칙 때문에 멤버는 참조가 아니라 값입니다. 원본을 참조로 들고 싶다면 이 패턴이 아니라 std::ref나 명시적인 참조 캡처를 써야 하고, 그때는 수명 관리 책임이 호출자에게 넘어갑니다.
가변 인자를 통째로 옮기려면 C++20의 팩 초기화 캡처를 씁니다. C++17까지는 std::tuple에 담고 std::apply로 풀어야 했습니다.
template <class F, class... Args>
auto bind_later(F f, Args&&... args) {
return [f = std::move(f), ...xs = std::forward<Args>(args)]() mutable {
return f(xs...);
};
}
...xs 위치가 이름 앞이라는 점(...xs = expr)이 다른 팩 확장 문법과 다르니 주의하세요.
mutable과 상태
람다의 operator()는 기본적으로 const입니다. 값으로 캡처한 멤버를 본문에서 바꾸려면 mutable을 붙여야 합니다.
auto counter = [n = 0]() mutable { return ++n; };
counter(); // 1
counter(); // 2
mutable 람다의 멤버는 호출 사이에 유지되는 상태입니다. 그래서 두 가지 함정이 있습니다.
하나는 복사입니다. 상태를 가진 람다를 복사하면 상태도 복사되어 따로 갑니다. std::function에 넣거나 알고리즘에 값으로 넘기는 순간 복사본의 카운터가 증가하고 원본은 그대로일 수 있습니다.
std::vector<std::function<void()>> tasks;
int total = 0;
for (int i = 0; i < 5; ++i)
tasks.push_back([total]() mutable { std::cout << ++total << ' '; });
for (auto& t : tasks) t(); // 1 1 1 1 1 — 각자 자기 복사본을 증가
// 바깥 total은 여전히 0
공유된 카운트가 필요하면 참조로 캡처하거나 shared_ptr<int>, std::atomic<int>처럼 공유 상태를 명시적으로 둡니다.
다른 하나는 스레드 안전성입니다. 같은 mutable 클로저 객체를 여러 스레드가 동시에 호출하면 멤버에 대한 데이터 레이스로 정의되지 않은 동작이 됩니다.
std::atomic<int> hits{0};
std::vector<std::thread> threads;
for (int i = 0; i < 10; ++i)
threads.emplace_back([&hits] { ++hits; }); // 공유 상태는 atomic으로
for (auto& t : threads) t.join();
흔한 실수
이동 후 바깥 변수 사용
auto ptr = std::make_unique<int>(42);
auto task = [p = std::move(ptr)]() { return *p; };
int v = *ptr; // ❌ nullptr 역참조: 정의되지 않은 동작
ptr.reset(new int(100)); // 이건 괜찮음: 이동된 unique_ptr에 새 값을 넣는 것은 정상
이동된 unique_ptr을 역참조하면 실제로는 널 포인터 역참조가 되어 Linux에서는 SIGSEGV, Windows에서는 access violation(주소 0 근처)으로 나타나는 경우가 대부분입니다. AddressSanitizer로 실행하면 SEGV on unknown address 0x000000000000으로 보고됩니다. 해제된 메모리 접근(heap-use-after-free)이 아니라는 점을 알면 원인을 더 빨리 좁힐 수 있습니다.
컴파일 단계에서 잡으려면 clang-tidy의 bugprone-use-after-move 검사가 가장 실용적입니다. MSVC는 /analyze에서 C26800(이동된 객체 사용) 경고를 냅니다. GCC에는 이에 해당하는 일반 경고 옵션이 없습니다. Clang의 -Wconsumed는 클래스에 consumable 속성을 직접 달아야 동작하므로 표준 타입의 이동 후 사용 검출용으로는 쓰기 어렵습니다.
clang-tidy file.cpp --checks='-*,bugprone-use-after-move' -- -std=c++17
이동이 섞인 함수 호출의 평가 순서
// ❌ 인자끼리의 평가 순서는 C++17에서도 지정되지 않음
pool.submit(req->id(), [r = std::move(req)]() { handle(*r); });
두 번째 인자(람다 생성, 즉 req 이동)가 먼저 평가되면 첫 번째 인자의 req->id()는 비어 있는 포인터를 역참조합니다. 컴파일러와 최적화 수준에 따라 실제 순서가 달라서, 어떤 빌드에서는 통과하고 다른 빌드에서만 크래시가 나는 형태로 나타납니다. 필요한 값을 이동 전에 지역 변수로 꺼내 두면 해결됩니다.
// ✅ 이동 전에 필요한 값을 확정
const auto id = req->id();
pool.submit(id, [r = std::move(req)]() { handle(*r); });
C++17(P0145)은 호출 대상 표현식이 인자보다 먼저 평가되도록 규칙을 바꿨습니다. 그래서 req->reply([r = std::move(req)] { ... }) 형태는 C++17부터는 안전하지만 C++14에서는 위험합니다. 다만 인자끼리의 순서는 여전히 미지정이므로, 이동이 섞인 호출에서는 필요한 값을 먼저 꺼내는 습관이 표준 버전에 상관없이 안전합니다.
의도치 않은 복사
std::vector<int> big(1'000'000);
auto a = [data = big]() { return data.size(); }; // 복사: 버퍼 새로 할당 + 원소 전부 복사
auto b = [data = std::move(big)]() { return data.size(); }; // 이동: 내부 포인터만 옮김
[data = big]은 초기화 캡처지만 big이 왼값이므로 복사입니다. 캡처 문법이라고 자동으로 이동되지 않습니다.
스냅샷과 추적을 혼동
초기화 캡처는 람다를 만든 시점의 값을 저장합니다. 바깥 객체의 이후 변화를 보고 싶다면 참조 캡처가 맞고, 그때는 수명을 보장해야 합니다.
std::vector<int> vec{1, 2, 3};
auto snapshot = [n = vec.size()]() { return n; };
auto live = [&vec]() { return vec.size(); };
vec.push_back(4);
snapshot(); // 3
live(); // 4
참조 초기화 캡처도 댕글링될 수 있음
[&r = expr]은 참조 멤버를 만듭니다. 초기화 캡처라고 해서 안전해지는 것은 아니며, 가리키는 객체가 먼저 사라지면 일반 참조 캡처와 똑같이 댕글링됩니다.
auto make_bad() {
int local = 42;
return [&r = local]() { return r; }; // ❌ local 파괴 후 댕글링
}
기본 캡처와 함께 쓰기
기본 캡처와 초기화 캡처는 섞어 쓸 수 있습니다. [=, x = x * 2]처럼 기본 캡처가 있는 상태에서 바깥 변수와 같은 이름으로 초기화 캡처를 만드는 것도 합법이며, 본문의 x는 초기화 캡처 멤버를 가리킵니다. 금지되는 것은 같은 이름을 캡처 목록에 두 번 명시하는 경우([x, x = 1])입니다.
auto ptr = std::make_unique<int>(30);
int x = 10, y = 20;
auto lambda = [&, p = std::move(ptr)]() { return x + y + *p; }; // x, y는 참조, p는 이동
성능과 크기
복사 캡처와 이동 캡처의 차이는 복잡도로 보면 명확합니다. std::vector를 복사 캡처하면 원소 수에 비례하는 O(n) 작업(새 버퍼 할당과 전체 복사)이고, 이동 캡처는 내부 포인터 몇 개를 옮기는 O(1) 작업입니다. 데이터가 커질수록 격차가 선형으로 벌어집니다. 직접 측정한다면 최적화 빌드(-O2 이상)로 하고, 람다의 반환값을 실제로 사용해야 합니다. 결과를 쓰지 않으면 컴파일러가 복사 자체를 제거해 둘 다 0에 가까운 시간이 나오기도 합니다.
클로저 객체의 크기는 대체로 멤버 크기의 합에 정렬 패딩을 더한 값입니다. 64비트 주류 구현에서 unique_ptr<T>(기본 삭제자)는 포인터 하나, shared_ptr<T>는 객체 포인터와 제어 블록 포인터로 두 개, std::vector는 포인터 세 개 크기입니다. 캡처가 없는 람다는 sizeof가 1입니다. 참조 캡처는 대부분의 구현에서 포인터로 저장되지만 표준이 이를 정해 두지는 않았고, 멤버의 선언 순서도 표준상 지정되지 않으므로 클로저의 레이아웃에 의존하는 코드는 이식성이 없습니다.
std::function은 이와 별개로 고정 크기 객체이며, 담는 클로저가 내부 버퍼보다 크면 힙에 할당합니다. 이동 캡처로 큰 데이터를 옮겨도 std::function에 넣는 순간 할당이 한 번 더 생길 수 있다는 뜻입니다.
[*this] (C++17)
멤버 함수 안의 람다에서 [this]나 [=](C++17까지 this를 암묵적으로 캡처)로 잡으면 객체 포인터만 저장합니다. 람다가 객체보다 오래 살면 댕글링입니다. C++17의 [*this]는 현재 객체의 복사본을 클로저에 저장합니다.
struct S {
int n = 0;
auto make_lambda() {
return [*this]() { return n; }; // S의 복사본이 클로저에 저장
}
};
필요한 멤버 하나만 잡고 싶다면 초기화 캡처가 더 가볍습니다. [n = n]처럼 쓰면 객체 전체가 아니라 그 값만 복사됩니다.
언어 버전별 지원
| 기능 | C++11 | C++14 | C++17 | C++20 |
|---|---|---|---|---|
기본 캡처 [x], [&x] | O | O | O | O |
초기화 캡처 [x = expr] | X | O | O | O |
제네릭 람다 [](auto x) | X | O | O | O |
[*this] 캡처 | X | X | O | O |
| constexpr 람다 | X | X | O | O |
[=, this] 명시 허용 ([=]의 암시적 this 캡처는 deprecated) | X | X | X | O |
템플릿 람다 []<typename T>(T x) | X | X | X | O |
팩 초기화 캡처 [...xs = std::move(args)] | X | X | X | O |