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

[=]·[&]만으로 부족할 때는 초기화 캡처 [ptr = std::move(p)]로 소유권을 람다 안으로 옮기고, 받은 인자를 그대로 다음 함수에 넘겨야 할 때는 auto&&와 std::forward를 조합합니다. 기본 문법은 #13-1 람다 표현식에서 다뤘으므로, 이 글은 그 다음 단계의 패턴과 함정을 다룹니다.

들어가며: unique_ptr을 작업 큐에 넘기려면

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

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

C++14의 초기화 캡처로 람다 전용 변수에 소유권을 옮기면 첫 번째 문제가 풀립니다.

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

여기서 mutable은 빠뜨리면 안 됩니다. 값으로 캡처된 변수는 람다의 operator()가 const이기 때문에 본문에서 const로 보이고, mutable 없이 std::move(p)를 쓰면 const unique_ptr&&가 되어 이동이 아니라 삭제된 복사 생성자를 호출하려다 컴파일 에러가 납니다.

그런데 taskQueue가 흔히 쓰는 std::queue<std::function<void()>>라면 이 코드도 컴파일되지 않습니다. unique_ptr을 품은 람다는 복사할 수 없는데 std::function은 복사 가능한 호출체만 담기 때문입니다. 이 두 번째 벽은 아래 “비동기 작업 큐에 unique_ptr 넘기기” 예제에서 해결법과 함께 다룹니다. 이 글에서 다루는 세 가지 패턴의 흐름을 그림으로 나타내면 다음과 같습니다.

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)]() mutable {
        process(std::move(p));  // mutable이 없으면 const unique_ptr이라 이동 불가
    };
    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 <iostream>
#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)]처럼 기본 캡처와 함께 쓸 수 있습니다. 기본 캡처(=나 &)는 항상 캡처 목록의 맨 앞에 와야 하고, 나머지 변수는 기본 캡처 규칙을 따르며 p만 초기화 캡처로 만들어집니다.

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

완벽 전달 (Perfect Forwarding)

왜 완벽 전달이 필요한가

제네릭 람다나 래퍼에서 인자를 내부 함수에 넘길 때, lvalue는 lvalue로, rvalue는 rvalue로 전달해야 불필요한 복사를 막을 수 있습니다. 이름이 붙은 매개변수는 타입이 rvalue 참조여도 식으로서는 lvalue이므로, 원래 값 범주를 되살리려면 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 (int& 반환)
    std::cout << identity(100) << "\n";  // 100 (int&& 반환)
    return 0;
}

실행 결과:

8
42
100

identity(100)이 반환하는 int&&는 임시 객체를 가리키므로 같은 전체 식 안에서 쓰는 것은 괜찮지만, auto&& r = identity(100);처럼 붙잡아 두면 다음 줄에서 댕글링이 됩니다. decltype(auto)로 참조를 그대로 반환하는 래퍼는 이런 수명 함정이 있다는 점을 기억해 두십시오.

제네릭 람다에서 완벽 전달

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

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" 다음 줄에 3

emplace와 완벽 전달

std::vector::emplace_back처럼 생성 인자를 그대로 전달할 때도 같은 패턴을 씁니다.

#include <iostream>
#include <string>
#include <vector>
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를 유지하면서 초기화할 수 있습니다.

// ❌ const는 선언 후 대입 불가
const std::string config;
if (useFile) {
    config = loadFromFile("config.json");  // 에러
} else {
    config = getDefaultConfig();           // 에러
}
// ✅ 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>
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 안에서 만든 임시 변수는 람다 스코프에 갇혀 바깥에 노출되지 않으므로, 초기화에만 쓰는 중간 값이 함수 전체에 퍼지지 않는다는 장점도 있습니다.

lock_guard와 IIFE

락을 잡은 상태에서 값을 읽거나 초기화하고, 락은 IIFE가 끝날 때 바로 풀리게 할 때 유용합니다.

#include <mutex>
std::mutex mtx;
SomeResource* resource = nullptr;
SomeResource* const handle = [&]() {
    std::lock_guard<std::mutex> lock(mtx);
    if (!resource) {
        resource = new SomeResource();
    }
    return resource;
}();
// lock은 IIFE가 반환될 때 이미 해제됨

