C++ 람다 표현식 | [=]·[&] 캡처와 sort·find_if에서 람다 활용법

C++ 람다 표현식 | [=]·[&] 캡처와 sort·find_if에서 람다 활용법

이 글의 핵심

C++ 람다 표현식에 대한 실전 가이드입니다. [=]·[&] 캡처와 sort·find_if에서 람다 활용법 등을 예제와 함께 상세히 설명합니다.

들어가며: 함수 객체가 너무 복잡하다

“정렬 기준을 바꾸려면 클래스를 만들어야 하나요?”

벡터를 정렬할 때 커스텀 비교 함수가 필요했습니다. 하지만 함수 객체를 만드는 게 너무 번거로웠습니다.

자주 겪는 문제 시나리오들

시나리오 1: find_if에 조건을 넘기려면?
”나이가 25 이상인 첫 번째 사람”을 찾으려면 std::find_if에 predicate를 넘겨야 합니다. 별도 함수나 함수 객체를 만들면 파일 상단에 정의가 떠 있고, 호출부와 멀리 떨어져 있어서 가독성이 떨어집니다.

시나리오 2: 스레드에 지역 변수를 넘기려면?
std::thread 생성 시 함수와 인자를 넘기는데, 여러 지역 변수를 조합한 로직을 실행하려면 구조체로 묶거나 std::bind를 써야 했습니다. 람다와 캡처를 쓰면 “어떤 변수를 어떻게 쓸지” 한 곳에서 명확히 표현할 수 있습니다.

시나리오 3: 콜백이 나중에 실행될 때
버튼 클릭 핸들러나 타이머 콜백처럼 “나중에 호출될” 함수에 지역 변수를 넘기려면, 참조로 넘기면 댕글링이 되고, 값으로 복사하면 수정이 반영되지 않는 딜레마가 있습니다. 람다의 캡처 모드([=], [&], [x, &y])를 이해하면 이 상황을 안전하게 처리할 수 있습니다.

정의를 풀어 쓰면 람다(lambda—이름 없이 한 곳에서만 쓰는 작은 함수를 정의하는 문법. 비유하면 필요한 순간에만 쓰는 임시 메모 같은 것)는 “이름 없는 작은 함수”를 호출하는 자리에서 바로 정의하는 문법입니다. “한 번만 쓰는 작은 함수”를 호출 지점에 바로 정의할 수 있어서, sort·find_if·스레드 생성처럼 콜백(나중에 호출될 함수를 인자로 넘기는 방식)이 필요한 STL(Standard Template Library)·API(Application Programming Interface)에서 자주 쓰입니다. 캡처로 주변 변수를 넘기되, 람다 수명이 콜백보다 길어질 수 있으면 참조 캡처는 위험하므로 값/참조 선택을 신경 쓰는 것이 좋습니다.

참조 캡처 [&] 사용 시 주의: 람다가 나중에 비동기로 실행되거나(예: 새 스레드, 타이머 콜백) 다른 컨테이너에 저장될 때, [&]로 캡처한 지역 변수는 이미 스코프를 벗어나 파괴된 상태일 수 있습니다. 그때 람다가 실행되면 댕글링 참조로 undefined behavior(정의되지 않은 동작, UB)가 됩니다. “지금 바로 호출되는” 콜백(예: sort의 비교자)에서는 참조 캡처가 안전하고, “나중에 호출될 수 있는” 콜백에서는 값 캡처 [=] 또는 필요한 변수만 값으로 [x, y] 캡처하는 편이 안전합니다.

문제의 코드에서는 std::sort에 비교 기준을 넘기기 위해 CompareByAge 구조체를 만들고 operator()를 구현했습니다. 정렬 기준이 하나일 때는 이렇게 클래스를 정의하는 것이 과한 경우가 많고, 비교 로직을 바꿀 때마다 별도 타입을 수정해야 합니다. 람다를 쓰면 호출하는 자리에서 “이름 없는 함수”를 바로 넣을 수 있어, Personage로 비교한다는 의도가 한눈에 들어오고, 나중에 name으로 바꾸는 것도 람다 본문만 고치면 됩니다. sort의 세 번째 인자로 넘기는 함수 객체는 “두 원소를 받아서 앞이 뒤보다 작으면 true”를 반환하면 됩니다.

struct CompareByAge {
    bool operator()(const Person& a, const Person& b) const {
        return a.age < b.age;
    }
};

std::vector<Person> people = /* ... */;
std::sort(people.begin(), people.end(), CompareByAge());

