← 문제로

36. 람다 클로저 타입의 내부 동작과 캡처 저장

난이도 상
내 리뷰 · C++
해설 · C++

해설 — 람다 클로저 타입의 내부 동작과 캡처 저장

난이도: 상

요약

각 람다는 컴파일러가 만든 고유한 이름 없는 클래스(클로저 타입) 의 객체다. 캡처는 그 클래스의 멤버로 저장되고 본문은 operator()(기본 const)가 된다. 값 캡처는 생성 시점의 복사본을 멤버로 갖고, 참조 캡처는 참조(구현상 포인터)를 갖는다. mutableoperator()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: 참조 캡처라 outer a 를 가리킨다. a=100 이 그대로 보임 → 100.
  • l5(): 값 캡처(생성 시 복사본 10) + mutable. 첫 호출 ++a(멤버 복사본)=11, 둘째=12. 클로저는 상태를 자기 멤버에 유지한다.
  • 마지막 a=100: l5++ 는 outer a 가 아니라 클로저 멤버를 건드렸으므로 outer 는 100 그대로.

흔한 오해·함정

  • 참조/포인터 캡처의 dangling: 지역 변수를 참조 캡처한 람다를 그 변수 수명 밖으로 넘기면(예: 비동기 콜백에 등록) use-after-free. 게임 서버에서 콜백에 [&] 로 로컬을 캡처했다가 스택이 풀리면 UAF 가 난다 — 수명을 넘길 땐 값 캡처하거나 shared_ptr 를 캡처.
  • [=] 의 오해: 값 캡처라도 포인터를 값으로 캡처하면 가리키는 대상은 여전히 공유된다(얕은 복사). this 캡처([=], [this])는 포인터를 캡처하는 것이라 객체 수명이 끝나면 dangling.
  • 각 람다 타입이 다름: 같은 모양 두 람다를 한 컨테이너에 담으려면 std::function 또는 공통 함수 포인터(캡처 없는 람다만 변환 가능)가 필요. 이 타입 소거는 힙 할당/가상 호출 비용을 유발할 수 있다.
  • mutable 람다의 상태는 호출 간 유지되지만, 람다를 복사하면 상태도 복사되어 사본은 독립적으로 진행한다.

면접 포인트

  • "람다는 문법 설탕이 아니라 실제 클래스 인스턴스"임을 설명하고, 캡처가 멤버로 저장됨을 sizeof 로 근거를 댈 수 있어야 한다.
  • 값 vs 참조 캡처의 수명·가시성 차이(특히 비동기/콜백에서의 dangling)를 게임 서버 맥락으로 설명.
  • 캡처 없는 람다만 함수 포인터로 변환 가능한 이유(상태가 없어야 함)와, 상태 있는 람다를 담으려면 std::function(타입 소거) 이 필요한 이유.