[Modern C++] 항목 31: 기본 캡처 모드를 피하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 6. 람다 표현식

🧠 람다 표현식 개요

  • 람다 표현식(lambda expression)은 C++ 프로그래밍의 면모를 크게 바꾼 기능이다. 람다 자체가 새로운 표현력을 주지는 않는다. 람다로 하는 일은 타자만 조금 더 하면 다른 방식으로도 할 수 있다. 하지만 함수 객체를 너무 쉽게 만들 수 있어서 일상적인 C++ 개발에 미치는 영향이 아주 크다.
  • 람다가 없을 때 STL의 _if 알고리즘(std::find_if, std::remove_if, std::count_if 등)은 자명한 술어(predicate)하고만 함께 쓰이는 경향이 있었다. 람다가 있으면 자명하지 않은 조건과도 쓰는 사례가 폭발적으로 늘어난다. 비교 함수를 커스텀할 수 있는 알고리즘(std::sort, std::nth_element, std::lower_bound 등)도 마찬가지다.
  • STL 이외에도 유용하다.
    • std::unique_ptr, std::shared_ptr의 커스텀 삭제자를 간단히 만들 수 있다(항목 18, 19).
    • 다중 스레드 API의 조건 변수 술어를 지정할 때도 쓴다(항목 39).
    • 콜백 함수, 인터페이스 적응(adaption) 함수, 단발성 호출을 위한 문맥 국한적 함수를 즉석에서 지정할 수 있다.

용어 정리

  • 람다 표현식: 이름 그대로 하나의 표현식으로, 소스 코드의 일부다.
std::find_if(container.begin(), container.end(),
             [](int val) { return 0 < val && val < 10; });   // 강조된 부분이 람다 표현식
  • 클로저(closure): 람다에 의해 만들어진 실행 시점 객체다. 캡처 모드(capture mode)에 따라 캡처된 자료의 복사본이나 참조를 가진다. 위 std::find_if 호출에서 클로저는 실행 시점에 세 번째 인수로 전달되는 객체다.
  • 클로저 클래스: 클로저를 만드는 데 쓰이는 클래스다. 컴파일러는 각 람다마다 고유한 클로저 클래스를 만든다. 람다 안의 문장들은 해당 클로저 클래스의 멤버 함수 안의 실행 가능한 명령이 된다.
  • 클로저는 복사할 수 있으므로, 하나의 람다에 대응되는 하나의 클로저 클래스로부터 여러 클로저를 만들 수 있다.
{
  int x;                               // x는 지역 변수
  …
  auto c1 =                            // c1은 람다에 의해
    [x](int y) { return x * y > 55; }; // 만들어진 클로저의 복사본

  auto c2 = c1;                        // c2는 c1의 복사본
  auto c3 = c2;                        // c3은 c2의 복사본
  …
}
  • 이 챕터에서는 컴파일 시점에 존재하는 것(람다, 클로저 클래스)과 실행 시점에 존재하는 것(클로저)을 구분하는 것이 중요하다.

👉🏻 항목 31: 기본 캡처 모드를 피하라

🔍 기본 캡처 모드의 두 가지 위험

  • C++11의 기본 캡처 모드(default capture mode)는 참조 캡처([&])와 값 캡처([=]) 두 가지다.
  • 기본 참조 캡처는 참조가 대상을 잃을(dangling) 위험이 있다.
  • 기본 값 캡처는 두 가지 오해를 유발한다.
    • 참조가 대상을 잃는 문제가 없을 것 같지만 사실은 그렇지 않다.
    • 람다가 자기 완결적(self-contained)일 것 같지만 그렇지 않은 경우도 있다.

🔍 기본 참조 캡처의 위험

  • 참조 캡처를 사용하는 클로저는 람다가 정의된 범위의 지역 변수나 매개변수에 대한 참조를 가진다. 클로저의 수명이 그 지역 변수나 매개변수의 수명보다 오래 지속되면 클로저 안의 참조는 대상을 잃는다.
  • int 하나를 받아 필터를 만족하는지 bool을 돌려주는 함수들의 컨테이너를 예로 들어 보자.
using FilterContainer =                    // using은 항목 9를, std::function은
  std::vector<std::function<bool(int)>>;   // 항목 5를 보라

FilterContainer filters;                   // 필터링 함수들

filters.emplace_back(                      // emplace_back은 항목 42를 보라
  [](int value) { return value % 5 == 0; } // 5의 배수를 선별하는 필터
);
  • 제수(divisor)를 실행 시점에 계산해야 하는 경우, 5를 람다 안에 하드코딩할 수 없다.
void addDivisorFilter()
{
  auto calc1 = computeSomeValue1();
  auto calc2 = computeSomeValue2();

  auto divisor = computeDivisor(calc1, calc2);

  filters.emplace_back(
    [&](int value) { return value % divisor == 0; } // 위험! divisor에 대한
  );                                                 // 참조가 대상을 잃을 수 있음!
}
  • 이 람다는 지역 변수 divisor를 참조하는데, 그 변수는 addDivisorFilter가 반환되면 더 이상 존재하지 않는다. filters에 추가된 필터 함수는 사실상 “사망 상태로 도착”하는 셈이며, 생성 직후부터 미정의 행동을 유발한다.
  • divisor의 참조 캡처를 명시적으로 지정해도 같은 문제가 발생한다.
