콘텐츠로 이동

람다 표현식 (Lambda Expressions)

적절한 곳에 람다 표현식을 사용하세요. 람다가 현재 범위를 벗어날 수 있다면 명시적 캡처를 선호하세요.

정의:

람다 표현식은 익명 함수 객체를 만드는 간결한 방법입니다. 함수를 인수로 전달할 때 특히 유용합니다. 예를 들면 다음과 같습니다.

std::sort(v.begin(), v.end(), [](int x, int y) {
  return Weight(x) < Weight(y);
});

또한 람다는 바깥 범위의 변수를 이름으로 명시해서, 또는 기본 캡처를 써서 암시적으로 캡처할 수 있습니다. 명시적 캡처는 각 변수를 값 캡처인지 참조 캡처인지와 함께 하나씩 나열해야 합니다.

int weight = 3;
int sum = 0;
// `weight`는 값으로, `sum`은 참조로 캡처합니다.
std::for_each(v.begin(), v.end(), [weight, &sum](int x) {
  sum += weight * x;
});

기본 캡처는 람다 본문에서 참조하는 모든 변수를 암시적으로 캡처하며, 멤버를 사용한다면 this도 캡처합니다.

const std::vector<int> lookup_table = ...;
std::vector<int> indices = ...;
// `lookup_table`을 참조로 캡처하고, `lookup_table`의 대응하는 원소 값을
// 기준으로 `indices`를 정렬합니다.
std::sort(indices.begin(), indices.end(), [&](int a, int b) {
  return lookup_table[a] < lookup_table[b];
});

변수 캡처에는 명시적인 초기화식을 붙일 수도 있습니다. 이동 전용 변수를 값으로 캡처하거나, 일반적인 참조·값 캡처로는 처리되지 않는 상황에 쓸 수 있습니다.

std::unique_ptr<Foo> foo = ...;
[foo = std::move(foo)] () {
  ...
}

이런 캡처(흔히 "init 캡처" 또는 "일반화된 람다 캡처"라고 부릅니다)는 바깥 범위에서 실제로 무언가를 "캡처"할 필요도, 바깥 범위에 이름이 있을 필요도 없습니다. 이 문법은 람다 객체의 멤버를 정의하는 완전히 일반적인 방법입니다.

[foo = std::vector<int>({1, 2, 3})] () {
  ...
}

초기화식이 있는 캡처의 타입은 auto와 같은 규칙으로 추론됩니다.

장점:

  • 람다는 STL 알고리즘에 넘길 함수 객체를 정의하는 다른 방법들보다 훨씬 간결하며, 그만큼 가독성이 좋아질 수 있습니다.
  • 기본 캡처를 적절히 쓰면 중복을 없애고 기본값에서 벗어나는 중요한 예외를 드러낼 수 있습니다.
  • 람다, std::function, std::bind를 조합하면 범용 콜백 메커니즘으로 쓸 수 있습니다. 바인딩된 함수를 인수로 받는 함수를 쉽게 작성할 수 있습니다.

단점:

  • 람다의 변수 캡처는 댕글링 포인터 버그의 원인이 될 수 있으며, 람다가 현재 범위를 벗어날 때 특히 그렇습니다.
  • 값에 의한 기본 캡처는 댕글링 포인터 버그를 막아 주지 않기 때문에 오해를 부를 수 있습니다. 포인터를 값으로 캡처해도 깊은 복사가 일어나지 않으므로, 참조 캡처와 같은 수명 문제가 그대로 생기는 경우가 많습니다. this를 값으로 캡처할 때 특히 혼란스러운데, this의 사용이 암시적인 경우가 많기 때문입니다.
  • 캡처는 (초기화식이 있든 없든) 실제로는 새 변수를 선언하는 것이지만, 그 모습이 C++의 다른 어떤 변수 선언 문법과도 닮지 않았습니다. 특히 변수의 타입을 적을 자리도, 심지어 auto 자리표시자를 적을 자리도 없습니다(init 캡처는 캐스트 같은 방법으로 간접적으로 나타낼 수 있기는 합니다). 그래서 그것이 선언이라는 사실조차 알아보기 어려울 수 있습니다.
  • init 캡처는 본질적으로 타입 추론에 의존하므로 auto가 가진 단점을 상당 부분 그대로 안고 있으며, 문법이 독자에게 추론이 일어나고 있다는 신호조차 주지 않는다는 문제가 더해집니다.
  • 람다 사용이 걷잡을 수 없어질 수 있습니다. 아주 길고 중첩된 익명 함수는 코드를 이해하기 어렵게 만듭니다.

