C++ 람다 표현식 심화 | 클로저 타입·캡처·제네릭 연역·IIFE·실무 패턴

이 글의 핵심

람다가 단순 문법이 아니라 컴파일러가 생성하는 고유 클로저 타입임을 밝히고, 캡처·제네릭 연역·IIFE·스레드·콜백 실무까지 연결합니다.

들어가며

람다 표현식(lambda expression)은 호출 지점에서 작은 호출 가능 객체를 만드는 문법입니다. “익명 함수”라고만 이해하면 캡처한 변수의 수명, std::function의 타입 소거 비용, 제네릭 람다의 템플릿 연역 같은 문제를 놓치기 쉽습니다. 이 글은 컴파일러가 클로저 타입을 어떻게 만들고 캡처를 어떻게 저장하는지에서 출발해, 제네릭 람다, IIFE(즉시 실행 람다), 스레드와 콜백에서의 선택 기준까지 이어서 설명합니다.


람다 기초: 문법과 실행 모델

람다는 캡처 목록 [...], 매개변수 (...), 선택적 반환 타입 -> T, 본문 { ... }로 이루어집니다. 매개변수가 없으면 [] { }처럼 괄호를 생략할 수 있습니다.

#include <iostream>

int main() {
    auto add = [](int a, int b) -> int { return a + b; };
    std::cout << add(3, 5) << '\n';
    return 0;
}

반환 타입을 생략하면 본문의 return 식에서 반환 타입이 연역됩니다. 캡처가 없는 람다는 같은 시그니처의 함수 포인터로 암시적으로 변환되므로, 콜백으로 함수 포인터를 받는 C API에 그대로 넘길 수 있습니다.


람다와 클로저 타입: 컴파일러가 생성하는 것

고유한 클로저 타입

람다 표현식마다 컴파일러는 서로 다른 이름 없는 클래스 타입을 생성합니다. 그 타입의 객체가 클로저(closure)이며, auto x = [...] { ... };에 담기는 것이 그 인스턴스입니다. 표준이 정한 모델을 요약하면 다음과 같습니다.

  1. 값으로 캡처한 변수는 클로저 타입의 이름 없는 비정적 데이터 멤버가 됩니다. 참조로 캡처한 변수가 멤버로 저장되는지는 구현에 맡겨져 있지만, 개념적으로는 참조를 들고 있다고 보면 됩니다.
  2. 호출 연산자 operator()가 생성되며, 기본적으로 const 멤버 함수입니다. 그래서 값으로 캡처한 복사본은 본문 안에서 수정할 수 없습니다.
  3. mutable을 붙이면 operator()가 비const 멤버 함수가 되어 클로저 안에 저장된 복사본을 수정할 수 있습니다. 바깥의 원본이 바뀌는 것은 아닙니다.

개념적으로는 아래와 비슷한 코드가 생성된다고 생각하면 됩니다(실제 이름과 멤버 배치는 구현마다 다릅니다).

// 개념적 모델 (의사 코드)
struct /* 고유 이름 없는 타입 */ {
    int x_copy;           // [x] 값 캡처
    int& y_ref;           // [&y] 참조 캡처

    auto operator()(int a) const {
        return x_copy + y_ref + a;
    }
};

타입·복사·이동

서로 다른 람다 표현식은 본문이 글자 하나까지 같아도 서로 다른 타입입니다. 그래서 decltype(lam1)과 decltype(lam2)는 일치하지 않습니다.

C++17까지 클로저 타입에는 기본 생성자와 복사 대입 연산자가 없습니다. C++20부터는 캡처가 없는 람다에 한해 기본 생성과 대입이 가능해져, std::map<K, V, decltype(cmp)>처럼 비교자 타입만 넘기고 객체는 생략할 수 있게 되었습니다. 람다를 저장할 때는 보통 템플릿 매개변수로 타입을 그대로 받고, 타입 이름이 꼭 필요한 곳에서만 std::function이나 함수 포인터를 씁니다.