filters.emplace_back(
  [&divisor](int value)                    // 위험! 이번에도
  { return value % divisor == 0; }         // divisor 참조는 대상을 잃는다!
);
  • 다만 명시적 캡처에는 이 람다의 유효성이 divisor의 수명에 의존한다는 점이 명확히 나타나는 장점이 있다. [&]가 암시하는 “어떤 참조도 대상을 잃지 않게 해야 한다”는 일반적인 경고보다 구체적인 힌트다.

클로저가 즉시 사용되어 복사되지 않을 때

  • 클로저를 즉시 사용하고(예: STL 알고리즘에 전달) 복사되지 않는다면, 참조가 람다가 생성된 환경의 지역 변수보다 오래 살아남을 위험은 없다. 그렇다면 기본 참조 캡처를 피할 필요가 없을까?
template<typename C>
void workWithContainer(const C& container)
{
  auto calc1 = computeSomeValue1();               // 이전과 동일
  auto calc2 = computeSomeValue2();               // 이전과 동일

  auto divisor = computeDivisor(calc1, calc2);    // 이전과 동일

  using ContElemT = typename C::value_type;       // 컨테이너에 담긴 요소들의 타입

  using std::begin;                               // 일반성을 위해; 항목 13을 보라
  using std::end;

  if (std::all_of(                                // 컨테이너의 모든
        begin(container), end(container),         // 값이 divisor의
        [&](const ContElemT& value)               // 배수인가?
        { return value % divisor == 0; })
     ) {
    …                                             // 그런 경우
  } else {
    …                                             // 적어도 하나는 아닌 경우
  }
}
  • 이 코드는 안전하지만 그 안전성은 깨지기 쉽다. 이 람다가 다른 문맥에서도 유용해 보여 복사해서 붙여넣었는데, 그 문맥에서 divisor가 클로저보다 먼저 소멸한다면 대상을 잃는 문제가 발생한다. 캡처 절에 divisor의 수명을 분석해야 한다는 힌트가 없으므로 프로그래머가 문제를 인식하기 어렵다.
  • 장기적으로는 람다가 의존하는 지역 변수와 매개변수를 명시적으로 나열하는 것이 더 나은 소프트웨어 공학이다.
  • C++14에서는 람다 매개변수에 auto를 쓸 수 있어 ContElemT 타입 별칭을 생략할 수 있다.
if (std::all_of(begin(container), end(container),
                [&](const auto& value)                    // C++14
                { return value % divisor == 0; }))

🔍 기본 값 캡처의 위험 ① — 포인터(특히 this)

  • divisor의 참조 문제는 기본 값 캡처([=])로 해결할 수 있다.
filters.emplace_back(
  [=](int value) { return value % divisor == 0; }   // 이제는 divisor가 대상을 잃지 않음
);
  • 이 예제에서는 충분하지만, 일반적으로 기본 값 캡처가 대상을 잃은 문제에 대한 특효약은 아니다. 포인터를 값으로 캡처하면 그 포인터는 클로저 안으로 복사되는데, 람다 바깥의 코드가 그 포인터를 delete하지 않는다는 보장이 없다. 그런 일이 발생하면 포인터 복사본은 가리키는 대상을 잃는다.
  • 현대적 C++에서는 raw 포인터의 존재가 소스 코드에 잘 드러나지 않는 경우가 많다. 예를 들어 Widget 클래스가 필터를 컨테이너에 추가하는 능력을 갖추고 있다고 하자.
class Widget {
public:
  …                                   // 생성자 등등
  void addFilter() const;             // 필터를 filters에 추가

private:
  int divisor;                        // Widget의 필터에 쓰인다
};

void Widget::addFilter() const
{
  filters.emplace_back(
    [=](int value) { return value % divisor == 0; }
  );
}
  • 잘 모르는 사람에게는 안전한 코드로 보인다. 값 캡처니 divisor의 값이 클로저 안으로 복사된다고 생각하기 때문이다. 그러나 이 생각은 완전히, 철저히, 치명적으로 틀렸다.
  • 캡처는 람다가 생성된 범위에서 보이는 static이 아닌 지역 변수(매개변수 포함)에만 적용된다. Widget::addFilter의 본문에서 divisor는 지역 변수가 아니라 클래스의 자료 멤버이므로 캡처될 수 없다. 기본 캡처 모드 =를 제거하면 코드는 컴파일되지 않는다.
void Widget::addFilter() const
{
  filters.emplace_back(
    [](int value) { return value % divisor == 0; }  // 오류! divisor를 사용할 수 없음
  );
}

