C++ 람다 심화 | 초기화 캡처·완벽 전달·IIFE·재귀 람다와 실전 패턴

💡 핵심 개념: [=]·[&]만으로 부족할 때 초기화 캡처 [ptr = std::move(p)]로 이동 소유권을 람다 안으로 가져옵니다. 완벽 전달은 “받은 인자를 그대로 다음 함수에 넘길 때” auto&& + std::forward를 떠올리면 됩니다. #13-1 람다 표현식을 본 뒤 읽으면 흐름이 자연스럽다.

들어가며: unique_ptr을 스레드에 넘기려면 어떻게 해야 할까요?

비동기 작업 큐에 unique_ptr을 담은 작업을 넣고 싶었다. std::unique_ptr은 복사가 불가능하고 이동만 가능합니다. [=]로 캡처하면 복사 시도로 컴파일 에러가 나며, [&]로 캡처하면 지역 변수가 스코프를 벗어난 뒤 큐에서 작업이 실행될 때 댕글링 참조가 됩니다.

이동 캡처, 댕글링 참조, 재귀가 필요한 순간들

unique_ptr을 람다로 넘기기

std::thread나 std::async에 unique_ptr을 담은 작업을 넘기려면, 소유권 이전이 필요합니다. [=]는 복사라서 불가능하며, [&]는 수명 문제가 있습니다. 초기화 캡처 [p = std::move(ptr)]로 람다 전용 변수에 이동하면 해결됩니다.

제네릭 람다에서 인자를 그대로 전달하기

래퍼 함수가 인자를 받아서 내부 함수에 그대로 넘겨야 할 때, lvalue는 lvalue로, rvalue는 rvalue로 전달해야 불필요한 복사가 없습니다. std::forward와 완벽 전달을 람다에 적용하는 패턴이 필요합니다.

const 변수 초기화를 조건에 따라 다르게

const 변수를 if-else 분기마다 다른 값으로 초기화하려면, 블록을 나눠야 해서 스코프가 복잡해집니다. IIFE(즉시 실행 함수 표현식)로 한 블록 안에서 초기화를 완료할 수 있습니다.

재귀를 람다로 쓰고 싶을 때

std::function에 담긴 람다는 자기 자신을 호출할 수 없어서, 재귀 로직을 람다로 쓰기 어렵습니다. std::function + 재귀 람다 패턴이나 Y combinator를 쓰면 람다만으로 재귀를 표현할 수 있습니다.

호출마다 상태를 유지하는 함수가 필요할 때

카운터, 시퀀스 생성기, 캐시처럼 호출 사이에 상태를 유지하는 함수가 필요합니다. 전역 변수는 스레드 안전하지 않으며, 별도 클래스를 만들기는 과합니다. 상태 보유 람다(stateful lambda)는 mutable과 초기화 캡처 [n = 0]로 간단히 구현할 수 있습니다.

템플릿 함수에 람다를 넘길 때

std::sort, std::find_if처럼 템플릿으로 콜백을 받는 함수에 람다를 넘기면, 컴파일러가 인라인 최적화를 수행해 std::function보다 빠릅니다. 반대로 std::function으로 타입을 고정하면 타입 소거로 인라인이 어렵습니다. 템플릿 내 람다 사용 시점을 이해하면 성능과 유연성을 맞출 수 있습니다.

정렬·검색 기준을 동적으로 바꿀 때

std::sort, std::set, std::priority_queue에 비교자(comparator)를 넘겨야 합니다. 나이순, 이름순, 점수순처럼 기준이 바뀔 때마다 람다로 비교자를 넘기면, 호출 지점에서 의도가 분명해집니다. 람다 비교자는 operator<를 오버로드한 함수 객체보다 간결합니다.

콜백을 저장해 나중에 호출할 때

버튼 클릭, 이벤트 핸들러처럼 나중에 호출될 함수를 저장하려면 std::function에 람다를 담습니다. 이때 람다가 캡처한 변수의 수명이 std::function보다 길어야 댕글링이 발생하지 않습니다. std::function + 람다 조합에서 수명 관리가 핵심입니다. 정의를 풀어 쓰면 초기화 캡처는 람다가 생성될 때 새 변수를 만들어 식의 결과로 초기화하는 문법입니다. 비유하면 람다 “입장권”을 받을 때 필요한 짐을 한 번만 싸서 들고 들어가는 것입니다. unique_ptr 이동, 큰 객체의 move, 표현식 결과 캡처에 사용됩니다. 문제의 코드:

// ❌ unique_ptr은 복사 불가
void enqueueTask() {
    auto ptr = std::make_unique<Payload>(...);
    taskQueue.push([=]() {  // 컴파일 에러!
        process(ptr);
    });
}
// ❌ 참조 캡처는 enqueueTask() 종료 후 댕글링
taskQueue.push([&ptr]() { process(ptr); });

초기화 캡처로 해결:

// ✅ 소유권 이전
taskQueue.push([p = std::move(ptr)]() mutable {
    process(std::move(p));
});