클로저 객체를 복사하면 캡처 멤버가 복사되고, 이동하면 각 멤버가 이동됩니다. 큰 std::vector를 값으로 캡처한 람다를 여기저기 복사하면 그때마다 벡터 전체가 복사됩니다.


캡처의 의미론: 값과 참조

값 캡처 [=]·[x]

값 캡처는 람다가 생성되는 시점에 대상을 복사해 클로저에 넣습니다. 이후 바깥 변수를 바꿔도 클로저 안의 복사본은 변하지 않습니다. 비동기 작업, 스레드, 나중에 호출되는 콜백처럼 바깥 스택 프레임이 사라진 뒤 실행될 수 있는 경우에는 값 캡처가 수명 측면에서 안전한 선택입니다.

참조 캡처 [&]·[&y]

참조 캡처는 원본을 가리키므로, 람다가 실행될 때마다 그 시점의 원본 값을 봅니다. std::sort나 std::find_if에 넘기는 비교자처럼 같은 스코프에서 즉시 호출되어 원본이 람다보다 오래 사는 것이 보장되면, 참조 캡처로 복사 비용을 줄일 수 있습니다. 반대로 람다가 원본보다 오래 살 수 있으면 댕글링 참조가 됩니다.

혼합·기본 캡처

[=, &y]는 기본을 값으로 두고 y만 참조로, [&, x]는 기본을 참조로 두고 x만 값으로 캡처합니다. 기본 캡처는 본문에서 실제로 사용(ODR-use)된 자동 변수만 캡처하지만, 무엇이 캡처되는지가 코드에 드러나지 않으므로 이름을 나열하는 명시적 캡처가 리뷰와 유지보수에 유리합니다. 기본 캡처와 같은 방식으로 이름을 다시 적는 것([=, x], [&, &y])과 같은 이름을 두 번 적는 것은 오류입니다. ([=, this]는 예외로, C++20부터 허용됩니다.)

this와 *this

멤버 함수 안에서 [this]는 현재 객체의 포인터를 캡처합니다. 람다가 객체보다 오래 살아 소멸 후에 호출되면 미정의 동작입니다. C++17의 [*this]는 현재 객체를 복사해 클로저가 값으로 소유하게 하므로, 다른 스레드에 객체의 스냅샷을 넘길 때 수명을 분리할 수 있습니다. 이때 복사되는 타입은 멤버 함수가 정의된 클래스이므로, 파생 클래스 객체에서 호출하면 기반 부분만 복사되는 슬라이싱이 일어납니다.

[=]가 this를 암시적으로 캡처하는 동작은 C++20에서 deprecated되었습니다. 값으로 캡처한 것처럼 보이지만 실제로는 포인터만 복사되어, 객체가 사라진 뒤 멤버에 접근하는 버그를 만들기 쉽기 때문입니다. C++20부터는 [=, this]나 [=, *this]로 의도를 명시합니다.

초기화 캡처(C++14)

[name = expr] 형태는 클로저 전용 멤버를 만들고 expr로 초기화합니다. [p = std::move(ptr)]처럼 std::unique_ptr 같은 이동 전용 객체의 소유권을 클로저로 옮기는 것이 대표적인 용도입니다. 단, 이동 전용 멤버를 가진 클로저는 복사할 수 없으므로 std::function(C++23의 std::move_only_function이 아닌)에는 담을 수 없습니다.

mutable과 const 정확성

값으로 캡처한 멤버를 본문에서 고치려면 mutable이 필요합니다. 수정되는 것은 클로저 내부의 복사본입니다.

int x = 0;
auto f = [x]() mutable {
    ++x;
    return x;
};
f();  // 1
f();  // 2 — 클로저 내부 복사본이 누적
// 바깥 x는 여전히 0

제네릭 람다와 템플릿 연역

C++14: auto 매개변수

매개변수에 auto(또는 const auto& 등)를 쓰면 클로저의 operator()가 함수 템플릿이 됩니다. 각 auto 매개변수마다 별도의 템플릿 타입 매개변수가 붙습니다.

