본문으로 건너뛰기

안동민 개발노트

본문 시작

람다 표현식

캡처·매개변수·반환 타입으로 람다를 구성하고 지역 상태와 STL 알고리즘을 연결하는 패턴 및 주의점을 익힙니다.

이번 장에서는 람다 표현식(Lambda Expressions)의 문법, 캡처 방식, STL 알고리즘에서의 사용 패턴을 다룹니다.

람다 표현식은 C++11에서 도입되었으며, 코드 내에서 짧은 함수를 즉석에서 정의하고 사용할 수 있게 해줍니다.

이는 특히 STL 알고리즘과 함께 사용할 때 코드의 가독성과 유연성을 높입니다.

12장 4절에서 함수 객체(Functors)를 잠깐 다루면서 람다를 언급했지만, 여기서는 람다의 모든 면모를 깊이 있게 탐구할 것입니다.

HTML 다이어그램: /docs/cpp/ch14/ch14-2/1.html

람다 표현식이란 무엇인가?

람다 표현식은 이름이 없는(익명) 함수 객체(Functor)를 코드 내에서 즉석으로 생성하는 방법입니다.

즉, 별도의 클래스를 정의하거나 전역 함수를 만들 필요 없이, 필요한 곳에 바로 함수처럼 호출할 수 있는 객체를 만들 수 있습니다.

등장 배경
  • STL 알고리즘(std::sort, std::for_each, std::transform, std::find_if 등)은 사용자 정의 동작을 위해 함수 포인터나 함수 객체를 인자로 받습니다.
  • 함수 포인터는 상태(멤버 변수)를 가질 수 없고, 함수 객체는 작은 함수를 위해 별도의 클래스를 정의해야 하는 번거로움이 있었습니다.
  • 람다 표현식은 이러한 불편함을 해소하고, 인라인으로 함수 객체를 생성할 수 있게 하여 코드를 훨씬 간결하고 의미 있게 만듭니다.
람다 표현식 기본 형식
[캡처](매개변수) -> 반환타입 {
    // 함수 본문
}
  • [캡처] (Capture Clause): 람다 본문 내에서 람다 외부의 변수를 사용할 수 있도록 지정하는 부분입니다. 람다의 핵심 기능 중 하나입니다.
  • (매개변수) (Parameter List): 일반 함수처럼 람다가 받을 매개변수 목록입니다.
  • -> 반환타입 (Return Type): 람다의 반환 타입입니다. 보통 생략할 수 있으며, 컴파일러가 본문에서 추론합니다. 반환 타입이 복잡하거나 명시적으로 지정하고 싶을 때 사용합니다.
  • { 함수 본문 } (Function Body): 람다가 실행할 코드 블록입니다.

캡처 절 (Capture Clause) 상세 분석

캡처 절은 값 복사, 원본 참조, 객체 수명 분리를 기준으로 읽으면 각 표기법의 차이가 선명해집니다.

HTML 다이어그램: /docs/cpp/ch14/ch14-2/2.html

캡처 절은 람다 표현식이 외부 스코프의 변수를 어떻게 사용할 것인지 결정합니다.

[] (아무것도 캡처하지 않음)
  • 람다 내부에서 외부 스코프의 변수를 사용할 수 없습니다. 순수한 함수처럼 동작합니다.
아무것도 캡처하지 않는 람다
int main() {
    int x = 10;
    auto func = [](int a, int b) {
        // std::cout << x; // 컴파일 오류: x를 캡처하지 않음
        return a + b;
    };
    std::cout << func(1, 2) << std::endl; // 출력: 3
    return 0;
}
[var] (값으로 캡처 - by value)
  • 외부 변수 var의 값을 람다 객체 생성 시 복사합니다. 람다 본문 내에서 var의 값을 변경해도 외부의 원본 var에는 영향을 주지 않습니다. 기본적으로 const로 캡처됩니다.
값으로 캡처하는 람다
int main() {
    int x = 10;
    auto func = [x](int a) {
        // x++; // 컴파일 오류: 값으로 캡처된 변수는 기본적으로 const
        return a + x;
    };
    x = 20; // 람다 내부의 x는 영향을 받지 않음
    std::cout << func(5) << std::endl; // 출력: 15 (5 + 10)
    return 0;
}
  • mutable 키워드: 값으로 캡처된 변수를 람다 내부에서 수정하고 싶다면, 매개변수 목록 뒤에 mutable 키워드를 붙여야 합니다.