결정:

  • 적절한 곳에 람다 표현식을 사용하되, 형식은 아래에 설명된 대로 맞추세요.
  • 람다가 현재 범위를 벗어날 수 있다면 명시적 캡처를 선호하세요. 예를 들어 다음과 같이 쓰는 대신,

    {
      Foo foo;
      ...
      executor->Schedule([&] { Frobnicate(foo); })
      ...
    }
    // 나쁨! 이 람다가 `foo`에 대한 참조를 사용한다는 사실과, (`Frobnicate`가
    // 멤버 함수라면) `this`까지 사용할 수 있다는 사실이 훑어봐서는 드러나지
    // 않습니다. 함수가 반환된 뒤에 람다가 호출된다면 `foo`도 바깥 객체도
    // 이미 파괴되었을 수 있으므로 좋지 않습니다.
    

    다음과 같이 쓰는 편이 낫습니다.

    {
      Foo foo;
      ...
      executor->Schedule([&foo] { Frobnicate(foo); })
      ...
    }
    // 더 나음 - `Frobnicate`가 멤버 함수라면 컴파일이 실패하며, `foo`가
    // 위험하게 참조로 캡처된다는 점이 더 분명해집니다.
    
  • 참조에 의한 기본 캡처([&])는 람다의 수명이 캡처 대상 어느 것보다도 명백히 짧을 때만 사용하세요.

  • 값에 의한 기본 캡처([=])는 짧은 람다에서 변수 몇 개를 묶는 수단으로만 사용하세요. 캡처되는 변수 집합이 한눈에 분명하고, this가 암시적으로 캡처되지 않는 경우여야 합니다. (즉, 비정적 클래스 멤버 함수 안에 있으면서 본문에서 비정적 클래스 멤버를 참조하는 람다는 this를 명시적으로 캡처하거나 [&]를 통해 캡처해야 합니다.) 값에 의한 기본 캡처로 길거나 복잡한 람다를 작성하지 않는 편이 좋습니다.
  • 캡처는 바깥 범위에서 실제로 변수를 캡처할 때만 사용하세요. 새 이름을 도입하거나 기존 이름의 의미를 크게 바꾸려고 초기화식이 있는 캡처를 쓰지 마세요. 그런 경우에는 관례적인 방식으로 새 변수를 선언한 다음 그것을 캡처하거나, 람다라는 축약 표기를 포기하고 함수 객체를 명시적으로 정의하세요.
  • 매개변수와 반환 타입을 지정하는 지침은 타입 추론 절을 참조하세요.

옮긴이 풀이

핵심: 적절할 때 람다, 범위를 벗어나면 명시적 캡처

람다는 익명 함수 객체를 간결하게 만드는 방법으로, STL 알고리즘에 함수를 넘길 때 특히 유용합니다.

std::sort(v.begin(), v.end(), [](int x, int y) {
  return Weight(x) < Weight(y);
});

캡처의 위험: 댕글링 포인터

람다가 현재 범위를 벗어나 나중에 실행될 수 있다면, 참조 캡처는 이미 파괴된 객체를 가리키는 댕글링 포인터 버그가 됩니다.

값 캡처라고 안전한 것도 아닙니다. 포인터를 값으로 캡처해도 가리키는 대상이 복사되지는 않으므로 수명 문제는 그대로입니다. this를 값으로 캡처하는 경우가 특히 헷갈리는데, 멤버를 쓰기만 해도 this가 암시적으로 캡처되기 때문입니다.

지침

  • [&](참조 기본 캡처): 람다의 수명이 캡처 대상보다 확실히 짧을 때만.
  • [=](값 기본 캡처): 짧은 람다에서 명확한 소수 변수만 묶을 때.
  • 길거나 복잡한 람다에는 기본 캡처를 쓰지 말고, 변수를 명시적으로 나열하세요.
  • init 캡처([foo = std::move(foo)])는 실제로 바깥에서 무언가를 캡처할 때만. 새 이름을 만들거나 기존 이름의 의미를 바꾸는 용도로 쓰지 마세요.
  • 매개변수·반환 타입 지정은 타입 추론 절을 참조하세요.