36. 람다 클로저 타입의 내부 동작과 캡처 저장
난이도 상내 리뷰 · C++
해설 · C++
해설 — 람다 클로저 타입의 내부 동작과 캡처 저장
난이도: 상
요약
각 람다는 컴파일러가 만든 고유한 이름 없는 클래스(클로저 타입) 의 객체다. 캡처는 그 클래스의 멤버로 저장되고 본문은 operator()(기본 const)가 된다. 값 캡처는 생성 시점의 복사본을 멤버로 갖고, 참조 캡처는 참조(구현상 포인터)를 갖는다. mutable 은 operator() 의 const 를 떼어 멤버 복사본을 수정 가능하게 한다.
예측 출력:
1 // sizeof(l1)
4 // sizeof(l2)
8 // sizeof(l3)
8 // sizeof(l4)
10 // l2()
100 // l4()
11 // l5()
12 // l5()
100 // a
핵심 개념
- 클로저 타입은 람다마다 고유:
decltype(l1) != decltype(l2). 두 람다가 시그니처가 같아도 타입이 다르다(그래서std::function이나 함수 포인터로 타입 소거가 필요할 때가 있다). - 캡처 = 멤버 변수: 값 캡처는 그 타입 크기의 멤버, 참조 캡처는 참조(대개 포인터 크기) 멤버.
operator()는 기본 const: 값 캡처 멤버를 수정하려면mutable.
단계별 내부 동작
sizeof(l1)=1: 캡처 없는 클로저는 상태가 없다. C++ 객체는 크기 0 이 될 수 없어 최소 1바이트.sizeof(l2)=4:int a한 개를 멤버로 저장 → 4바이트.sizeof(l3)=8:int a, int b두 멤버 → 8바이트(정렬 포함).sizeof(l4)=8:a를 참조로 캡처 → 64비트에서 포인터 크기 8바이트로 저장.l2()=10:l2는 생성 시점(a==10)의 복사본을 갖는다. 이후a=100은 그 복사본과 무관 → 10.l4()=100: 참조 캡처라 outera를 가리킨다.a=100이 그대로 보임 → 100.l5(): 값 캡처(생성 시 복사본 10) +mutable. 첫 호출++a(멤버 복사본)=11, 둘째=12. 클로저는 상태를 자기 멤버에 유지한다.- 마지막
a=100:l5의++는 outera가 아니라 클로저 멤버를 건드렸으므로 outer 는 100 그대로.
흔한 오해·함정
- 참조/포인터 캡처의 dangling: 지역 변수를 참조 캡처한 람다를 그 변수 수명 밖으로 넘기면(예: 비동기 콜백에 등록) use-after-free. 게임 서버에서 콜백에
[&]로 로컬을 캡처했다가 스택이 풀리면 UAF 가 난다 — 수명을 넘길 땐 값 캡처하거나shared_ptr를 캡처. [=]의 오해: 값 캡처라도 포인터를 값으로 캡처하면 가리키는 대상은 여전히 공유된다(얕은 복사).this캡처([=],[this])는 포인터를 캡처하는 것이라 객체 수명이 끝나면 dangling.- 각 람다 타입이 다름: 같은 모양 두 람다를 한 컨테이너에 담으려면
std::function또는 공통 함수 포인터(캡처 없는 람다만 변환 가능)가 필요. 이 타입 소거는 힙 할당/가상 호출 비용을 유발할 수 있다. mutable람다의 상태는 호출 간 유지되지만, 람다를 복사하면 상태도 복사되어 사본은 독립적으로 진행한다.
면접 포인트
- "람다는 문법 설탕이 아니라 실제 클래스 인스턴스"임을 설명하고, 캡처가 멤버로 저장됨을 sizeof 로 근거를 댈 수 있어야 한다.
- 값 vs 참조 캡처의 수명·가시성 차이(특히 비동기/콜백에서의 dangling)를 게임 서버 맥락으로 설명.
- 캡처 없는 람다만 함수 포인터로 변환 가능한 이유(상태가 없어야 함)와, 상태 있는 람다를 담으려면
std::function(타입 소거) 이 필요한 이유.