KoreanFoodie's Study
Effective Modern C++ | 항목 31 : 기본 갈무리 모드를 피하라 본문
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 를 참조로 갈무리한 것과 거의 같다고 봐도 무방하다.
'Tutorials > C++ : Advanced' 카테고리의 다른 글
Effective Modern C++ | 항목 33 : std::forward 를 통해 전달할 auto&& 매개변수에는 decltype 을 사용하라 (0) | 2022.10.26 |
---|---|
Effective Modern C++ | 항목 32 : 객체를 클로저 안으로 이동하려면 초기화 갈무리를 사용하라 (0) | 2022.10.26 |
Effective Modern C++ | 항목 30 : 완벽 전달이 실패하는 경우들을 잘 알아두라 (0) | 2022.10.26 |
Effective Modern C++ | 항목 29 : 이동 연산이 존재하지 않고, 저렴하지 않고, 적용되지 않는다고 가정하라 (0) | 2022.10.26 |
Effective Modern C++ | 항목 28 : 참조 축약을 숙지하라 (0) | 2022.10.26 |