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회 할당.
단계별 내부 동작 (복사)
g는Tracer를 값 캡처한 람다를 담는다(Tracer 는 작아 SBO).g2 = g는std::function의 복사 생성자를 호출 → 내부 manager 가 담긴 람다를 복사 생성한다 → 캡처된Tracer의 복사 생성자가 1번 호출 → 출력Tracer copies=1.- 즉
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 의 값 의미론과 캡처 수명(특히 참조 캡처 + 비동기)에서 오는 함정.