void Widget::addFilter() const
{
  filters.emplace_back(
    [divisor](int value)                            // 오류! 캡처할 지역
    { return value % divisor == 0; }                // divisor가 없음
  );
}
  • 그렇다면 기본 값 캡처 [=]가 컴파일되는 이유는 무엇일까? 암묵적으로 this raw 포인터가 쓰이기 때문이다. 모든 비static 멤버 함수에는 this 포인터가 있으며, 클래스의 멤버를 언급할 때마다 컴파일러는 내부적으로 divisor를 this->divisor로 대체한다. 즉 람다가 클로저 안에 캡처하는 것은 divisor가 아니라 Widget의 this 포인터다.
void Widget::addFilter() const
{
  auto currentObjectPtr = this;

  filters.emplace_back(
    [currentObjectPtr](int value)
    { return value % currentObjectPtr->divisor == 0; }
  );
}
  • 따라서 이 람다에서 만들어진 클로저의 유효성은 해당 Widget 객체(클로저가 가진 this 복사본의 원본)의 수명에 의해 제한된다. 다음 코드를 보자.
using FilterContainer =                   // 이전과 동일
  std::vector<std::function<bool(int)>>;

FilterContainer filters;                  // 이전과 동일

void doSomeWork()
{
  auto pw =                               // Widget을 생성한다;
    std::make_unique<Widget>();           // std::make_unique에 관해서는 항목 21을 보라

  pw->addFilter();                        // Widget::divisor를 사용하는 필터를 추가한다
  …
}                                         // 여기서 Widget이 파괴된다;
                                          // 이제 filters에는 대상을 잃은 포인터가 존재한다
  • doSomeWork가 끝나면 std::unique_ptr의 수명 관리(항목 18)에 의해 Widget이 파괴되고, 그때부터 filters는 대상을 잃은 포인터를 가진 필터를 담게 된다.

해결책 ①: 자료 멤버의 지역 복사본을 캡처

void Widget::addFilter() const
{
  auto divisorCopy = divisor;                  // 자료 멤버를 복사한다

  filters.emplace_back(
    [divisorCopy](int value)                   // 복사본을 캡처한다
    { return value % divisorCopy == 0; }       // 복사본을 사용한다
  );
}
  • 이 접근방식을 쓴다면 기본 값 캡처 모드도 잘 작동한다. 그러나 위험을 자초할 필요는 없다. 애초에 divisor를 캡처하려 했는데 사실은 this가 캡처되게 한 장본인이 바로 기본 캡처 모드다.

해결책 ②: C++14의 일반화된 람다 캡처 (항목 32)

void Widget::addFilter() const
{
  filters.emplace_back(
    [divisor = divisor](int value)             // C++14: divisor를 클로저에 복사한다
    { return value % divisor == 0; }           // 복사본을 사용한다
  );
}
  • 일반화된 람다 캡처에는 기본 캡처 모드라는 것이 없으므로, C++14에서도 기본 캡처 모드를 피하라는 이 항목의 조언은 유효하다.

🔍 기본 값 캡처의 위험 ② — 자기 완결적이라는 오해

  • 값 기본 캡처는 클로저가 자기 완결적이고 클로저 바깥에서 일어나는 자료의 변화로부터 격리되어 있다는 오해를 부를 수 있다. 하지만 람다는 지역 변수와 매개변수뿐 아니라 정적 저장소 수명 기간(static storage duration)을 가진 객체(전역/이름공간 범위의 객체, 클래스/함수/파일 안에서 static으로 선언된 객체)에도 의존할 수 있다. 이런 객체는 람다 안에서 사용할 수는 있지만 캡처할 수는 없다. 그러나 기본 값 캡처 표기는 마치 이들도 캡처되는 듯한 느낌을 준다.
void addDivisorFilter()
{
  static auto calc1 = computeSomeValue1();       // 이제는 정적 변수
  static auto calc2 = computeSomeValue2();       // 이제는 정적 변수

  static auto divisor =                          // 이제는 정적 변수
    computeDivisor(calc1, calc2);

  filters.emplace_back(
    [=](int value)                               // 아무 것도 캡처하지 않음!
    { return value % divisor == 0; }             // 위의 정적 변수를 가리킨다
  );

  ++divisor;                                     // divisor를 수정한다
}
  • [=]를 보고 “이 람다는 자신이 사용하는 모든 객체의 복사본을 만든다. 따라서 자기 완결적이다”라고 오해하기 쉽다. 그러나 사실 이 람다는 자기 완결적이지 않다. 비정적 지역 변수를 사용하지 않으므로 아무것도 캡처하지 않고, static 변수 divisor를 가리킨다. addDivisorFilter를 호출할 때마다 divisor가 증가하므로, 이 함수를 통해 filters에 추가된 람다는 서로 다른 행동(divisor의 새 값에 상응하는)을 보이게 된다. 현실적으로는 참조 캡처와 같은 효과다. 이는 기본 값 캡처 모드가 뜻하는 바와 직접적으로 모순이 된다.
  • 애초에 기본 값 캡처 모드를 사용하지 않는다면 이처럼 오해의 여지가 큰 코드가 만들어질 위험도 사라진다.

🧐 정리

  • 기본 참조 캡처는 참조가 대상을 잃을 위험이 있다.
  • 기본 값 캡처는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.
  • 람다가 의존하는 지역 변수와 매개변수를 명시적으로 캡처하는 것이 안전하다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기