단, taskQueue가 흔히 쓰는 std::queue<std::function<void()>>라면 이 코드도 컴파일되지 않습니다. unique_ptr을 품은 람다는 복사할 수 없는데 std::function은 복사 가능한 호출체만 담기 때문입니다. 초기화 캡처로 문제의 절반을 풀고 나면 이 두 번째 벽을 만나게 되는데, 아래 “비동기 작업 큐에 unique_ptr 넘기기” 예제에서 해결법을 함께 다룹니다. 또 람다 본문에서 std::move(p)를 하려면 mutable이 필요합니다. 값 캡처된 멤버는 기본적으로 const라서, mutable 없이 std::move(p)를 쓰면 const unique_ptr&&가 되어 이동이 아니라 (삭제된) 복사를 시도합니다.

이 글을 읽으면:

  • 초기화 캡처로 unique_ptr·큰 객체를 안전하게 넘기는 방법을 알 수 있습니다.
  • 완벽 전달을 람다에 적용하는 패턴을 배울 수 있습니다.
  • IIFE로 복잡한 const 초기화를 단순화할 수 있습니다.
  • 재귀 람다를 구현하는 방법을 익힐 수 있습니다.
  • 자주 겪는 에러와 프로덕션 패턴을 정리할 수 있습니다. 람다 고급 캡처와 전달 흐름을 요약하면 아래와 같습니다.
flowchart TB
  subgraph init[초기화 캡처]
    A[람다 정의] --> B["p = std move ptr"]
    B --> C[람다 내부 p 소유]
  end
  subgraph iife[IIFE]
    D[람다 정의] --> E[즉시 호출]
    E --> F[결과만 변수에 저장]
  end
  subgraph recurse[재귀 람다]
    G["std function 저장"] --> H[람다 내부에서 self 호출]
  end

초기화 캡처 (Init Capture)

기본 문법

C++14부터 [이름 = 식] 형태로 람다 전용 변수를 만들 수 있습니다. 이 변수는 람다가 정의될 때 한 번만 초기화되고, 람다 본문에서 사용합니다.

// [변수명 = 초기화식]
auto lambda = [value = 42]() {
    return value * 2;  // 84
};

unique_ptr 이동 캡처

unique_ptr은 복사가 불가능하므로, 초기화 캡처로 이동해서 람다에 넘깁니다.

// g++ -std=c++17 -o init_capture_unique init_capture_unique.cpp && ./init_capture_unique
#include <iostream>
#include <memory>
struct Payload {
    int id;
    Payload(int i) : id(i) {}
};
void process(std::unique_ptr<Payload> p) {
    if (p) std::cout << "Processing id=" << p->id << "\n";
}
int main() {
    auto ptr = std::make_unique<Payload>(100);
    // [p = std::move(ptr)]: ptr의 소유권을 람다 내부 p로 이전
    auto task = [p = std::move(ptr)]() {
        process(std::move(p));
    };
    task();  // Processing id=100
    return 0;
}

실행 결과:

Processing id=100

[p = std::move(ptr)]에서 ptr은 빈 상태가 되고, 람다 객체 내부에 p라는 unique_ptr<Payload> 멤버가 생성됩니다. 람다가 실행될 때 process(std::move(p))로 한 번 더 이동해 전달합니다.

큰 객체 move 캡처

std::string, std::vector처럼 큰 객체를 람다에 넘길 때, 복사 대신 이동으로 전달하면 성능이 좋아집니다.

#include <string>
#include <vector>
int main() {
    std::string bigString(10000, 'x');
    std::vector<int> bigVec(1000, 42);
    // 복사 대신 이동으로 캡처
    auto lambda = [s = std::move(bigString), v = std::move(bigVec)]() {
        std::cout << "s.size()=" << s.size() << ", v.size()=" << v.size() << "\n";
    };
    lambda();
    // bigString, bigVec은 이제 빈 상태 (이동됨)
    return 0;
}

표현식 결과 캡처

캡처 시점에 계산된 값을 람다 전용 변수에 넣을 수 있습니다.

int x = 10;
int y = 20;
// [sum = x + y]: 정의 시점에 30이 계산되어 sum에 저장
auto lambda = [sum = x + y]() {
    return sum;  // 30
};
x = 100;  // sum에는 영향 없음
std::cout << lambda() << "\n";  // 30

초기화 캡처와 기존 캡처 혼합

[=, p = std::move(ptr)]처럼 기존 캡처와 함께 쓸 수 있습니다.

int threshold = 10;
auto ptr = std::make_unique<int>(42);
auto lambda = [=, p = std::move(ptr)]() {
    if (p && *p > threshold) {
        return true;
    }
    return false;
};

초기화 캡처 비교표

문법의미사용 시점
[x = expr]expr 결과를 x에 저장표현식 결과 캡처
[p = std::move(ptr)]ptr 소유권 이전unique_ptr, 큰 객체
[s = std::move(str)]str 이동string, vector
[c = getConnection()]연결 객체 캡처리소스 획득

완벽 전달 (Perfect Forwarding)

왜 완벽 전달이 필요한가