단순히 한 번만 초기화하는 것이 목적이라면 함수 안의 static 지역 변수가 C++11부터 스레드 안전하게 초기화되므로 그쪽이 더 간단합니다.


재귀 람다 (Recursive Lambda)

문제: 람다는 자기 자신을 부를 이름이 없다

일반 함수는 이름이 있어서 재귀 호출이 쉽지만, 람다는 클로저 타입에 이름이 없고 auto 변수에 담으면 초기화가 끝나기 전에는 그 변수의 타입도 정해지지 않습니다.

// ❌ error: use of 'factorial' before deduction of 'auto'
auto factorial = [](int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
};

해결 1: 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

이 방식의 비용은 매 재귀 호출이 std::function의 간접 호출을 거친다는 점입니다. 컴파일러가 호출 대상을 알기 어려워 인라인이 잘 되지 않고, 캡처가 커서 내부 버퍼에 들어가지 않으면 담을 때 힙 할당도 생길 수 있습니다. 또 지역 std::function을 참조로 캡처했으므로 이 람다를 함수 밖으로 반환하면 안 됩니다(아래 에러 4).

해결 2: Y combinator

Y combinator 형태의 래퍼를 쓰면 std::function 없이 람다만으로 재귀를 구현할 수 있습니다. 람다가 첫 번째 인자로 “자기 자신을 호출하는 방법”을 받는 구조입니다.

// g++ -std=c++17 -o y_combinator y_combinator.cpp && ./y_combinator
#include <iostream>
#include <utility>
template <typename F>
struct Y {
    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에서는 명시적 객체 매개변수(explicit object parameter, deducing this)로 람다가 자기 자신을 직접 받을 수 있어 가장 간단합니다. GCC 14, Clang 18 이상처럼 이 기능을 지원하는 컴파일러가 필요합니다.

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

재귀 람다 비교표

방식장점단점
std::function구현 간단, C++11부터 가능간접 호출로 인라인 어려움, 반환 시 수명 주의
Y combinatorstd::function 없이 인라인 가능래퍼와 문법이 낯섦
C++23 deducing this가장 직관적C++23 지원 컴파일러 필요

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

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

unique_ptr을 초기화 캡처한 람다를 그대로 std::function 큐에 넣으면 GCC에서는 다음과 같은 에러가 납니다.

.../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에서 컴파일·실행되는 버전입니다.

// 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::thread와 std::async는 호출체를 복사본으로 저장하되 rvalue는 이동해서 저장하므로, 이동 전용 람다를 std::move로 넘기면 그대로 받을 수 있습니다. C++23을 쓸 수 있다면 큐를 std::queue<std::move_only_function<void()>>로 바꾸는 것만으로 처음의 [p = std::move(ptr)]() mutable 코드가 그대로 동작합니다.

예제 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;
}

decltype(auto) 반환에서 return result;는 result의 선언 타입(값)으로 반환되지만, return (result);처럼 괄호를 치면 지역 변수에 대한 참조를 반환하게 되어 댕글링이 됩니다. 또 void를 반환하는 함수를 감싸면 auto result = ...가 컴파일되지 않으므로, 범용 래퍼라면 if constexpr로 void 경우를 따로 처리해야 합니다.

예제 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;
}

config가 const이므로 이후에는 operator[](없는 키를 삽입할 수 있어 const 맵에는 없음) 대신 at()이나 find()로 읽어야 합니다.

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

#include <functional>
#include <iostream>
#include <vector>
struct Node {
    int value;
    std::vector<Node*> children;
};
int main() {
    Node n1{1, {}}, n2{2, {}}, n3{3, {}};
    Node root{0, {&n1, &n2, &n3}};
    std::function<void(const Node*)> visit = [&visit](const Node* node) {
        if (!node) return;
        std::cout << node->value << " ";
        for (const Node* child : node->children) {
            visit(child);  // 자기 자신을 재귀 호출
        }
    };
    visit(&root);  // 0 1 2 3
    std::cout << "\n";
    return 0;
}

자주 만나는 람다 에러

에러 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: 초기화 캡처 후 원본 사용

std::move(ptr)로 캡처한 뒤 ptr은 빈 상태이므로, 역참조하면 널 포인터 역참조로 정의되지 않은 동작이 됩니다.

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