auto add = [](auto a, auto b) { return a + b; };
// 개념: template <typename T, typename U> auto operator()(T a, U b) const;

호출할 때 인자 타입에 맞는 인스턴스가 생성되므로, 서로 다른 타입 조합으로 호출할 때마다 별도의 함수가 만들어집니다. 작은 헬퍼라면 문제가 없지만, 많은 타입 조합으로 호출되는 큰 람다는 바이너리 크기와 컴파일 시간을 늘릴 수 있습니다.

C++20: 템플릿 람다와 제약

명시적 템플릿 매개변수가 필요하면 다음과 같이 씁니다.

auto f = []<class T>(T x) { return x; };

operator()가 템플릿이므로 requires로 받을 수 있는 타입을 제한할 수도 있습니다.

auto inc = [](auto x) requires std::integral<decltype(x)> { return x + 1; };
// inc(1.5)는 제약 위반으로 컴파일 에러

반환 타입 연역의 함정

반환 타입을 생략한 람다에 return이 여러 개 있으면, 모든 return의 연역 결과가 같은 타입이어야 합니다.

auto pick = [](bool first, auto a, auto b) {
    if (first) return a;
    return b;   // a와 b의 타입이 다르면(int, double 등) 컴파일 에러
};

pick(true, 1, 2.0)은 int와 double로 연역이 충돌해 컴파일되지 않습니다. 런타임 조건으로 고르는 경우라면 -> std::common_type_t<decltype(a), decltype(b)>처럼 반환 타입을 명시합니다. 조건이 컴파일 타임 상수라면 if constexpr로 갈라서 인스턴스마다 return 경로가 하나만 남게 할 수 있습니다.

C++14 제네릭 람다로 완벽 전달하기

C++20 이전에는 템플릿 매개변수 이름이 없으므로 auto&&와 decltype으로 전달합니다. auto&& x는 전달 참조라 인자가 좌값이면 좌값 참조로, 우값이면 우값 참조로 묶이고, std::forward<decltype(x)>(x)가 그 값 범주를 보존해 다음 호출로 넘깁니다.

void sink(int&);
void sink(int&&);

auto forward_to_sink = [](auto&& x) -> decltype(auto) {
    return sink(std::forward<decltype(x)>(x));
};

int n = 0;
forward_to_sink(n);   // sink(int&)
forward_to_sink(42);  // sink(int&&)

auto x(값)로 받으면 매번 복사가 일어나고 우값이라는 정보도 사라집니다. 읽기만 한다면 const auto&, 원본을 수정한다면 auto&, 다른 API에 그대로 넘긴다면 위 패턴을 씁니다. 반환까지 참조를 유지해야 하면 decltype(auto)를 씁니다. C++20에서는 []<class T>(T&& x) { sink(std::forward<T>(x)); }로 같은 일을 더 읽기 쉽게 쓸 수 있습니다.

제네릭 람다에서 자주 하는 실수

[](auto a, auto b)의 두 auto는 서로 다른 템플릿 매개변수이므로 int와 double이 섞여 들어와도 막지 못합니다. 같은 타입을 강제하려면 C++20 템플릿 람다 []<class T>(T a, T b)를 씁니다. 원소 타입이 필요한 경우([]<class T>(const std::vector<T>& v))에도 템플릿 람다가 훨씬 읽기 쉽습니다.

std::for_each(v.begin(), v.end(), [](auto x) { x *= 2; })는 복사본만 바꿉니다. 원소를 고치려면 auto&로 받아야 합니다.

람다는 이름이 없어 본문에서 자기 자신을 부를 수 없습니다. C++23에서는 명시적 객체 매개변수로 auto fact = [](this auto self, int n) -> int { return n <= 1 ? 1 : n * self(n - 1); };처럼 쓸 수 있고, 그 이전에는 자기 자신을 인자로 넘기는 제네릭 람다([](auto self, int n) -> int { ... self(self, n - 1) ... })나 이름 있는 함수 객체를 씁니다. 두 방식 모두 반환 타입을 명시해야 재귀 호출 시점에 타입을 알 수 있습니다. std::function에 담아 재귀하면 동작은 하지만 호출마다 타입 소거 비용이 붙습니다.