제네릭 람다나 래퍼에서 인자를 내부 함수에 넘길 때, lvalue는 lvalue로, rvalue는 rvalue로 전달해야 불필요한 복사를 막을 수 있습니다. std::forward를 사용합니다.

기본 패턴

// g++ -std=c++17 -o perfect_forward_lambda perfect_forward_lambda.cpp && ./perfect_forward_lambda
#include <iostream>
#include <utility>
template <typename Func, typename... Args>
decltype(auto) wrapper(Func&& f, Args&&... args) {
    // f에 args를 완벽 전달
    return std::forward<Func>(f)(std::forward<Args>(args)...);
}
int main() {
    auto add = [](int a, int b) { return a + b; };
    std::cout << wrapper(add, 3, 5) << "\n";  // 8
    auto identity = [](auto&& x) -> decltype(auto) {
        return std::forward<decltype(x)>(x);
    };
    int i = 42;
    std::cout << identity(i) << "\n";   // 42 (lvalue 그대로)
    std::cout << identity(100) << "\n";  // 100 (rvalue 그대로)
    return 0;
}

실행 결과:

8
42
100

제네릭 람다에서 완벽 전달

auto&&와 std::forward를 조합하면, 람다가 받은 인자를 그대로 다음 함수에 넘길 수 있습니다.

template <typename F>
auto makeLogger(F&& f) {
    return [f = std::forward<F>(f)](auto&&... args) -> decltype(auto) {
        std::cout << "Calling with " << sizeof...(args) << " args\n";
        return f(std::forward<decltype(args)>(args)...);
    };
}
auto add = [](int a, int b) { return a + b; };
auto loggedAdd = makeLogger(add);
std::cout << loggedAdd(1, 2) << "\n";  // Calling with 2 args\n 3

emplace와 완벽 전달

std::vector::emplace_back처럼 생성 인자를 직접 전달할 때 유용합니다.

#include <vector>
#include <string>
template <typename T, typename... Args>
void emplaceAndLog(std::vector<T>& vec, Args&&... args) {
    auto doEmplace = [&vec](Args&&... a) {
        vec.emplace_back(std::forward<Args>(a)...);
    };
    doEmplace(std::forward<Args>(args)...);
}
int main() {
    std::vector<std::string> v;
    emplaceAndLog(v, "hello");           // const char* → string 변환
    emplaceAndLog(v, 5, 'x');            // string(5, 'x')
    std::cout << v[0] << " " << v[1] << "\n";  // hello xxxxx
    return 0;
}

IIFE (즉시 실행 함수 표현식)

개념

IIFE(Immediately Invoked Function Expression)는 람다를 정의한 직후 ()를 붙여 바로 호출하는 패턴입니다. “한 번만 쓰는 함수”를 인라인으로 실행해, 결과만 변수에 저장합니다.

기본 형태

// { 본문 }(실제인자)
auto result = [](int x) { return x * x; }(5);  // result = 25

const 변수 조건부 초기화

const 변수를 if-else에 따라 다르게 초기화할 때, IIFE로 한 블록 안에서 처리할 수 있습니다.

// ❌ 블록을 나눠야 함
const std::string config;
if (useFile) {
    config = loadFromFile("config.json");
} else {
    config = getDefaultConfig();  // 에러: const는 한 번만 초기화
}
// ✅ IIFE로 한 블록에서 초기화
const auto config = [&]() {
    if (useFile) {
        return loadFromFile("config.json");
    } else {
        return getDefaultConfig();
    }
}();

복잡한 초기화 로직

여러 단계를 거치는 초기화를 IIFE로 캡슐화합니다.

// g++ -std=c++17 -o iife_init iife_init.cpp && ./iife_init
#include <iostream>
#include <string>
#include <vector>
std::string loadFromFile(const std::string& path) {
    return "loaded:" + path;
}
std::string getDefault() { return "default"; }
int main() {
    bool useFile = true;
    const auto config = [&]() -> std::string {
        if (useFile) {
            auto raw = loadFromFile("config.json");
            return raw + "_parsed";
        } else {
            return getDefault();
        }
    }();
    std::cout << config << "\n";  // loaded:config.json_parsed
    return 0;
}

실행 결과:

loaded:config.json_parsed

스코프 제한

IIFE 안에서 만든 변수는 람다 스코프에 갇혀 바깥에 노출되지 않습니다.

const auto result = [&]() {
    std::vector<int> temp = {1, 2, 3, 4, 5};
    int sum = 0;
    for (int x : temp) sum += x;
    return sum;  // temp는 여기서 소멸
}();
// temp에 접근 불가 → 깔끔한 스코프

lock_guard와 IIFE

락을 잡은 상태에서만 초기화하며, 락 해제 후 사용할 때 유용합니다.

#include <mutex>
std::mutex mtx;
SomeResource* resource = nullptr;
const auto initialized = [&]() {
    std::lock_guard<std::mutex> lock(mtx);
    if (!resource) {
        resource = new SomeResource();
    }
    return resource;
}();
// lock은 여기서 해제됨

재귀 람다 (Recursive Lambda)

문제: 람다는 자기 자신을 알 수 없다

