KoreanFoodie's Study

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

Tutorials/C++ : Advanced

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

GoldGiver 2022. 10. 26. 10:04

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 31 : 기본 갈무리 모드를 피하라

핵심 :

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

 

항목을 들어가기 전, 자주 나오는 용어를 다시 한번 짚고 넘어가겠다.

  • 람다 표현식(lambda expression) : 이름 그대로 하나의 표현식으로, 소스 코드에서 "[ ]( ){ }" 로 표현되는 부분이다.
  • 클로저(closure) : 람다에 의해 만들어진 실행 시점 객체. 갈무리 모드(capture mode)에 따라, 클로저가 갈무리된 자료의 복사본을 가질 수도 있고 그 자료에 대한 참조를 가질 수도 있다.
  • 클로저 클래스 : 클로저를 만드는데 쓰인 클래스. 각각의 람다에 대해 컴파일러는 고유한 클로저 클래스를 만든다.

 

C++11 의 기본 갈무리 모드(default capture mode) 는 두 가지로, 하나는 참조에 의한(by-reference) 갈무리 모드(참조 갈무리 모드)이고, 또 하나는 값에 대한(by-value) 갈무리 모드(줄여서 값 갈무리 모드)이다.

캡쳐에 참조를 사용하는 클로저는 지역 변수 또는 람다가 정의된 범위에서 볼 수 있는 매개변수에 대한 참조를 가진다. 람다에 의해 생성된 클로저의 수명이 그 지역 변수나 매개변수의 수명보다 오래 지속되면, 클로저 안의 참조는 대상을 잃는다. 다음 예시를 보자.

void addDivisorFilter()
{
  auto calc1 = computeSomeValue1();
  auto calc2 = computeSomeValue2();

  auto divisor = computeDivisor(calc1, calc2);

  // 함수가 리턴하면 지역 변수 divisor 에 대한 참조가 대상을 잃을 수 있다!
  filters.emplace_back(
    [&](int value) { return value % divisor == 0;}
  );

  // 함수가 리턴하면 지역 변수 divisor 에 대한 참조가 대상을 잃을 수 있다!
  filters.emplace_back(
    [&divisor](int value) { return value % divisor == 0;}
  );
}

사실 위와 같은 경우는, 다음과 같이 참조가 아닌 값을 캡쳐하면 잠재적인 문제를 해결할 수 있다.

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

하지만 일반적으로는, 포인터를 값으로 갈무리할 경우, 포인터는 람다에 의해 생성된 클로저 안으로 복사되는데, 람다 바깥의 어떤 코드가 그 포인터를 delete 로 삭제하지 않는다는 보장은 없다. 만약 그런 일이 발생하면 포인터 복사본은 지칭 대상을 잃게 된다. 다음 예시를 보자.

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

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

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

갈무리는 오직 람다가 생성된 범위 안에서 보이는, static 이 아닌 지역 변수(매개변수 포함)에만 적용된다. Widget::addFilter 의 본문에서 divisor 은 지역 변수가 아니라 클래스의 한 자료 멤버이므로 갈무리될 수 없다. '[ ]' 안의 '=' 를 제거하면 컴파일조차 이루어지지 않는다. 또한, '[divisor]' 같은 식으로 써도 컴파일이 실패한다. divisor 을 찾을 수 없기 때문이다.

그렇다면 왜 이런 일이 발생할까? 사실 람다가 클로저 안에 갈무리하는 것은 divisor 가 아니라 Widget 의 this 포인터이다. 위의 addFilter 함수는 실제로는 다음과 같은 식으로 변환된다.

void Widget::addFilter() const
{
  auto currentObjectPtr = this;

  filters.emplace_back(
    [currentObjectPtr](int value) 
    { return value % currentObjectPtr->divisor == 0;}
  );
}

즉, 이 객체에서 만들어진 클로저의 유효성은 해당 Widget 객체(클로저가 가진 this 복사본의 원본에 해당하는) 수명에 의해 제한된다!

위의 코드는 다음과 같이 고치면 안전한 코드가 된다.

// 방법 1
void Widget::addFilter() const
{
  auto divisorCopy = divisor;

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

// 방법 2
void Widget::addFilter() const
{
  auto divisorCopy = divisor;

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

C++14 에서는 일반화된 람다 갈무리(항목 32 참고)를 사용할 수도 있다.

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

 

람다는 자기 완결적이지 않다. 즉, 클로저 바깥에서 일어나는 자료의 변화로부터 영향을 받을 수 있다. 실제로, 람다는 지역 변수와 매개변수(갈무리가 가능한) 뿐만 아니라 정적 저장소 수명 기간(static storage duration) 을 가진 객체에도 의존할 수 있다. 해당 객체들은 전역 범위나 이름 공간 범위에 저의된 객체 및 클래스, 함수, 파일 안에서 static 으로 선언된 객체들을 의미한다. 또한, 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;
}

위의 코드는, filters 에 추가되는 함수들의 divisor 값들이 계속 달라져 매우 이상한 동작을 보일 것이다! 위의 경우는 divisor 를 참조로 갈무리한 것과 거의 같다고 봐도 무방하다.

Comments