참조 캡처의 수명과 댕글링 사례는 람다 참조 캡처 심화에서 더 자세히 다룹니다.


IIFE(즉시 실행 람다) 패턴

IIFE(Immediately Invoked Function Expression)는 람다를 정의하자마자 (...)() 형태로 호출하는 관용구입니다.

복잡한 초기화를 한 덩어리로

여러 단계를 거쳐 값을 계산해야 하는데 중간 변수를 바깥 스코프에 노출하고 싶지 않을 때 유용합니다. 특히 결과를 const 변수로 만들 수 있다는 점이 핵심입니다. 일반적인 방식으로는 변수를 먼저 선언하고 나중에 값을 채워야 하므로 const를 붙일 수 없습니다.

const int value = [](unsigned seed) {
    unsigned x = seed;
    x ^= x << 13;
    x ^= x >> 17;
    x ^= x << 5;
    return static_cast<int>(x);
}(42u);

(부호 있는 정수를 왼쪽으로 시프트해 오버플로가 나는 것을 피하려고 unsigned로 계산했습니다.)

IIFE 안에서 예외가 나면 초기화 대상 변수는 아예 생성되지 않은 채로 예외가 전파됩니다. 반쯤 초기화된 변수가 남는 일은 없으므로, 초기화가 끝까지 성공한 경우에만 변수가 존재한다는 보장을 얻습니다. 실패를 예외가 아닌 값으로 다루고 싶다면 IIFE가 std::optional이나 C++23의 std::expected를 반환하게 합니다.

IIFE 안에서 [&]로 지역 변수를 참조하는 것은 즉시 호출되므로 안전합니다. 문제는 IIFE가 다른 람다를 반환하고 그 람다가 지역 변수를 참조로 캡처했을 때로, 이 경우는 일반적인 댕글링 규칙이 그대로 적용됩니다.

constexpr 람다와 컴파일 타임 상수

C++17부터 람다의 operator()는 constexpr 함수의 요건을 만족하면 암시적으로 constexpr이 됩니다. 그래서 IIFE를 constexpr 함수 안이나 템플릿 인자, std::array 크기 같은 상수 표현식에 쓸 수 있습니다.

constexpr unsigned factorial(unsigned n) {
    return [n] {
        unsigned r = 1;
        for (unsigned i = 2; i <= n; ++i) {
            r *= i;
        }
        return r;
    }();
}
static_assert(factorial(5) == 120);

함수 내부의 정적 지역 변수 초기화(C++11부터 스레드 안전)와 IIFE는 “한 번만 계산한다”는 역할이 겹칠 때가 있습니다. 프로세스 전체에서 한 번 계산하고 공유할 값이면 정적 지역 변수가, 스코프마다 계산하는 지역 상수면 IIFE가 맞습니다.


실전: STL·스레드·콜백

STL 알고리즘

std::find_if, std::transform 같은 알고리즘의 predicate 자리에 람다를 직접 넣으면 조건이 호출 지점 바로 옆에 보입니다. 알고리즘 호출이 끝나기 전에 람다도 끝나므로 참조 캡처를 써도 안전합니다.

std::vector<int> v = {1, 2, 3, 4, 5};
int threshold = 3;
auto it = std::find_if(v.begin(), v.end(), [&threshold](int x) { return x > threshold; });

스레드와 값 캡처

스레드는 람다를 만든 함수가 반환된 뒤에도 실행될 수 있으므로, 스레드가 쓰는 데이터는 값이나 이동 캡처로 클로저가 소유하게 합니다.

std::vector<int> data = {1, 2, 3};
int mul = 10;
std::thread t([data, mul] {
    for (int x : data) { /* x * mul 처리 */ }
});
t.join();