일반 함수는 이름이 있어서 재귀 호출이 쉽지만, 람다는 이름이 없어서 자기 자신을 호출할 수 없습니다.

// ❌ 람다 안에서 factorial을 아직 정의하지 않음
auto factorial = [](int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);  // 에러: factorial 캡처 안 됨
};

해결 1: std::function에 담기

std::function에 담으면, 재귀 호출 시 그 std::function을 캡처해서 사용할 수 있습니다.

// g++ -std=c++17 -o recursive_lambda recursive_lambda.cpp && ./recursive_lambda
#include <iostream>
#include <functional>
int main() {
    std::function<int(int)> factorial;
    factorial = [&factorial](int n) -> int {
        if (n <= 1) return 1;
        return n * factorial(n - 1);
    };
    std::cout << factorial(5) << "\n";  // 120
    return 0;
}

실행 결과:

120

factorial을 참조 캡처 [&factorial]로 가져와서, 람다 안에서 factorial(n-1)로 자기 자신을 호출합니다. std::function은 타입 소거로 인해 힙 할당이 발생할 수 있어, 성능이 중요한 경로에서는 주의가 필요합니다.

해결 2: Y combinator (고급)

Y combinator를 쓰면 std::function 없이 순수 람다만으로 재귀를 구현할 수 있습니다.

// g++ -std=c++17 -o y_combinator y_combinator.cpp && ./y_combinator
#include <iostream>
template <typename F>
struct Y {
    F f;
    Y(F f) : f(f) {}
    template <typename... Args>
    decltype(auto) operator()(Args&&... args) {
        return f(*this, std::forward<Args>(args)...);
    }
};
template <typename F>
Y(F) -> Y<F>;
int main() {
    auto factorial = Y([](auto& self, int n) -> int {
        if (n <= 1) return 1;
        return n * self(n - 1);
    });
    std::cout << factorial(5) << "\n";  // 120
    return 0;
}

해결 3: C++23 deducing this (자기 참조)

C++23에서는 this를 값으로 받는 explicit object parameter로 재귀를 더 간단히 쓸 수 있습니다.

// C++23
auto factorial = [](this auto self, int n) -> int {
    if (n <= 1) return 1;
    return n * self(n - 1);
};

재귀 람다 비교표

방식장점단점
std::function구현 간단힙 할당 가능, 인라인 어려움
Y combinator순수 람다, 인라인 가능문법 복잡
C++23 this직관적C++23 필요

작업 큐, 로거 래퍼, 설정 로드, 트리 순회 예제

예제 1: 비동기 작업 큐에 unique_ptr 넘기기

이 예제의 이전 버전은 unique_ptr을 초기화 캡처한 람다를 그대로 std::function 큐에 넣고 실행 결과까지 적어 두었는데, 실제로 컴파일해 보면 GCC 10에서 다음 에러가 납니다.

.../bits/std_function.h:161:6: error: use of deleted function 'main()::<lambda()>::<lambda>(const main()::<lambda()>&)'
lam.cpp:45:36: error: use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)'

에러가 내 코드가 아니라 std_function.h 안에서 시작되고, “람다의 복사 생성자가 삭제됨”이라고만 나와서 원인이 std::function의 복사 요구 조건이라는 것을 떠올리기 어렵습니다. 초기화 캡처를 처음 배우고 작업 큐에 적용할 때 거의 반드시 만나는 에러입니다. 아래는 unique_ptr을 shared_ptr로 바꿔 캡처해 C++17에서 컴파일·실행되는 버전입니다(GCC 10 -std=c++17 -pthread로 확인).

// g++ -std=c++17 -pthread -o async_unique async_unique.cpp && ./async_unique
#include <condition_variable>
#include <functional>
#include <iostream>
#include <memory>
#include <mutex>
#include <queue>
#include <thread>

struct Task {
    int id;
    explicit Task(int i) : id(i) {}
};

class TaskQueue {
    std::queue<std::function<void()>> queue_;
    std::mutex mtx_;
    std::condition_variable cv_;
    bool done_ = false;
public:
    void push(std::function<void()> f) {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            queue_.push(std::move(f));
        }
        cv_.notify_one();
    }
    void run() {
        while (true) {
            std::function<void()> f;
            {
                std::unique_lock<std::mutex> lock(mtx_);
                cv_.wait(lock, [this] { return !queue_.empty() || done_; });
                if (queue_.empty()) break;   // done_이고 남은 작업도 없음
                f = std::move(queue_.front());
                queue_.pop();
            }
            f();                              // 락 밖에서 실행
        }
    }
    void stop() {
        {
            std::lock_guard<std::mutex> lock(mtx_);
            done_ = true;
        }
        cv_.notify_all();
    }
};

int main() {
    TaskQueue q;
    std::thread worker([&q] { q.run(); });
    for (int i = 0; i < 3; ++i) {
        auto task = std::make_unique<Task>(i);
        // unique_ptr → shared_ptr로 소유권을 옮겨 람다를 복사 가능하게 만든다
        q.push([p = std::shared_ptr<Task>(std::move(task))]() {
            std::cout << "Processing task " << p->id << "\n";
        });
    }
    q.stop();
    worker.join();
}