mutable 키워드를 사용한 값 캡처
int main() {
    int x = 10;
    auto func = [x](int a) mutable { // mutable 키워드 추가
        x++; // 이제 람다 내부의 x는 수정 가능 (원본 x와 별개)
        return a + x;
    };
    std::cout << func(5) << std::endl; // 출력: 16 (5 + 11)
    std::cout << x << std::endl;      // 출력: 10 (원본 x는 변함 없음)
    return 0;
}
[&var] (참조로 캡처 - by reference)
  • 외부 변수 var를 참조로 캡처합니다. 람다 본문 내에서 var를 변경하면 외부의 원본 var도 변경됩니다.
참조로 캡처하는 람다
int main() {
    int x = 10;
    auto func = [&x](int a) { // x를 참조로 캡처
        x++; // 원본 x를 변경
        return a + x;
    };
    std::cout << func(5) << std::endl; // 출력: 16 (5 + 11)
    std::cout << x << std::endl;      // 출력: 11 (원본 x가 변경됨)
    return 0;
}
[=] (모든 변수를 값으로 캡처)
  • 람다 본문 내에서 사용되는 모든 외부 변수를 값으로 캡처합니다. (예외: this 포인터는 항상 참조로 캡처됩니다.)
모든 변수를 값으로 캡처하는 람다
int main() {
    int x = 10;
    int y = 20;
    auto func = [=](int a) { // x, y 모두 값으로 캡처
        return a + x + y;
    };
    std::cout << func(5) << std::endl; // 출력: 35
    return 0;
}
[&] (모든 변수를 참조로 캡처)
  • 람다 본문 내에서 사용되는 모든 외부 변수를 참조로 캡처합니다.
모든 변수를 참조로 캡처하는 람다
int main() {
    int x = 10;
    int y = 20;
    auto func = [&](int a) { // x, y 모두 참조로 캡처
        x++;
        y--;
        return a + x + y;
    };
    std::cout << func(5) << std::endl; // 출력: 35 (a=5, x=11, y=19)
    std::cout << "x: " << x << ", y: " << y << std::endl; // 출력: x: 11, y: 19
    return 0;
}

혼합 캡처: 특정 변수는 값으로, 다른 변수는 참조로 캡처할 수 있습니다.

혼합 캡처 예시
int main() {
    int x = 10;
    int y = 20;
    auto func = [x, &y](int a) { // x는 값으로, y는 참조로
        // x++; // 오류: x는 const로 캡처됨
        y++; // y는 수정 가능
        return a + x + y;
    };
    std::cout << func(5) << std::endl; // 출력: 36 (a=5, x=10, y=21)
    std::cout << "x: " << x << ", y: " << y << std::endl; // 출력: x: 10, y: 21
    return 0;
}

[this] (C++11) / *this (C++17): 멤버 함수 내에서 람다를 정의할 때, 람다 내부에서 클래스의 멤버 변수나 멤버 함수에 접근하려면 this 포인터를 캡처해야 합니다.

  • [this]: this 포인터를 참조로 캡처합니다.
  • [*this] (C++17): 현재 객체(*this)를 값으로 복사하여 캡처합니다. 복사본이므로 원본 객체와 람다의 생명 주기를 분리할 때 유용합니다.

람다 표현식의 활용 사례

람다 표현식은 주로 STL 알고리즘과 함께 사용될 때 그 진가를 발휘합니다.

HTML 다이어그램: /docs/cpp/ch14/ch14-2/3.html
std::sort와 람다 사용 예시
#include <iostream>
#include <vector>
#include <algorithm> // sort
#include <string>

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

int main() {
    std::vector<Person> people = {
        {"Alice", 30},
        {"Charlie", 25},
        {"Bob", 35}
    };

    // 나이 기준 오름차순 정렬 (람다 사용)
    std::sort(people.begin(), people.end(), [](const Person& p1, const Person& p2) {
        return p1.age < p2.age;
    });

    std::cout << "Sorted by age:\n";
    for (const auto& p : people) {
        std::cout << p.name << " (" << p.age << ")\n";
    }
    // 출력:
    // Charlie (25)
    // Alice (30)
    // Bob (35)
    return 0;
}
std::find_if와 람다 사용 예시
#include <iostream>
#include <vector>
#include <algorithm> // find_if