에러 3: 분기마다 다른 반환 타입

반환 타입을 생략한 람다는 모든 return 문에서 추론된 타입이 정확히 같아야 합니다. 공통 타입으로 변환해 주지 않으므로 int와 std::string을 섞으면 inconsistent types ... deduced for lambda return type 에러(GCC)가 납니다. 반환 타입을 명시하면 각 return이 그 타입으로 변환됩니다.

// ❌ 추론 실패: int vs std::string
const auto x = [&]() {
    if (flag) return 1;
    return std::string("hello");
}();
// ✅ 명시적 반환 타입 (#include <variant>)
const auto x = [&]() -> std::variant<int, std::string> {
    if (flag) return 1;
    return std::string("hello");
}();

return 1;과 return 2.0;처럼 숫자끼리 섞여도 같은 에러가 나므로, 분기가 있는 IIFE는 반환 타입을 적어 두는 편이 안전합니다.

에러 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;  // 복사본 안의 람다가 소멸할 지역 변수를 참조
}

shared_ptr<std::function>을 만들어 람다가 그 shared_ptr을 값으로 캡처하게 하는 방법도 자주 보이지만, std::function이 자신을 가리키는 shared_ptr을 품게 되어 순환 참조가 생기고 메모리가 해제되지 않습니다. 자기 자신을 인자로 받는 람다를 쓰면 캡처할 것이 없어서 수명 문제가 사라집니다.

// ✅ 자기 자신을 인자로 넘기는 방식 (수명·순환 참조 문제 없음)
std::function<int(int)> makeFactorial() {
    auto impl = [](const auto& self, int n) -> int {
        if (n <= 1) return 1;
        return n * self(self, n - 1);
    };
    return [impl](int n) { return impl(impl, n); };
}

에러 5: 값으로 받은 인자를 forward

std::forward<decltype(x)>(x)는 x가 전달 참조(auto&&)일 때만 의미가 있습니다. auto x로 받으면 인자는 이미 복사(또는 이동)된 지역 변수이고, decltype(x)는 참조가 아닌 타입이라 std::forward가 사실상 std::move처럼 동작합니다. 호출자가 넘긴 값이 lvalue였는지 rvalue였는지는 이미 사라진 뒤입니다.

// ❌ 값으로 받아 이미 복사됨, 값 범주 정보 없음
auto bad = [](auto x) {
    return std::forward<decltype(x)>(x);
};
// ✅ 인자를 전달 참조로
auto good = [](auto&& x) -> decltype(auto) {
    return std::forward<decltype(x)>(x);
};

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

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(); });

// ✅ 스레드마다 람다 객체를 복사 (각자 독립된 n을 가짐)
std::thread t3([counter]() mutable { for (int i = 0; i < 100; ++i) counter(); });
std::thread t4([counter]() mutable { for (int i = 0; i < 100; ++i) counter(); });

스레드끼리 카운터를 공유해야 한다면 복사는 답이 아니고, std::atomic<int>나 std::mutex로 보호한 공유 상태를 캡처해야 합니다.

에러 7: std::set/priority_queue에 람다 비교자를 넘길 때 생성자 누락

std::set<T, Compare>와 std::priority_queue<T, Container, Compare>는 비교자 객체를 기본 생성하려고 하는데, C++17까지 람다의 클로저 타입은 기본 생성자가 없습니다. 그래서 생성자에 비교자 인스턴스를 넘겨야 합니다.

// ❌ 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부터는 캡처가 없는 람다가 기본 생성 가능해져서 첫 번째 코드도 컴파일됩니다. 같은 코드가 표준 버전에 따라 되기도 하고 안 되기도 하므로, 여러 표준으로 빌드하는 라이브러리라면 생성자에 비교자를 넘기는 쪽으로 통일하는 것이 안전합니다. 캡처가 있는 람다는 C++20에서도 기본 생성할 수 없습니다.

에러 8: void를 반환하는 IIFE의 결과를 변수에 담기

void를 반환하는 람다를 즉시 호출하는 것 자체는 문제가 없습니다. 부수 효과만 필요한 초기화라면 결과를 받지 않고 호출만 하면 됩니다. 하지만 그 결과를 auto 변수에 담으면 void 타입 변수를 선언하는 셈이라 GCC에서 'void x' has incomplete type 에러가 납니다.