실행 결과:

Processing task 0
Processing task 1
Processing task 2

선택지는 세 가지입니다.

방법장점단점
shared_ptr로 바꿔 캡처 (위 코드)C++11부터 동작, 큐 타입 그대로참조 카운트 원자 연산 비용, “단독 소유”라는 의도가 흐려짐
C++23 std::move_only_function<void()> 큐이동 전용 람다를 그대로 저장, 의도가 정확C++23과 그를 지원하는 표준 라이브러리 필요(GCC 12+, MSVC 19.32+)
직접 만든 타입 소거 래퍼(이동 전용 unique_function)구버전에서도 이동 전용직접 유지보수해야 함

std::function이 복사를 요구하는 이유는 std::function 객체 자체가 복사 가능해야 하기 때문입니다. 담긴 호출체가 복사 불가능하면 std::function을 복사하는 순간 할 수 있는 일이 없으니, 담을 때부터 거부합니다. std::async, std::thread는 호출체를 복사하지 않고 이동하므로 이동 전용 람다를 그대로 받을 수 있다는 점도 함께 기억해 두면 좋습니다. 수정 전 코드의 run()은 notify_one()을 락을 쥔 채 호출하고 있었는데, 동작에는 문제가 없지만 깨어난 스레드가 곧바로 락을 다시 기다리게 되므로 위처럼 락을 푼 뒤 알리는 편이 일반적입니다.

예제 2: 완벽 전달 로거 래퍼

#include <iostream>
#include <utility>
#include <string>
template <typename F>
auto makeLogged(F&& f) {
    return [f = std::forward<F>(f)](auto&&... args) -> decltype(auto) {
        std::cout << ">> call\n";
        auto result = f(std::forward<decltype(args)>(args)...);
        std::cout << "<< return\n";
        return result;
    };
}
int main() {
    auto add = [](int a, int b) { return a + b; };
    auto concat = [](std::string a, std::string b) { return a + b; };
    auto loggedAdd = makeLogged(add);
    auto loggedConcat = makeLogged(concat);
    std::cout << loggedAdd(1, 2) << "\n";
    std::cout << loggedConcat(std::string("Hello"), std::string(" World")) << "\n";
    return 0;
}

예제 3: IIFE로 설정 로드

#include <iostream>
#include <string>
#include <map>
std::map<std::string, std::string> loadFromEnv() {
    return {{"host", "localhost"}, {"port", "8080"}};
}
std::map<std::string, std::string> loadFromFile(const std::string& path) {
    return {{"host", "file_host"}, {"port", "9000"}};
}
int main() {
    bool useEnv = true;
    const auto config = [&]() -> std::map<std::string, std::string> {
        auto raw = useEnv ? loadFromEnv() : loadFromFile("config.json");
        raw["version"] = "1.0";
        return raw;
    }();
    std::cout << "host=" << config.at("host") << ", port=" << config.at("port") << "\n";
    return 0;
}

예제 4: 재귀 람다로 트리 순회

#include <iostream>
#include <functional>
#include <vector>
struct Node {
    int value;
    std::vector<Node*> children;
};
void traverse(Node* node, std::function<void(int)>& visit) {
    if (!node) return;
    visit(node->value);
    for (auto* child : node->children) {
        traverse(child, visit);
    }
}
int main() {
    Node n1{1, {}}, n2{2, {}}, n3{3, {}};
    Node root{0, {&n1, &n2, &n3}};
    std::function<void(int)> print = [&print](int v) {
        std::cout << v << " ";
    };
    traverse(&root, print);  // 0 1 2 3
    std::cout << "\n";
    return 0;
}

unique_ptr 복사 캡처, this 캡처 착각 같은 람다 에러

에러 1: unique_ptr을 [=]로 캡처

증상: error: use of deleted function 'std::unique_ptr<...>::unique_ptr(const std::unique_ptr<...>&)' 원인: unique_ptr은 복사 생성자가 삭제되어 있습니다.

// ❌ 잘못된 예
auto ptr = std::make_unique<int>(42);
auto lambda = [=]() { std::cout << *ptr << "\n"; };  // 컴파일 에러
// ✅ 올바른 예
auto lambda = [p = std::move(ptr)]() { std::cout << *p << "\n"; };

에러 2: 초기화 캡처 후 원본 사용

증상: 이동 후 ptr을 사용하면 정의되지 않은 동작(UB) 또는 null 참조. 원인: std::move(ptr) 후 ptr은 빈 상태입니다.

// ❌ 잘못된 예
auto ptr = std::make_unique<int>(42);
auto lambda = [p = std::move(ptr)]() { /* ... */ };
std::cout << *ptr << "\n";  // UB: ptr은 이미 빈 상태
// ✅ 올바른 예
auto lambda = [p = std::move(ptr)]() { std::cout << *p << "\n"; };
lambda();

에러 3: IIFE 반환 타입 생략 시 추론 실패

