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::function | std::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: 생성자에 람다 인스턴스 전달
같이 보면 좋은 글
- C++ 람다 표현식 | 캡처·mutable·제네릭 람다와 재귀 람다
- C++ STL 알고리즘 기초: sort·find·transform·accumulate 실전 활용
- C++ Perfect Forwarding | std::forward로 “복사 없이 인자 전달”
- C++ vector 성능
- C++ map vs unordered_map (STL 시리즈)
- C++ 템플릿 입문 | template
와 템플릿 컴파일 에러 해결법
참고 자료
자주 묻는 질문 (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: 람다 표현식