// ✅ void 반환 IIFE: 부수 효과만 수행
[&]() {
    initializeSomething();
}();
// ❌ 결과를 변수에 담을 수 없음
auto x = [&]() { return; }();  // error: 'void x' has incomplete type

에러 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는 -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_); });             // ⚠️ 객체 수명이 콜백보다 길다는 보장이 있을 때만

// 객체 수명을 콜백이 확인해야 할 때: 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 캡처 후 반환자기 자신을 인자로 받는 람다, Y combinator
forward 타입 오류auto x (값)auto&& x (전달 참조)
mutable + 멀티스레드공유 람다 객체 수정스레드마다 복사, 공유 상태는 atomic/mutex
set/pq 생성자C++17까지 람다 기본 생성 불가생성자에 람다 인스턴스 전달
void IIFE 결과 대입void 타입 변수 선언결과를 받지 않고 호출만

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

콜백을 받는 쪽의 타입을 정할 때는 저장이 필요한지가 기준입니다. std::sort의 비교자처럼 그 자리에서 호출하고 끝나는 콜백은 템플릿 매개변수로 받으면 컴파일러가 람다 본문을 인라인할 수 있습니다. 여러 종류의 콜백을 멤버나 컨테이너에 저장해야 할 때만 std::function(이동 전용이면 C++23 std::move_only_function)으로 타입을 지웁니다. 재귀 람다도 같은 기준으로, 편의가 우선이면 std::function, 성능이 중요하면 Y combinator나 평범한 이름 있는 함수를 고르면 됩니다.

// 그 자리에서 호출: 템플릿으로 받아 인라인 가능
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; });

// 저장 필요: 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); });  // 값 캡처라 수명 문제 없음

저장되는 콜백은 캡처한 대상의 수명이 콜백보다 길어야 한다는 점이 가장 중요합니다. 위처럼 값으로 캡처하면 안전하고, 참조나 this를 캡처했다면 에러 9의 weak_ptr 패턴을 검토하십시오. 람다 본문이 길어져 한눈에 읽히지 않는다면 이름 있는 함수로 빼는 편이 유지보수에 유리합니다.


소유권 이전, 비동기, 상태 보유: 람다 활용 패턴

패턴 A: 작업 큐에 소유권 이전 (C++23)

// 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()]() mutable {
    return process(std::move(data));  // mutable이 없으면 data가 const라 복사됨
});

mutable이 없으면 std::move(data)가 const rvalue가 되어 process가 값으로 받는 경우 이동이 아니라 복사가 일어납니다. 컴파일 에러 없이 조용히 복사되므로 unique_ptr 때보다 알아차리기 어렵습니다.

패턴 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 T>
auto makeSequenceGenerator(T start = T{}) {
    return [n = start]() mutable { return n++; };
}
// 사용
auto gen = makeSequenceGenerator(100);
int a = gen();  // 100
int b = gen();  // 101

호출 사이에 상태를 유지해야 하는데 전역 변수나 별도 클래스를 만들기는 과할 때 mutable과 초기화 캡처의 조합이 간결합니다. 람다 객체를 복사하면 상태도 함께 복사된다는 점은 에러 6과 같습니다.

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

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;
    }
}

분기마다 std::sort를 따로 호출하는 것은 중복처럼 보이지만, 각 호출이 서로 다른 람다 타입으로 인스턴스화되어 비교자가 인라인됩니다. 비교자를 std::function<bool(const Person&, const Person&)> 하나에 담아 std::sort를 한 번만 호출하면 코드는 짧아지지만 비교할 때마다 간접 호출이 생깁니다.


같이 보면 좋은 글


참고 자료

자주 묻는 질문 (FAQ)

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

구현이 간단한 std::function은 프로토타입이나 성능이 중요하지 않은 경로에, Y combinator나 자기 자신을 인자로 받는 람다는 재귀 호출이 많은 경로에 쓰는 것을 권합니다. C++23을 쓸 수 있다면 deducing this가 가장 읽기 쉽습니다.

다음 글: C++ 실전 가이드 #10-3: STL 알고리즘 이전 글: C++ 실전 가이드 #13-1: 람다 표현식