본문 바로가기
C++/Effective Modern C++

람다표현식에서 기본 갈무리 모드는 피하자

by COCO1337 2020. 8. 3.

C++11의 기본 갈무리 모드는 두가지로 하나는 참조에 의한 갈무리 모드, 또 하나는 값에 의한 갈무리 모드이다. 기본 참조에 의한 갈무리 모드는 참조 대상을 잃을 위험이 있으며, 값에 의한 갈무리 모드 또한 참조가 대상을 잃는 문제를 갖고있으며, 자기 완결적이지 않은 경우가 있다.

참조 갈무리를 사용하는 클로저는 지역 변수 또는 람다가 정의된 범위에서 볼 수 있는 매개변수에 대한 참조를 가지게 된다. 람다에 의해 생성된 클로저의 수명이 그 지역변수나 매개변수의 수명보다 오래 지속되면, 클로저 안의 참조는 대상을 잃는다. 예를들어 int 하나를 받아 그 값이 필터를 만족하는지 뜻하는 bool 하나를 돌려주는 필터링 함수들을 담는 컨테이너가 있다고 하자

using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;

filters.emplace_back(
  [](int value) { return value % 5 == 0; }
);

하지만 나누는 수를 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를 반환하면 더이상 존재하지 않게 된다.

addDivisorFilter는 filters.emplace_back이 반환된 직후에 반환되므로, filters에 추가되는 필터 함수는 미정의 행동을 유발할 수 있다.

filters.emplace_back(
  [&divisor] (int value) { return value % divisor == 0; }	// 직접 명시해도 같은 문제가 나타남
);

 

클로저가 즉시 사용되며 복사되지 않는다는 것을 알고 있다면, 클로저를 가진 참조가 해당 람다에 생성된 환경 안의 지역변수나 매개변수보다 오래 살아남을 위험은 없다. 하지만 그렇다고해서 기본 갈무리 모드가 안전하다는 것은 아니다.

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;
  using std::end;
  
  if (std::all_of(begin(container), end(container),
      [&](const ContElemT& value)
      {return value % divisor == 0; }))
  {
  ...
  }
  else
  ...
  
 // C++14
  if (std::all_of(begin(container), end(container),
      [&](const auto& value)		// 매개변수 지정시 auto 가능
      {return value % divisor == 0; }))

이 코드 내에서는 안전하다. 하지만 다른문맥에서도 사용하기 위해 람다를 복사해서 사용하는데 divisor가 클로저보다 먼저 소멸하게 되면 아까와 동일한 문제가 발생하게 된다.

 

divisor와 관련된 문제를 해결하는 한 가지 방법은 기본 값 갈무리 모드를 사용하는 것이다.

filters.emplace_back(
  [=](int value) { return value % divisor == 0; }	// divisor가 대상을 잃지 않음
);

하지만 위 식에서 포인터 값을 갈무리 한다고 생각해보자. 포인터 값으로 갈무리 하면 그 포인터는 람다에 의해 생성된 클로저 안으로 복사된다. 하지만 람다 바깥의 코드가 포인터를 delete로 삭제하지 않는다는 보장이 없다. 그러면 결국 포인터 복사본은 지칭 대상을 잃게 된다.

 

다음은 값 갈무리 모드의 예시이다. 값 갈무리모드에서 divisor의 값이 람다가 생성하는 클로저 안으로 복사되므로 안전하다고 생각할 수 있지만 아니다.

class Widget {
public:
  ...		// 생성자 등
  void addFilter() const;	// 필터를 filters에 추가

private:
  int divisor;
};

// Widget::addFilter의 구현
void widget::addFilter() const
{
  filters.emplace_back(
    [=](int value) { return value % divisor == 0; }
    // 1. [](int value) { return value % divisor == 0; }	컴파일 에러
    // 2. [divisor](int value) { return value % divisor == 0; }	컴파일 에러
  );
}

위 식에서 주석되지않은 부분(=를 사용한 부분)은 안전해 보이지만 전혀 아니다. 갈무리는 오직 람다가 생성된 범위 안에서 보이는 static이 아닌 지역 변수(매개변수)에만 적용 된다. 본문에서 divisor는 클래스의 자료멤버이므로 갈무리 될 수 없기 때문에 =를 제거한 1.은 컴파일 되지 않는다. 2.에서는 divisor를 명시적으로 갈무리하려고 하지만 역시 지역변수도 매개변수도 아니기 때문에 컴파일 되지 않는다.

값 갈무리 절은 암묵적으로 this라는 raw 포인터를 쓰기 때문에 위의 이유에서 컴파일이 되지 않는다. 모든 static이 아닌 멤버 함수에는 this 포인터가 있으며, 클래스의 멤버 함수를 언급할때마다 그 포인터가 쓰인다. 즉 위의 식을 풀어서 다시 적어보자.

void Widget::addFilter() const
{
  auto currentObjectPtr = this;
  filters.emplace_back(
    [currentObjectPtr](int value)
    { return value % currentObjectPtr->divisor == 0; }
  );
}

기본 값 갈무리 모드가 this를 갈무리 하기 때문에 이에 착안하여 자료 멤버의 지역 복사본을 만들어 그 복사본을 갈무리하게 만들면 위의 문제는 해결 된다.

void Widget:: addFilter() const
{
  auto divisorCopy = divisor;		// 자료 멤버 복사
  filters.emplace_back(
    [divisorCopy](int value)		// 복사본 갈무리, [=](int value)로 바꿔도 잘 작동한다
    { return value % divisorCopy == 0; }	// 복사본 사용
  );
}

// C++14
void Widget:: addFilter() const
{
  filters.emplace_back(
    [divisor = divisor](int value)	// divisor를 클로저에 복사
    { return value % divisor == 0; }	// 복사본 사용
  );
}

값에 의한 갈무리 모드의 또 다른 단점은 해당 클로저가 자기 완결적이고 클로저 바깥에서 일어나는 자료의 변화로 부터 격리 되어 있다는 오해가 있을 수 있다는 점이다. 왜냐하면 람다는 지역변수와 매개변수 뿐만 아니라 정적 저장소 수명기간을 가진 객체에도 의존 할 수 있다. 그런 객체들은 람다 안에서 사용할 수 있지만 갈무리할 수는 없다.

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를 참조로 갈무리 한 것과 같은 결과를 나타낸다. 하지만 아무것도 갈무리 하지않고, 어떤 비 정적 지역 변수도 사용하지 않았기 때문에 기본 값 갈무리 모드가 뜻하는 바와는 모순이 된다.


Conclusion

- 기본 참조 갈무리는 참조가 대상을 잃을 위험이 있다.

- 기본 값 갈무리는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.


Reference

Effective Modern C++ 항목 31: 기본 갈무리 모드를 피하라

반응형

댓글