Effective C++/Effective Modern C++
[Effective Modern C++] 31. 람다 기본 캡쳐 모드 지양하기
김디트
2025. 4. 1. 11:30
728x90
항목 31. 기본 캡처 모드를 피하라
람다와 관련된 용어
- 람다 표현식( lambda expression)
- 이름 그대로 하나의 표현식.
-
std::find_if(container.begin(), container.end(), [](int val) { return 0 < val && val < 10; } // 다음 부분이 람다 표현식 );
- 클로저(closure)
- 람다에 의해 만들어진 실행시점 객체
- 캡처 모드(capture mode)에 따라, 자료의 복사본을 가질수도 있고 참조를 가질 수도 있다.
- 클로저 클래스(closure class)
- 클로저를 만드는 데 쓰인 클래스
- 각 람다에 대해 컴파일러는 고유한 클로저 클래스를 만든다.
- 람다 안의 문장들은 해당 클로저 클래스의 멤버 함수들 안의 실행 가능한 명령들이 된다.
C++11의 기본 캡처 모드(default capture mode)
- 두 가지가 있다.
- 참조 캡처 모드(capture by reference)
- 참조 대상이 댕글링(dangling)될 위험이 있다.
- 값 캡쳐 모드(capture by value)
- 댕글링 문제가 없을 것 같지만, 사실은 그럴 수 있다.
- 기본 값 캡처 모드는 자기 완결적(self-contained)일 것 같지만, 그렇지 않은 경우도 있다.
참조 캡처
- 지역 변수나 람다가 접근 가능한 매개변수에 대한 참조를 가지게 된다.
- 람다에 의해 생성된 클로저의 수명이 그 지역 변수나 매개변수의 수명보다 길다면?
- 클로저 안의 참조는 대상을 잃는다.
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;
// 이런 식으로 filters에 값을 추가한다.
filters.emplace_back(
[](int value) { return value % 5 == 0; } // 5의 배수 추가
);
// 하지만, 런타임 시점의 값을 제수(divisor; 나누는 수)로 쓰고 싶은 경우도 있다.
void addDivisorFilter()
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
filters.emplace_back(
[&](int value) { return value % divisor == 0; } // 위험! 참조가 대상을 잃을 수 있다.
);
}
- 런타임에 제수를 구하는 버전은 divisor를 참조하는데
- 그 지역변수는 addDivisorFilter가 리턴하면 더 이상 존재하지 않게 된다.
- 허나 filters가 쓰이는 시점은 해당 지역변수가 사라진 후이다!
- 즉 댕글링 문제가 발생한다.
- 이렇게 해도 여전히 같은 문제이다.
filters.emplace_back(
[&divisor](int value) { return value % divisor == 0; } // 여전히 댕글링 문제
);
- 하지만 이 경우 divisor를 명시적으로 타이핑하면서 문제를 확인할 여지라도 있다.
- 문제시, &보다는 좀 더 구체적으로 문제를 찾을 수 있다는 의미이다.
- 클로저가 즉시 사용되고, 해당 클로저가 복사도 되지 않는다는 것이 명확하면 그럼 문제는 없을까?
templace<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
{ ... }
}
- 물론 안전하다.
- 하지만 그 안전성이 언제든 깨질 수 있다는 것이 문제이다.
- 만일 이 코드를 다른 곳에서 사용하려고 복붙해 간다면? 거기서도 안전한지는 확신할 수 있을까?
- 장기적으로 람다가 의존하는 지역 변수들과 매개변수를 명시적으로 나열하는 것이 낫다는 것은 자명하다.
기본 값 캡쳐(default value capture)
- filters 예시의 경우 기본 값 캡처 모드로 해결할 수도 있다.
// 기본 값 캡처 모드를 사용한 버전
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
);
- 하지만 값 캡처 모드가 만능은 아니다.
- 포인터를 값 캡처 하는 경우가 문제이다.
- 해당 포인터가 가리키는 것이 삭제되지 않음을 보장해주지 않는다.
- 스마트 포인터를 사용하면 된다?
- 그렇긴 하지만 raw 포인터를 사용하지 않는다는 보장도 없다.
- 기본 값 캡쳐는 그런 걸 따지지 않고 모두 캡쳐해간다.
- 모던 C++ 프로그래밍 스타일에서는 raw 포인터의 존재가 소스 코드에 잘 드러나지 않는 경우가 많다.
class Widget
{
public:
...
void addFilter() const;
private:
int divisor;
};
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; } // 문제가 없어 보이지만..
);
}
- 캡처는 오직 람다가 생성된 범위 안에서 보이는, static이 아닌 지역 변수에만 적용된다.
- 그러므로 람다는 divisor를 볼 수 없다.
- 근데 컴파일이 된 이유는?
- 이 경우 암묵적으로 this 포인터가 값 캡쳐되어 사용된 것이다.
- 결국 사실은 아래와 같은 코드라는 말이다.
void Widget::addFilter() const
{
auto currentObjectPtr = this;
// 람다에서 currentObjectPtr 포인터가 캡처되어 사용됨!
filters.emplace_back(
[currentObjectPtr](int value) { return value % currentObjectPtr->divisor == 0; }
);
}
- 기본 값 캡처의 단점 1.
- 여기서 기본 값 캡처 모드의 문제를 알 수 있다.
- divisor를 캡처하려 했으나 사실은 this를 캡처하게 한 장본인이 바로 기본 캡처 모드니까.
- 아래처럼 하면 생각한 대로 잘 캡처한다.
// C++11용
void Widget::addFilter() const
{
auto divisorCopy = divisor;
filters.emplace_back(
[=](int value) { return value % divisorCopy == 0; }
);
}
// C++14용 (일반화된 람다 캡처)(항목 32 참조)
void Widget::addFilter() const
{
filters.emplace_back(
[divisor = divisor](int value) { return value % divisor == 0; }
);
}
- 기본 값 캡처의 단점 2.
- 또 다른 단점으로는, 해당 클로저가 자기 완결적이라는 오해를 부를 수 있다는 점이다.
- 값 챕처는 아래 값들을 캡처할 수 없다.
- 전역 범위, 네임 스페이스 범위에 정의된 객체
- 클래스, 함수, 파일 안에서 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; // 람다에 영향을 끼친다!
}
728x90