← 문제로

11. std::function 의 타입 소거와 람다 캡처 저장(SBO·값 의미론)

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

해설 — std::function 의 타입 소거와 람다 캡처 저장(SBO·값 의미론)

난이도: 상

요약

std::function타입 소거 컨테이너다. 서로 다른 타입의 람다/함수를 하나의 std::function<int()> 로 담을 수 있다. 작은 캡처는 내부 소형 버퍼(SBO) 에 담겨 힙 할당이 없고, 버퍼를 넘는 큰 캡처는 힙에 할당된다. std::function값 타입이라 복사하면 담긴 호출가능 객체(캡처 상태 포함)가 복제된다.

실제 출력 (libstdc++, x86-64)

f1 (no capture)   allocs=0
f2 (int capture)  allocs=0
f3 (64B capture)  allocs=1
copy std::function -> Tracer copies=1

핵심 개념

  • 타입 소거: std::function 내부에는 (개념적으로) "호출/복사/소멸"을 어떻게 하는지 가리키는 함수 포인터(또는 vtable 류 manager)와, 호출가능 객체를 담는 저장 공간이 있다. 컴파일러는 각 구체 람다 타입마다 이 연산들을 생성해 두고, std::function 은 그 포인터를 통해 간접 호출한다. 덕분에 호출부는 람다의 구체 타입을 몰라도 된다.
  • SBO(소형 버퍼 최적화): libstdc++ 는 약 16바이트(union _Nocopy_types 크기) 까지의, nothrow 이동 가능한 호출가능 객체를 내부 버퍼에 직접 담는다.
    • f1: 무캡처 람다는 상태가 없어 SBO 에 들어감 → 0 할당.
    • f2: int 하나(4바이트) 캡처 → SBO 에 들어감 → 0 할당.
    • f3: 64바이트 캡처 → SBO 초과 → 힙 1회 할당.

단계별 내부 동작 (복사)

  1. gTracer 를 값 캡처한 람다를 담는다(Tracer 는 작아 SBO).
  2. g2 = gstd::function 의 복사 생성자를 호출 → 내부 manager 가 담긴 람다를 복사 생성한다 → 캡처된 Tracer 의 복사 생성자가 1번 호출 → 출력 Tracer copies=1.
  3. std::function 복사는 얕은 공유가 아니라 깊은 복제다. 두 함수 객체는 독립된 캡처 상태를 갖는다. (상태를 공유하려면 shared_ptr 캡처 등 별도 설계.)

흔한 오해·함정

  • "std::function 은 그냥 함수 포인터다" — 아니다. 캡처 상태를 담고, 타입 소거 를 위해 간접 호출(분기 예측·인라인 방해)과 때때로 힙 할당을 한다. 핫패스에서는 비용이 된다.
  • "람다를 std::function 에 넣어도 공짜" — 큰 캡처는 힙 할당, 호출은 간접 호출. 템플릿 인자로 콜백을 받으면(예: template<class F> void each(F&&)) 인라인되어 훨씬 빠르다. 타입을 지워야 할 때만 std::function.
  • "복사하면 캡처가 공유된다" — 복제된다. 콜백 등록 리스트에 std::function 을 복사해 넣으면 캡처 객체가 그만큼 복제됨에 주의.
  • 댕글링 캡처: [&] 참조 캡처를 std::function 에 담아 수명을 넘기면 참조 대상이 먼저 죽어 UB. 비동기 콜백에는 값/shared_ptr 캡처가 안전.
  • SBO 임계(16바이트)·할당 횟수는 구현 정의 — libc++/MSVC 는 다르다.

면접 포인트

  • 타입 소거의 메커니즘(저장부 + manager/vtable + 간접 호출)과 그 비용을 설명할 수 있는지. "왜 가상 함수와 비슷한 비용인가".
  • 게임 서버의 이벤트/콜백 시스템에서 언제 std::function(유연·타입소거) 을 쓰고 언제 템플릿 콜백/함수 포인터(제로 오버헤드) 를 쓸지 판단.
  • std::function 의 값 의미론과 캡처 수명(특히 참조 캡처 + 비동기)에서 오는 함정.