루프에서 스레드를 여러 개 띄우면서 [&i]나 [&]로 루프 변수를 캡처하면, 모든 스레드가 같은 i를 가리킵니다. 스레드가 실제로 실행될 즈음에는 루프가 이미 i를 바꿨거나 루프가 끝나 i가 사라졌을 수 있으므로, 같은 값을 읽거나 댕글링 참조를 읽는 데이터 레이스가 생깁니다. [i]로 값을 캡처하면 각 클로저가 생성 시점의 i를 복사해 가집니다.

비동기 작업에 소유권 넘기기

작업 큐나 스레드 풀, std::async에 넘기는 callable은 참조가 아니라 값이나 스마트 포인터로 데이터를 소유하게 만드는 것이 안전합니다.

auto fut = std::async(std::launch::async, [data = makeHeavyData()] {
    return process(data);
});

장시간 실행되는 작업을 외부에서 중단할 수 있게 하려면 C++20의 std::stop_token을 값으로 캡처합니다. stop_token은 복사해도 같은 중단 상태를 공유하므로, 원래의 std::stop_source가 있는 스코프보다 작업이 오래 살아도 안전합니다. C++20 이전이라면 std::shared_ptr<std::atomic<bool>>을 값으로 캡처해 플래그의 수명을 공유합니다. 지역 std::atomic<bool>을 참조로 캡처하면 그 스코프가 먼저 끝났을 때 폴링이 미정의 동작이 됩니다.

스코프 가드

임시 파일, 락, 핸들처럼 스코프를 벗어날 때 정리가 필요한 자원은 소멸자에서 람다를 한 번 호출하는 작은 클래스로 다룰 수 있습니다. 표준에는 아직 없고 Library Fundamentals TS v3의 std::experimental::scope_exit가 이 형태를 정의합니다. 직접 구현할 때는 정리 함수가 두 번 실행되지 않도록 복사를 금지하고, 성공 경로에서 정리를 취소할 수 있게 release()(또는 dismiss) 플래그를 두는 것이 일반적입니다.

std::function 남용

std::function은 타입 소거를 위해 간접 호출을 쓰고, 클로저가 내부 버퍼(small buffer)보다 크면 힙 할당을 합니다. 콜백을 받는 함수가 콜백을 저장하지 않고 바로 호출한다면 template <class F> void work(F&& f)처럼 구체 클로저 타입을 그대로 받는 편이 인라인 최적화에 유리합니다. 콜백을 멤버로 저장하거나 서로 다른 람다를 한 컨테이너에 담아야 할 때처럼 타입 소거가 꼭 필요한 경우에 std::function을 씁니다.

template <class OnOk, class OnFail>
void tryOp(OnOk&& ok, OnFail&& fail) {
    if (doWork()) ok();
    else fail();
}

상태를 가진 람다와 mutable

std::generate처럼 호출마다 내부 상태가 바뀌어야 하는 경우에는 mutable 값 캡처로 카운터를 클로저 안에 둡니다.

std::vector<int> ids(5);
std::generate(ids.begin(), ids.end(), [next = 100]() mutable { return next++; });
// 100, 101, 102, 103, 104

이런 람다는 내부 상태를 바꾸므로, 같은 클로저 객체를 여러 스레드에서 동시에 호출하면 데이터 레이스가 됩니다. 또 알고리즘이 함수 객체를 내부에서 복사할 수 있다는 점도 주의해야 합니다. 상태가 복사본에 쌓이면 호출 후 원래 람다의 상태는 바뀌지 않은 채로 남습니다.

불필요한 캡처와 noexcept

[=]는 본문에서 쓰는 변수만 캡처하지만, 큰 객체를 무심코 사용하면 그 객체 전체가 복사됩니다. 캡처할 이름을 명시하면 이런 복사가 코드에 드러납니다. 람다에도 noexcept를 붙일 수 있으며([]() noexcept { return 42; }), 예외를 던지지 않는다는 계약을 문서화하고 std::is_nothrow_invocable 같은 검사에 반영됩니다.


같이 보면 좋은 글

영문 버전은 C++ Lambda Expressions (English)를 참고하세요.