람다로 해결:

std::vector<Person> people = /* ... */;

// ✅ 간결하고 명확
std::sort(people.begin(), people.end(), 
           {
              return a.age < b.age;
          });

이 글을 읽으면:

  • 람다 표현식의 기본 문법을 이해할 수 있습니다.
  • 캡처 방식(값, 참조)을 올바르게 사용할 수 있습니다.
  • 실전에서 람다를 효과적으로 활용할 수 있습니다.
  • 람다의 성능과 제약을 이해할 수 있습니다.

람다의 캡처와 실행 흐름을 요약하면 아래와 같습니다.

flowchart TB
  subgraph capture["캡처 시점"]
    A[람다 정의] --> B{캡처 모드}
    B --> C[""(="] 값 복사"]
    B --> D[""(&"] 참조"]
    B --> E[""(x, &y"] 혼합"]
  end
  subgraph exec["실행 시점"]
    F[람다 호출] --> G{캡처 타입}
    G --> H["값: 스냅샷 사용"]
    G --> I["참조: 현재 값 접근"]
  end
  capture --> exec

목차

  1. 람다 기초
  2. 캡처 방식
  3. mutable과 예외 지정
  4. 제네릭 람다 (C++14)
  5. 실전 활용 패턴
  6. 자주 발생하는 오류
  7. 성능 최적화 팁
  8. 프로덕션 패턴

1. 람다 기초

기본 문법

람다는 캡처 [ ], 매개변수 ( ), 반환 타입 -> return_type(생략 가능), 본문 { } 네 부분으로 이루어집니다. 캡처는 주변 변수를 람다 안에서 어떻게 쓸지 정하고, 매개변수와 본문은 일반 함수와 비슷합니다. 아래처럼 선언한 람다는 이름이 있는 호출 가능 객체처럼 사용할 수 있습니다.

// 기본 형태
[capture](parameters) -> return_type {
    // 함수 본문
}

// 복사해 붙여넣은 뒤: g++ -std=c++17 -o lambda_basic lambda_basic.cpp && ./lambda_basic
#include <iostream>
int main() {
    auto add =  -> int {
        return a + b;
    };
    int result = add(3, 5);  // 8
    std::cout << result << "\n";
    return 0;
}

실행 결과: 8 이 한 줄 출력됩니다.

반환 타입 생략

-> return_type을 쓰지 않으면 컴파일러가 return문의 식으로부터 반환 타입을 추론합니다. 본문에 return이 하나만 있고 식이 단순하면 생략하는 경우가 많고, 여러 return이 있으면 모두 같은 타입이어야 합니다.

// 반환 타입 자동 추론
auto add =  {
    return a + b;  // int 반환
};

auto multiply =  {
    return a * b;  // double 반환
};

매개변수 없는 람다

매개변수가 없으면 ()만 쓰거나, C++11 이후에서는 괄호를 아예 생략할 수 있습니다. [] { ... }처럼 쓰면 “인자를 받지 않는 람다”입니다.

auto sayHello =  {
    std::cout << "Hello!\n";
};

sayHello();  // Hello!

// 괄호 생략 가능
auto sayWorld = [] {
    std::cout << "World!\n";
};

즉시 실행

람다를 정의한 뒤 바로 (인자)를 붙여 호출하면, “한 번만 쓰는 함수”를 인라인으로 실행할 수 있습니다. 복잡한 초기화 식을 한 번만 계산해서 변수에 넣고 싶을 때, 또는 스코프 안에서만 쓰는 임시 계산에 유용합니다.

// 람다를 정의하고 즉시 호출
int result =  {
    return x * x;
}(5);  // 25

// 초기화에 사용
auto data =  {
    std::vector<int> vec;
    for (int i = 0; i < 10; ++i) {
        vec.push_back(i * i);
    }
    return vec;
}();

2. 캡처 방식

값 캡처 [=]

[=]람다가 정의된 시점의 주변 변수들을 값으로 복사해 둡니다. 람다 안에서 쓰는 x, y는 그때의 스냅샷이므로, 나중에 바깥에서 x, y를 바꿔도 람다를 호출하면 예전 값이 나옵니다. 람다가 나중에 실행될 때(예: 콜백) 참조가 끊기지 않도록 할 때 값 캡처를 씁니다.

int x = 10;
int y = 20;

// 모든 외부 변수를 값으로 캡처
auto lambda = [=]() {
    std::cout << x << ", " << y << "\n";  // 10, 20
};

x = 100;  // 람다 내부의 x는 변하지 않음
lambda();  // 10, 20

참조 캡처 [&]

[&]는 주변 변수를 참조로 캡처합니다. 람다 안에서 x, y를 수정하면 원본이 바뀌고, 람다가 나중에 실행되면 그때의 x, y 값을 봅니다. 람다가 스코프를 벗어난 뒤에 호출될 수 있으면 참조가 이미 무효일 수 있어서(댕글링), 수명을 꼭 확인해야 합니다.

int x = 10;
int y = 20;

// 모든 외부 변수를 참조로 캡처
auto lambda = [&]() {
    x += 5;
    y += 10;
};

lambda();
std::cout << x << ", " << y << "\n";  // 15, 30

선택적 캡처

캡처할 변수를 이름으로 나열하면, [x]는 값, [&y]는 참조로만 캡처합니다. 나열하지 않은 변수는 람다 안에서 쓸 수 없습니다. 필요한 것만 골라서 캡처하면 의도가 분명해지고, 불필요한 복사나 참조를 줄일 수 있습니다.

int x = 10;
int y = 20;
int z = 30;

// x는 값, y는 참조, z는 캡처 안 함
auto lambda = [x, &y]() {
    std::cout << x << ", " << y << "\n";
    // std::cout << z;  // ❌ 에러: z 캡처 안 됨
};

혼합 캡처

[=, &y]기본은 값 캡처이고 y만 참조로, [&, x]기본은 참조이고 x만 값으로 캡처합니다. “대부분 복사하고 몇 개만 참조” 또는 그 반대일 때 한 번에 쓸 수 있습니다. 기본 캡처와 함께 쓸 때는 나머지 변수만 명시하면 됩니다.

int x = 10;
int y = 20;
int z = 30;

// 기본은 값, y만 참조
auto lambda1 = [=, &y]() {
    std::cout << x << ", " << y << ", " << z << "\n";
};

// 기본은 참조, x만 값
auto lambda2 = [&, x]() {
    std::cout << x << ", " << y << ", " << z << "\n";
};

this 캡처

멤버 함수 안에서 람다를 쓰고 멤버 변수에 접근하려면 [this]로 현재 객체를 캡처합니다. 람다 안에서 count처럼 멤버를 쓰면 this를 통해 접근하는 것이고, 객체가 소멸한 뒤에 람다가 호출되면 댕글링이 되므로 수명에 주의해야 합니다. C++17 이상에서는 [*this]로 객체 복사 캡처도 가능합니다.

class Counter {
    int count = 0;
    
public:
    void increment() {
        // this를 캡처
        auto lambda = [this]() {
            count++;  // 멤버 변수 접근
        };
        
        lambda();
    }
    
    int getCount() const { return count; }
};

캡처 모드 비교표

캡처 문법의미수명 안전사용 시점
[]아무것도 캡처 안 함항상 안전외부 변수 불필요
[=]모든 변수 값 복사나중 호출 시 안전비동기·스레드·저장용 콜백
[&]모든 변수 참조즉시 호출만 안전동기 콜백, sort·find_if
[x]x만 값 복사나중 호출 시 안전필요한 변수만 선택
[&y]y만 참조즉시 호출만 안전수정 필요할 때
[=, &y]기본 값, y만 참조y 수명 주의대부분 복사, y만 갱신
[&, x]기본 참조, x만 값x는 안전대부분 참조, x만 스냅샷
[this]현재 객체 포인터객체 수명 주의멤버 함수 내부
[*this] (C++17)객체 전체 복사나중 호출 시 안전스레드로 객체 넘길 때
[p = std::move(ptr)]move로 초기화소유권 이전unique_ptr, 큰 객체

초기화 캡처 (C++14)

[이름 = 식]람다 전용 변수를 하나 만들어서 식의 결과로 초기화합니다. 복사 비용을 피하려면 [p = std::move(ptr)]처럼 move로 가져올 수 있어서, unique_ptr이나 큰 객체를 람다로 넘길 때 유용합니다. 기존 변수와 다른 이름을 써도 됩니다.

int x = 10;

// 새 변수 생성
auto lambda = [y = x + 5]() {
    std::cout << y << "\n";  // 15
};

// move 캡처
auto ptr = std::make_unique<int>(42);
auto lambda2 = [p = std::move(ptr)]() {
    std::cout << *p << "\n";
};

3. mutable과 예외 지정

mutable 람다

값으로 캡처한 변수는 람다 안에서 const로 취급되어 수정할 수 없습니다. mutable을 붙이면 그 복사본은 수정 가능해지지만, 원본 x는 변하지 않습니다. 호출할 때마다 복사본이 갱신되는 것이 아니라, 람다 객체가 들고 있는 캡처 복사본만 바뀝니다.

int x = 0;

// 값 캡처는 기본적으로 const
auto lambda1 = [x]() {
    // x++;  // ❌ 에러: const
    std::cout << x << "\n";
};

// mutable: 값 캡처를 수정 가능
auto lambda2 = [x]() mutable {
    x++;  // ✅ OK (복사본 수정)
    std::cout << x << "\n";
};

lambda2();  // 1
lambda2();  // 2
std::cout << x << "\n";  // 0 (원본은 변하지 않음)

noexcept 지정

noexcept를 붙이면 이 람다가 예외를 던지지 않는다고 선언하는 것입니다. noexcept 함수에서 호출하거나, 이동 시 예외를 막고 싶을 때 사용하며, 실제로 예외를 던지면 std::terminate가 호출됩니다.

auto lambda =  noexcept {
    // 예외를 던지지 않음을 보장
    return 42;
};

속성 지정

auto lambda =  [[nodiscard]] {
    return 42;
};

// ⚠️ 경고: 반환값 무시
lambda();

4. 제네릭 람다 (C++14)

auto 매개변수

C++14부터 람다 매개변수에 auto를 쓸 수 있어서, 호출될 때마다 인자 타입에 맞는 템플릿 인스턴스가 생성됩니다. 한 람다로 여러 타입을 받을 수 있어서, 작은 제네릭 함수를 인라인으로 쓸 때 편합니다.

// 모든 타입 받기
auto print =  {
    std::cout << value << "\n";
};

print(42);           // int
print(3.14);         // double
print("hello");      // const char*

여러 타입 매개변수

auto add =  {
    return a + b;
};

std::cout << add(1, 2) << "\n";        // 3
std::cout << add(1.5, 2.5) << "\n";    // 4.0
std::cout << add(std::string("Hello"), std::string(" World")) << "\n";

템플릿 람다 (C++20)

C++20에서는 람다에 템플릿 매개변수를 직접 쓸 수 있습니다. []<typename T>(T value)처럼 하면 타입 T를 본문 안에서 사용할 수 있어서, typeid나 타입별 분기 같은 처리가 필요할 때 유용합니다.

auto lambda = []<typename T>(T value) {
    std::cout << typeid(T).name() << ": " << value << "\n";
};

lambda(42);      // int: 42
lambda(3.14);    // double: 3.14

5. 실전 활용 패턴

패턴 1: STL 알고리즘

find_if, count_if, all_of, transform 같은 STL 알고리즘은 조건이나 변환을 predicate/함수로 받습니다. 이런 자리에 람다를 넘기면 “짝수인지”, “제곱” 같은 로직을 호출 지점에 바로 적을 수 있어서, 별도 함수를 만들 필요가 없습니다.

완전한 예제 (복사 후 실행 가능):

// g++ -std=c++17 -o lambda_stl lambda_stl.cpp && ./lambda_stl
#include <algorithm>
#include <iostream>
#include <vector>

struct Person {
    std::string name;
    int age;
};

int main() {
    std::vector<Person> people = {
        {"Alice", 25}, {"Bob", 30}, {"Charlie", 20}, {"Diana", 25}
    };
    std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

    // find_if: 나이 25 이상 첫 번째
    auto it = std::find_if(people.begin(), people.end(),
         { return p.age >= 25; });
    if (it != people.end())
        std::cout << "Found: " << it->name << ", " << it->age << "\n";

    // count_if: 나이 25 이상 인원 수
    int count = std::count_if(people.begin(), people.end(),
         { return p.age >= 25; });
    std::cout << "Count (age>=25): " << count << "\n";

    // all_of: 모두 성인(18세 이상)?
    bool allAdult = std::all_of(people.begin(), people.end(),
         { return p.age >= 18; });
    std::cout << "All adult: " << (allAdult ? "yes" : "no") << "\n";

    // transform: 제곱
    std::vector<int> squares;
    std::transform(numbers.begin(), numbers.end(),
        std::back_inserter(squares),
         { return x * x; });
    std::cout << "Squares: ";
    for (int s : squares) std::cout << s << " ";
    std::cout << "\n";

    return 0;
}

실행 결과: Found: Alice, 25 / Count (age>=25): 3 / All adult: yes / Squares: 1 4 9 16 25 36 49 64 81 100

std::vector<int> numbers = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};

// 짝수 찾기
auto it = std::find_if(numbers.begin(), numbers.end(), 
                        { return x % 2 == 0; });

// 짝수 개수
int count = std::count_if(numbers.begin(), numbers.end(),
                           { return x % 2 == 0; });

// 모두 양수?
bool allPositive = std::all_of(numbers.begin(), numbers.end(),
                                { return x > 0; });

// 변환
std::vector<int> squares;
std::transform(numbers.begin(), numbers.end(), 
               std::back_inserter(squares),
                { return x * x; });

패턴 2: 정렬

sort의 세 번째 인자로 비교 함수를 넘기면, “a가 b보다 앞에 오려면” 조건을 람다로 줄 수 있습니다. 나이 비교, 이름 길이 비교처럼 기준을 바꿀 때마다 새 람다만 넣으면 되어서, 함수 객체 클래스를 따로 만들 필요가 없습니다.

struct Person {
    std::string name;
    int age;
};

std::vector<Person> people = {
    {"Alice", 25},
    {"Bob", 30},
    {"Charlie", 20}
};

// 나이순 정렬
std::sort(people.begin(), people.end(),
           {
              return a.age < b.age;
          });

// 이름 길이순
std::sort(people.begin(), people.end(),
           {
              return a.name.length() < b.name.length();
          });

패턴 3: std::thread와 람다

스레드에 로직을 넘길 때 람다를 쓰면 지역 변수를 캡처로 넘길 수 있습니다. 반드시 값 캡처를 사용해, 메인 스레드가 먼저 끝나도 안전하게 합니다.

// g++ -std=c++17 -pthread -o lambda_thread lambda_thread.cpp && ./lambda_thread
#include <iostream>
#include <thread>
#include <vector>

int main() {
    std::vector<int> data = {1, 2, 3, 4, 5};
    int multiplier = 10;

    // ✅ 값 캡처: data, multiplier 복사본을 스레드가 소유
    std::thread t([data, multiplier]() {
        for (int x : data) {
            std::cout << x * multiplier << " ";
        }
        std::cout << "\n";
    });

    t.join();
    return 0;
}

실행 결과: 10 20 30 40 50

패턴 4: 콜백

버튼 클릭처럼 나중에 호출될 동작을 저장할 때 std::function에 람다를 넘깁니다. [&clickCount]로 참조 캡처하면 바깥 변수를 갱신할 수 있지만, Button이 람다보다 오래 살 때만 안전합니다. 콜백이 객체보다 오래 살 수 있으면 값 캡처나 shared_ptr 등으로 수명을 맞추는 것이 좋습니다.

class Button {
    std::function<void()> onClick;
    
public:
    void setOnClick(std::function<void()> callback) {
        onClick = callback;
    }
    
    void click() {
        if (onClick) onClick();
    }
};

int main() {
    Button button;
    
    int clickCount = 0;
    button.setOnClick([&clickCount]() {
        clickCount++;
        std::cout << "Clicked " << clickCount << " times\n";
    });
    
    button.click();  // Clicked 1 times
    button.click();  // Clicked 2 times
}

패턴 5: RAII 헬퍼

스코프를 벗어날 때 반드시 한 번 실행되어야 하는 정리 코드(파일 닫기, 잠금 해제 등)를 람다로 넘겨서 ScopeGuard에 넣습니다. 생성자에서 람다를 받고, 소멸자에서 실행합니다. dismiss()로 “이미 처리했으니 실행하지 말라”고 표시할 수 있어서, 예외와 정상 경로 모두에서 안전하게 정리할 수 있습니다.

template <typename Func>
class ScopeGuard {
    Func func;
    bool active = true;
    
public:
    ScopeGuard(Func f) : func(std::move(f)) {}
    
    ~ScopeGuard() {
        if (active) func();
    }
    
    void dismiss() { active = false; }
};

template <typename Func>
auto makeScopeGuard(Func func) {
    return ScopeGuard<Func>(std::move(func));
}

void processFile(const std::string& filename) {
    FILE* file = fopen(filename.c_str(), "r");
    if (!file) return;
    
    // 자동으로 파일 닫기
    auto guard = makeScopeGuard([file]() {
        fclose(file);
        std::cout << "File closed\n";
    });
    
    // 파일 처리...
    
    // 스코프 종료 시 자동으로 닫힘
}

패턴 6: 지연 실행

Lazy는 처음 호출될 때만 람다를 실행하고, 그 결과를 캐시해 둡니다. 이후 호출에서는 캐시된 값을 반환하므로, 비용이 큰 계산을 “필요할 때 한 번만” 하게 할 수 있습니다. 람다로 계산 로직을 넘기고, optional로 결과만 보관하는 패턴입니다.

template <typename Func>
class Lazy {
    Func func;
    mutable std::optional<decltype(func())> cached;
    
public:
    Lazy(Func f) : func(std::move(f)) {}
    
    auto operator()() const {
        if (!cached) {
            cached = func();
        }
        return *cached;
    }
};

int main() {
    Lazy expensive( {
        std::cout << "Computing...\n";
        return 42;
    });
    
    std::cout << expensive() << "\n";  // Computing... 42
    std::cout << expensive() << "\n";  // 42 (캐시됨)
}

패턴 7: 재귀 람다

람다가 자기 자신을 호출하려면 이름이 필요한데, 람다는 기본적으로 이름이 없습니다. C++14에서는 std::function에 담고 참조 캡처 [&]로 그 function을 캡처하면, 본문에서 자기 자신을 호출할 수 있습니다. C++23에서는 this auto self로 재귀 호출 대상을 명시할 수 있습니다.

// C++14: std::function 사용
std::function<int(int)> factorial = [&](int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
};

std::cout << factorial(5) << "\n";  // 120

// C++23: 명시적 this
auto factorial2 =  {
    return n <= 1 ? 1 : n * self(n - 1);
};

6. 자주 발생하는 오류

오류 1: 댕글링 참조 (Dangling Reference)

증상: 프로그램이 크래시하거나, 이상한 값이 출력되거나, 릴리즈 빌드에서만 문제가 발생합니다.

원인: 참조로 캡처한 지역 변수가 스코프를 벗어나 소멸한 뒤, 람다가 실행됩니다.

// ❌ 잘못된 예: 람다를 반환하면서 참조 캡처
std::function<void()> createBroken() {
    int x = 42;
    return [&x]() { std::cout << x << "\n"; };  // x는 createBroken() 종료 시 소멸
}

// ✅ 올바른 예: 값 캡처
std::function<void()> createSafe() {
    int x = 42;
    return [x]() { std::cout << x << "\n"; };
}

오류 2: for 루프 변수 캡처

증상: 스레드나 콜백에서 루프 인덱스 i가 항상 마지막 값만 나옵니다.

원인: [&i]로 참조 캡처하면 모든 람다가 같은 i를 가리키고, 루프가 끝난 뒤 i는 최종값입니다.

// ❌ 잘못된 예
std::vector<std::thread> threads;
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([&i]() {
        std::cout << i << "\n";  // 모두 5 또는 undefined
    });
}

// ✅ 올바른 예: 값 캡처
for (int i = 0; i < 5; ++i) {
    threads.emplace_back([i]() {
        std::cout << i << "\n";  // 0, 1, 2, 3, 4
    });
}

오류 3: mutable 없이 값 캡처 수정 시도

증상: error: increment of read-only variable 'x' 컴파일 에러.

원인: 값 캡처된 변수는 람다 내부에서 const로 취급됩니다.

// ❌ 잘못된 예
int x = 0;
auto lambda = [x]() {
    x++;  // 컴파일 에러
};

// ✅ 올바른 예
auto lambda = [x]() mutable {
    x++;  // 복사본만 수정, 원본 x는 그대로
};

오류 4: std::function과 람다 타입 불일치

증상: std::function에 람다를 담을 때 캡처가 있으면 타입이 달라서, 템플릿이 아닌 고정 시그니처 함수에 넘기기 어렵습니다.

해결: std::function<void()>는 캡처 있는 람다도 받을 수 있습니다. 다만 std::function은 힙 할당을 하므로, 성능이 중요하면 template<typename F>로 람다를 그대로 전달하는 편이 좋습니다.

// ✅ std::function은 캡처 람다도 OK
std::function<void()> f = [x = 42]() { std::cout << x << "\n"; };

// ✅ 성능 중요 시: 템플릿으로 람다 직접 전달
template <typename Func>
void run(Func&& f) { f(); }
run( { std::cout << "no heap\n"; });

오류 5: this 캡처 후 객체 소멸

증상: 멤버 함수에서 람다를 스레드나 큐에 넘긴 뒤, 객체가 먼저 소멸하면 댕글링 포인터 접근으로 크래시합니다.

// ❌ 위험: 객체가 먼저 소멸할 수 있음
class Worker {
    void start() {
        std::thread t([this]() {
            this->doWork();  // Worker 소멸 후 호출 시 UB
        });
        t.detach();
    }
};

// ✅ 안전: shared_from_this 또는 [*this] (C++17)
class Worker : public std::enable_shared_from_this<Worker> {
    void start() {
        auto self = shared_from_this();
        std::thread t([self]() { self->doWork(); });
        t.detach();
    }
};

7. 성능 최적화 팁

팁 1: 캡처 비용 최소화

  • 작은 타입(int, 포인터): 값 캡처 [x]로 복사 비용 무시 가능
  • 큰 객체(std::string, vector): 참조 [&s] 또는 move [s = std::move(str)]
  • 여러 변수: 필요한 것만 [a, &b]로 선택 캡처. [=]는 사용하지 않는 변수까지 복사할 수 있음
std::string big(10000, 'x');
int threshold = 10;

// ❌ big 전체 복사
auto bad = [=]() { return big.size() > threshold; };

// ✅ 참조 + 값
auto good = [&big, threshold]() { return big.size() > threshold; };

팁 2: std::function 대신 템플릿

std::function은 타입 소거를 위해 힙 할당을 할 수 있어, 짧은 람다보다 오버헤드가 있습니다. 콜백을 한 번만 받아서 저장하지 않는다면 template<typename F>로 람다를 직접 받는 편이 좋습니다.

// ❌ 매 호출마다 function 오버헤드
void sortWithFunc(std::vector<int>& v, std::function<bool(int,int)> cmp);

// ✅ 인라인 가능, 오버헤드 없음
template <typename Compare>
void sortWithLambda(std::vector<int>& v, Compare cmp) {
    std::sort(v.begin(), v.end(), cmp);
}

팁 3: noexcept 람다

예외를 던지지 않는 람다에 noexcept를 붙이면, 이동 연산 등에서 컴파일러가 더 나은 코드를 생성할 수 있습니다.

auto safe =  noexcept { return 42; };

팁 4: 즉시 실행 람다로 복잡 초기화

복잡한 초기화를 한 번만 수행하고, 결과만 변수에 넣을 때 즉시 실행 람다를 쓰면 중간 임시 객체를 줄일 수 있습니다.

// ✅ 한 번만 계산, 이동으로 반환
auto config = [&]() {
    Config c;
    c.loadFromFile("config.json");
    c.merge(defaults);
    return c;  // RVO 또는 이동
}();

8. 프로덕션 패턴

패턴 A: 스레드에 안전하게 인자 전달

스레드에 넘기는 람다는 반드시 값 캡처로 필요한 데이터를 복사합니다. 참조 캡처는 메인 스레드가 먼저 진행해 변수를 소멸시킬 수 있어 위험합니다.

void processInBackground(const std::string& input) {
    // ✅ 값 캡처: input 복사본을 스레드가 소유
    std::thread t([input]() {
        auto result = expensiveComputation(input);
        saveResult(result);
    });
    t.detach();  // 또는 join()으로 대기
}

패턴 B: std::async와 람다

std::async에 람다를 넘길 때도 값 캡처를 사용합니다. future가 나중에 get()될 때까지 지역 변수는 살아 있어야 하므로, 값 캡처가 안전합니다.

auto future = std::async(std::launch::async, [data = prepareData()]() {
    return process(data);
});
// ... 다른 작업 ...
auto result = future.get();

패턴 C: 에러 처리와 함께하는 ScopeGuard

프로덕션에서는 예외가 발생해도 리소스를 안전하게 해제해야 합니다. ScopeGuard에 람다로 정리 로직을 넘기고, dismiss()로 성공 시 중복 실행을 막습니다.

#include <fstream>
#include <filesystem>

void writeWithBackup(const std::string& path, const std::string& data) {
    std::string tmpPath = path + ".tmp";
    std::ofstream out(tmpPath);
    if (!out) throw std::runtime_error("Cannot open " + tmpPath);

    // 실패 시 임시 파일 삭제
    auto guard = makeScopeGuard([tmpPath]() {
        std::filesystem::remove(tmpPath);
    });

    out << data;
    out.close();
    std::filesystem::rename(tmpPath, path);  // 성공 시 최종 경로로 이동
    guard.dismiss();  // 성공 시 삭제 스킵
}

패턴 D: 조건부 로직을 람다로 캡슐화

반복되는 if-else 패턴을 람다로 묶어서, 전략 패턴처럼 동작을 주입할 수 있습니다.

template <typename OnSuccess, typename OnFailure>
void tryOperation(OnSuccess&& onOk, OnFailure&& onFail) {
    if (doSomething()) {
        onOk();
    } else {
        onFail();
    }
}

tryOperation(
     { log("OK"); commit(); },
     { log("Failed"); rollback(); }
);

주의사항

댕글링 참조

지역 변수 x를 참조로 캡처한 람다를 반환하면, 함수가 끝난 뒤에는 x가 이미 소멸해 댕글링 참조가 됩니다. 그 람다를 나중에 호출하면 미정의 동작입니다. 따라서 함수 밖으로 나가는 람다에서는 참조 캡처 대신 값 캡처로 필요한 값만 복사해 두는 것이 안전합니다.

std::function<void()> createLambda() {
    int x = 42;
    
    // ❌ 위험: x의 참조 캡처
    return [&x]() {
        std::cout << x << "\n";  // x는 이미 소멸!
    };
}

// ✅ 안전: 값 캡처
std::function<void()> createLambda() {
    int x = 42;
    return [x]() {
        std::cout << x << "\n";
    };
}

캡처 비용

[largeString]처럼 값 캡처하면 그 순간 복사가 일어납니다. 크기가 큰 객체면 비용이 들고, 람다가 한 번만 호출되거나 읽기만 할 때는 참조 캡처 [&largeString]로 복사를 피할 수 있습니다. 람다로 옮긴 뒤 원본을 더 쓰지 않을 거라면 초기화 캡처 [s = std::move(largeString)]로 이동하는 것이 좋습니다.

std::string largeString(10000, 'x');

// ❌ 복사 비용
auto lambda1 = [largeString]() {
    // largeString 복사됨
};

// ✅ 참조 사용
auto lambda2 = [&largeString]() {
    // 참조만
};

// ✅ move 캡처
auto lambda3 = [s = std::move(largeString)]() {
    // move됨
};

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ STL 알고리즘 | sort·find·transform 람다와 함께 쓰기 (실전 패턴)
  • C++ auto와 decltype | 타입 추론으로 코드 간결하게 만드는 방법
  • C++ Move Semantics | std::move로 불필요한 복사 제거하고 성능 최적화

이 글에서 다루는 키워드 (관련 검색어)

C++ 람다, 람다 표현식, 캡처 [=] [&], mutable, 제네릭 람다, sort 람다, find_if 등으로 검색하시면 이 글이 도움이 됩니다.

정리

항목문법용도
값 캡처[=]모든 변수 복사
참조 캡처[&]모든 변수 참조
선택 캡처[x, &y]x 복사, y 참조
this 캡처[this]멤버 접근
초기화 캡처[x = expr]새 변수 생성
mutable mutable값 캡처 수정
제네릭모든 타입

캡처 선택 가이드:

  • 즉시 호출(sort, find_if): [&] 또는 [=] 모두 안전
  • 나중 호출(스레드, async, 콜백 저장): [=] 또는 [x, y] 값 캡처
  • 큰 객체: [&] 또는 [s = std::move(str)] move 캡처

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++11 람다 표현식 완벽 가이드. [=]·[&]·[this] 캡처 방식, mutable 키워드, 제네릭 람다(C++14), 즉시 실행 람다, sort·find_if·thread와 함께 쓰기, 참조 캡처 주의사항,… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: 람다로 한 곳에서만 쓰는 함수를 인라인 정의해 STL과 콜백에 활용할 수 있습니다. 다음으로 std::function(#13-2)를 읽어보면 좋습니다.

이전 글: C++ 실전 가이드 #12-3: optional, variant, any

다음 글: C++ 실전 가이드 #13-2: std::function과 함수 객체 — 콜백과 전략 패턴을 다룹니다.

핵심 원칙:

  1. 짧은 로직은 람다
  2. 참조 캡처 주의 (수명)
  3. 큰 객체는 참조 또는 move
  4. STL 알고리즘과 함께 사용
  5. 콜백에 활용

관련 글

  • C++ 람다 기초 완벽 가이드 | 캡처·mutable·제네릭 람다와 실전 패턴
  • C++ vector 기초 완벽 가이드 | 초기화·연산·용량 관리와 실전 패턴
  • C++ std::function | 콜백·전략 패턴과 함수 객체
  • C++ map·set 완벽 가이드 | ordered vs unordered· 커스텀 키
  • C++ 컨테이너 선택 가이드 | vector/list/deque/map/set 상황별 선택과 성능 최적화