int main() {
    std::vector<int> numbers = {10, 25, 30, 45, 50};

    // 30보다 큰 첫 번째 요소 찾기
    auto it = std::find_if(numbers.begin(), numbers.end(), [](int n) {
        return n > 30;
    });

    if (it != numbers.end()) {
        std::cout << "First number greater than 30: " << *it << std::endl; // 출력: 45
    } else {
        std::cout << "No number greater than 30 found.\n";
    }
    return 0;
}
외부 변수 캡처를 활용한 조건 설정
#include <iostream>
#include <vector>
#include <algorithm> // count_if

int main() {
    std::vector<int> scores = {85, 92, 78, 65, 95, 80};
    int min_score = 90; // 외부 변수

    // min_score보다 높은 점수의 개수 세기
    int count_above_min = std::count_if(scores.begin(), scores.end(), [min_score](int s) {
        return s >= min_score;
    });

    std::cout << "Number of scores >= " << min_score << ": " << count_above_min << std::endl; // 출력: 2 (92, 95)
    return 0;
}

람다 표현식의 장점

  • 간결성: 작은 함수 객체를 즉석에서 정의할 수 있어 코드의 양이 줄어듭니다.
  • 가독성: 함수 정의가 사용되는 곳 바로 옆에 위치하므로, 코드의 의도를 파악하기 쉽습니다.
  • 유연성: 외부 스코프의 변수를 캡처할 수 있어, 더 복잡하고 유연한 조건을 표현할 수 있습니다.
  • 성능: 컴파일러가 람다를 인라인 처리하거나 최적화하기 더 쉬워, 함수 포인터보다 성능 이점을 가질 수 있습니다.

auto와 람다의 조합

auto 키워드는 람다 표현식의 타입을 추론하는 데 필수적으로 사용됩니다.

람다의 실제 타입은 컴파일러가 생성하는 고유한 이름 없는 타입이므로, auto를 사용해야 람다 객체를 변수에 저장하거나 다른 함수로 전달할 수 있습니다.

auto와 람다의 조합 예시
#include <iostream>
#include <functional> // std::function (선택적)

int main() {
    auto my_lambda = [](int a, int b) { return a * b; }; // auto로 람다 타입 추론

    std::cout << my_lambda(6, 7) << std::endl; // 출력: 42

    // (참고) C++11에서는 람다를 std::function 객체로 감쌀 수 있습니다.
    // 이는 람다의 고유한 타입을 일반적인 함수 포인터 타입처럼 다룰 수 있게 해줍니다.
    std::function<int(int, int)> func_wrapper = [](int a, int b) { return a / b; };
    std::cout << func_wrapper(10, 2) << std::endl; // 출력: 5
    return 0;
}

람다의 한계 및 주의점

  • 복잡성: 람다 본문이 너무 길어지거나 복잡해지면, 차라리 별도의 명명된 함수나 함수 객체 클래스로 분리하는 것이 가독성 면에서 더 좋습니다. 람다는 짧은, 한 번 사용할 함수에 가장 적합합니다.
  • 생명 주기: 참조 캡처([&], [&var])를 사용할 때는 캡처된 변수의 생명 주기를 주의해야 합니다. 람다가 캡처된 변수보다 오래 살아남는 경우, 댕글링 참조(Dangling Reference) 문제가 발생하여 정의되지 않은 동작을 초래할 수 있습니다.
    // 위험한 예시:
    auto create_lambda() {
        int local_var = 10;
        return [&local_var]() { // local_var를 참조 캡처
            std::cout << local_var << std::endl;
        };
    }
    int main() {
        auto lambda = create_lambda(); // create_lambda() 종료 시 local_var 소멸
        // lambda(); // 여기서는 local_var가 이미 소멸된 상태이므로 위험!
        return 0;
    }

이러한 문제를 피하려면 값 캡처([=], [var])를 사용하거나, std::shared_ptr 같은 스마트 포인터를 사용하여 객체의 생명 주기를 관리해야 합니다.

HTML 다이어그램: /docs/cpp/ch14/ch14-2/4.html

람다를 저장하거나 비동기로 넘길 가능성이 있다면 [&] 같은 기본 참조 캡처를 먼저 의심해야 합니다.

캡처 목록을 좁히고, 값 캡처나 소유권이 드러나는 스마트 포인터 캡처로 수명을 분리하는 편이 안전합니다.