증상: 복잡한 분기에서 반환 타입이 달라지면 컴파일 에러. 원인: 컴파일러가 여러 return 경로의 공통 타입을 추론하지 못할 수 있습니다.

// ❌ 타입이 다를 수 있음
const auto x = [&]() {
    if (flag) return 1;
    return std::string("hello");  // int vs string
}();
// ✅ 명시적 반환 타입
const auto x = [&]() -> std::variant<int, std::string> {
    if (flag) return 1;
    return std::string("hello");
}();

에러 4: 재귀 람다에서 참조 캡처 수명

증상: std::function을 지역 변수로 두고 람다를 반환하면, 반환된 람다가 나중에 호출될 때 std::function이 이미 소멸해 댕글링 참조.

// ❌ 위험
std::function<int(int)> makeFactorial() {
    std::function<int(int)> factorial;
    factorial = [&factorial](int n) {
        if (n <= 1) return 1;
        return n * factorial(n - 1);
    };
    return factorial;  // factorial 참조가 댕글링됨!
}
// ✅ shared_ptr로 수명 연장 (또는 Y combinator)
std::function<int(int)> makeFactorial() {
    auto factorial = std::make_shared<std::function<int(int)>>();
    *factorial = [factorial](int n) {
        if (n <= 1) return 1;
        return n * (*factorial)(n - 1);
    };
    return *factorial;
}

에러 5: 완벽 전달 시 decltype 누락

증상: std::forward에 잘못된 타입을 넘기면 lvalue/rvalue가 보존되지 않음.

// ❌ 잘못된 예
auto bad = [](auto x) {
    return std::forward<decltype(x)>(x);  // x는 이미 lvalue
};
// ✅ 올바른 예: 인자를 forwarding reference로
auto good = [](auto&& x) -> decltype(auto) {
    return std::forward<decltype(x)>(x);
};

에러 6: mutable 람다를 여러 스레드에서 공유

증상: 데이터 레이스, 정의되지 않은 동작(UB). 원인: mutable 람다의 캡처 변수는 람다 객체의 멤버입니다. 여러 스레드가 같은 람다를 공유하면 동시 수정이 발생합니다.

// ❌ 위험: 데이터 레이스
auto counter = [n = 0]() mutable { return ++n; };
std::thread t1([&counter]() { for (int i = 0; i < 100; ++i) counter(); });
std::thread t2([&counter]() { for (int i = 0; i < 100; ++i) counter(); });
// ✅ 스레드마다 람다 복사
std::thread t1([counter]() mutable { /* ... */ });
std::thread t2([counter]() mutable { /* ... */ });

에러 7: std::set/priority_queue에 람다 전달 시 생성자 누락

증상: error: use of deleted default constructor 또는 error: no matching constructor. 원인: std::set<T, Compare>와 std::priority_queue<T, Container, Compare>는 비교자 타입의 기본 생성자가 없으면, 생성자에 비교자 인스턴스를 넘겨야 합니다.

// ❌ C++17까지: 에러
auto cmp = [](int a, int b) { return a > b; };
std::set<int, decltype(cmp)> s;  // 에러: cmp는 기본 생성 불가
// ✅ 올바른 예
std::set<int, decltype(cmp)> s(cmp);
std::priority_queue<int, std::vector<int>, decltype(cmp)> pq(cmp);

C++20부터는 캡처가 없는 람다가 기본 생성 가능해져서 첫 번째 코드도 컴파일됩니다(GCC 10에서 -std=c++17은 에러, -std=c++20은 통과를 확인). 같은 코드가 표준 버전에 따라 되기도 하고 안 되기도 하므로, 여러 표준으로 빌드하는 라이브러리라면 생성자에 비교자를 넘기는 쪽으로 통일하는 것이 안전합니다. 캡처가 있는 람다는 C++20에서도 기본 생성할 수 없습니다.

에러 8: IIFE에서 void 반환

증상: error: void value not ignored as it ought to be 또는 예상치 못한 동작. 원인: IIFE의 () 뒤에 ;를 붙이면 문장이 되고, 반환값을 사용하지 않습니다. void를 반환하는 람다를 IIFE로 쓰면 ()만 호출해도 됩니다.

// ✅ void 반환 IIFE: 초기화 부수 효과만 수행
[&]() {
    initializeSomething();
    return;  // void
}();
// ❌ 잘못된 사용
auto x = [&]() { return; }();  // x는 void

에러 9: [=]가 멤버를 복사한다고 착각 (this 캡처)

증상: 비동기 콜백에서 멤버 값을 읽는데 가끔 쓰레기 값이 나오거나 크래시가 납니다. 로컬 테스트에서는 재현이 잘 안 됩니다.

class Downloader {
    std::string url_;
public:
    void start(TaskQueue& q) {
        q.push([=] { fetch(url_); });   // url_을 복사한 것 같지만 실제로는 this를 복사
    }
};

