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 combinator | std::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::function | std::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를 한 번만 호출하면 코드는 짧아지지만 비교할 때마다 간접 호출이 생깁니다.
같이 보면 좋은 글
참고 자료
- cppreference - Lambda expressions
- cppreference - std::forward
- cppreference - std::function
- cppreference - std::move_only_function
자주 묻는 질문 (FAQ)
std::function 재귀와 Y combinator 중 뭘 써야 하나요?
구현이 간단한 std::function은 프로토타입이나 성능이 중요하지 않은 경로에, Y combinator나 자기 자신을 인자로 받는 람다는 재귀 호출이 많은 경로에 쓰는 것을 권합니다. C++23을 쓸 수 있다면 deducing this가 가장 읽기 쉽습니다.
다음 글: C++ 실전 가이드 #10-3: STL 알고리즘 이전 글: C++ 실전 가이드 #13-1: 람다 표현식