원인: 멤버 함수 안에서 url_은 this->url_의 줄임말이고, 람다가 캡처하는 것은 멤버가 아니라 this 포인터입니다. [=]라는 표기 때문에 “전부 값으로 복사했다”고 믿기 쉽지만, 멤버는 결국 포인터를 통해 접근되므로 Downloader가 먼저 파괴되면 콜백이 실행될 때 댕글링이 됩니다. 이 혼동이 워낙 흔해서 C++20은 [=]의 암시적 this 캡처를 deprecated로 지정했고, GCC 10은 -std=c++20에서 implicit capture of 'this' via '[=]' is deprecated in C++20 경고를 냅니다. 이 경고가 뜬 곳은 대부분 한 번 들여다볼 가치가 있습니다.

해결법: 필요한 것이 무엇이냐에 따라 고릅니다.

q.push([url = url_] { fetch(url); });        // ✅ 필요한 멤버만 값으로 복사 (가장 명확)
q.push([*this] { fetch(url_); });            // ✅ C++17: 객체 전체를 복사 (큰 객체면 비용 주의)
q.push([this] { fetch(url_); });             // ⚠️ 객체 수명이 콜백보다 길다는 보장이 있을 때만

// 객체 수명을 콜백이 확인해야 할 때: shared_from_this + weak_ptr
class Session : public std::enable_shared_from_this<Session> {
    void onTimer(TaskQueue& q) {
        q.push([weak = weak_from_this()] {   // C++17
            if (auto self = weak.lock()) {   // 아직 살아 있으면
                self->handleTimeout();
            }                                // 이미 파괴됐으면 아무것도 안 함
        });
    }
};

[this]를 명시적으로 쓰면 적어도 “이 람다는 객체를 참조한다”는 의도가 코드에 드러나서 리뷰에서 수명 질문을 받게 됩니다. 콜백이 언제 실행될지 모르는 비동기 코드라면 weak_ptr로 생존 여부를 확인하는 패턴이 표준적인 해법이고, 반대로 shared_from_this()를 그대로 캡처하면 콜백이 큐에 남아 있는 동안 객체가 절대 파괴되지 않아 의도치 않게 수명이 늘어나니 둘을 구분해서 씁니다.

에러 요약표

에러원인해결법
unique_ptr 복사[=] 캡처[p=std::move(ptr)]
이동 전용 람다 → std::functionstd::function은 복사 가능한 호출체만 저장shared_ptr 캡처, C++23 move_only_function
[=]로 멤버 “복사”실제로는 this 포인터 캡처[v = member_], [*this], weak_ptr
이동 후 원본 사용ptr이 빈 상태이동 후 원본 사용 금지
IIFE 반환 타입분기별 타입 상이-> ReturnType 명시
재귀 람다 댕글링[&]로 std::function 캡처 후 반환shared_ptr 또는 Y combinator
forward 타입 오류auto x (값)auto&& x (forwarding ref)
mutable + 멀티스레드공유 람다 객체 수정스레드마다 람다 복사
set/pq 생성자람다 기본 생성 불가생성자에 람다 인스턴스 전달
IIFE void 반환void를 변수에 대입부수 효과만 수행 시 그대로 사용

캡처 방식과 콜백 저장 방식 고르기

unique_ptr/큰 객체는 초기화 캡처로 이동

복사 비용이 크거나 복사 불가인 타입은 [p = std::move(ptr)]로 넘깁니다.

auto task = [p = std::move(uniquePtr)]() { use(*p); };

제네릭 람다 전달 시 완벽 전달

래퍼나 데코레이터에서 인자를 그대로 넘길 때 auto&&와 std::forward를 사용합니다.

return [f = std::forward<F>(f)](auto&&... args) -> decltype(auto) {
    return f(std::forward<decltype(args)>(args)...);
};

복잡한 const 초기화는 IIFE

조건 분기나 여러 단계가 있는 초기화는 IIFE로 한 블록에서 처리합니다.

const auto config = [&]() {
    // 복잡한 로직
    return result;
}();

재귀는 필요할 때만 std::function

성능이 중요하면 Y combinator나 일반 재귀 함수를 고려하며, std::function은 편의성 우선일 때 사용합니다.

초기화 캡처와 기존 캡처 혼합 시 순서

[=, p = std::move(ptr)]에서 =가 먼저 오면 나머지 변수는 값 캡처, p만 초기화 캡처입니다.

콜백 저장 시 템플릿 vs std::function

한 번만 호출하는 콜백(예: std::sort의 비교자)은 템플릿으로 받아 인라인 최적화를 활용합니다. 여러 타입의 콜백을 저장해야 할 때만 std::function을 사용합니다.

// ✅ 성능 중요: 템플릿
template <typename Compare>
void sortWith(Compare cmp) { std::sort(...); }
// ✅ 저장 필요: std::function
std::vector<std::function<void()>> callbacks;

상태 보유 람다의 스레드 안전성

mutable 람다를 여러 스레드에서 호출할 때, 같은 람다 객체를 공유하면 데이터 레이스가 발생합니다. 스레드마다 복사하거나, 내부에서 std::mutex로 보호합니다.

람다 본문은 짧게 유지

5줄을 넘어가면 별도 함수나 auto compareItems = ...로 분리해 가독성을 높입니다. 복잡한 로직은 람다보다 명명된 함수가 유지보수에 유리합니다.


소유권 이전, 미들웨어, 비교자 주입: 람다 활용 패턴

패턴 A: 작업 큐에 소유권 이전

// C++23: 이동 전용 호출체를 그대로 저장
void enqueue(std::queue<std::move_only_function<void()>>& q, std::unique_ptr<Job> job) {
    q.push([p = std::move(job)]() {
        p->execute();
    });
}
// C++17 이하에서 std::function 큐를 써야 한다면 예제 1처럼 shared_ptr로 바꿔 캡처

패턴 B: 비동기 결과를 move로 전달

auto future = std::async(std::launch::async, [data = prepareLargeData()]() {
    return process(std::move(data));
});

패턴 C: IIFE로 설정 검증

const auto validatedConfig = [&]() -> Config {
    auto c = loadConfig();
    if (!c.validate()) throw std::runtime_error("Invalid config");
    c.applyDefaults();
    return c;
}();

패턴 D: 완벽 전달 미들웨어

template <typename Handler>
auto withLogging(Handler&& h) {
    return [h = std::forward<Handler>(h)](auto&&... args) -> decltype(auto) {
        log("enter");
        auto result = h(std::forward<decltype(args)>(args)...);
        log("exit");
        return result;
    };
}

패턴 E: 재귀 visit 패턴

template <typename T>
void visitTree(T* node, std::function<void(T*)> visitor) {
    std::function<void(T*)> visit;
    visit = [&visit, &visitor](T* n) {
        if (!n) return;
        visitor(n);
        for (auto* child : n->children) {
            visit(child);
        }
    };
    visit(node);
}

패턴 F: 상태 보유 람다로 시퀀스 생성

template <typename T>
auto makeSequenceGenerator(T start = T{}) {
    return [n = start]() mutable { return n++; };
}
// 사용
auto gen = makeSequenceGenerator(100);
int a = gen();  // 100
int b = gen();  // 101

패턴 G: 템플릿 콜백으로 인라인 유지

template <typename Container, typename Predicate>
auto findIf(Container& c, Predicate pred) {
    return std::find_if(std::begin(c), std::end(c), pred);
}
// 람다가 인라인됨
auto it = findIf(vec, [threshold](int x) { return x > threshold; });

패턴 H: std::function 콜백 저장 (수명 관리)

class AsyncWorker {
    std::function<void()> onComplete_;
public:
    void setOnComplete(std::function<void()> fn) {
        onComplete_ = std::move(fn);
    }
    void finish() {
        if (onComplete_) onComplete_();
    }
};
// 값 캡처로 수명 안전
int result = 42;
worker.setOnComplete([result]() { saveResult(result); });

패턴 I: 람다 비교자로 정렬 기준 주입

enum class SortBy { Name, Age, Score };
void sortPeople(std::vector<Person>& people, SortBy by) {
    switch (by) {
        case SortBy::Name:
            std::sort(people.begin(), people.end(),
                 [](const Person& a, const Person& b) { return a.name < b.name; });
            break;
        case SortBy::Age:
            std::sort(people.begin(), people.end(),
                 [](const Person& a, const Person& b) { return a.age < b.age; });
            break;
        case SortBy::Score:
            std::sort(people.begin(), people.end(),
                 [](const Person& a, const Person& b) { return a.score > b.score; });
            break;
    }
}

고급 람다 코드 리뷰 체크리스트

람다 고급 기능을 쓸 때 확인할 항목입니다.

  • unique_ptr/이동 전용: [p = std::move(ptr)] 사용
  • 이동 후 원본: 사용하지 않기
  • 완벽 전달: auto&& + std::forward<decltype(x)>(x)
  • IIFE 반환 타입: 분기 시 -> ReturnType 명시
  • 재귀 람다: 반환 시 [&] 캡처 주의, shared_ptr 또는 Y combinator
  • 큰 객체: 복사 대신 move 캡처
  • 상태 보유 람다: mutable + 초기화 캡처, 멀티스레드 시 복사
  • 템플릿 vs std::function: 저장 필요 시에만 std::function
  • set/priority_queue: 생성자에 람다 인스턴스 전달

같이 보면 좋은 글


참고 자료

자주 묻는 질문 (FAQ)

Q. unique_ptr을 캡처한 람다를 std::function에 넣을 수 없는 이유는?

A. std::function은 담은 호출체를 복사할 수 있어야 하는데, unique_ptr을 캡처한 람다는 복사할 수 없는 타입이 되기 때문입니다. C++23의 std::move_only_function을 쓰거나, 그 전이라면 shared_ptr로 바꿔 캡처합니다(예제 1).

Q. std::function 재귀와 Y combinator 중 뭘 써야 하나요?

A. 구현이 간단한 std::function은 프로토타입이나 성능이 중요하지 않은 경로에, Y combinator는 인라인과 성능이 중요한 경로에 사용하는 것을 권합니다. 다음 글: C++ 실전 가이드 #10-3: STL 알고리즘 이전 글: C++ 실전 가이드 #13-1: 람다 표현식