람다와 관련된 용어들 정리
- 람다 표현식(lambda expression)은 이름 그대로 하나의 표현식으로, 소스 코드의 일부입니다.
- 클로저(closure)는 람다에 의해 만들어진 실행시점 객체입니다. 갈무리 모드에 따라, 클로저가 갈무리된 자료의 복사본을 가질 수도 있고 그 자료에 대한 참조를 가질 수도 있습니다.
- 클로저 클래스는 클로저를 만드는 데 쓰인 클래스를 말합니다. 각각의 람다에 대해 컴파일러는 고유한 클로저 클래스를 만들어 냅니다. 람다 안의 문장들은 해당 클로저 클래스의 멤버 함수들 안의 실행 가능한 명령들이 됩니다.
31. Avoid default capture modes
C++의 기본 갈무리 모드는 두 가지로, 하나는 참조에 의한 갈무리 모드이며 또 하나는 값에 의한 갈무리 모드입니다. 기본 참조 갈무리에서는 참조가 대상을 잃을 위험이 존재합니다. 기본 값 갈무리도 참조가 대상을 잃는 경우가 생기며, 자기 완결적이지 못하여 문제가 발생하는 경우가 존재합니다.
참조 갈무리를 사용하는 클로저는 지역 변수 또는 람다가 정의된 범위에서 볼 수 있는 매개변수에 대한 참조를 가져옵니다. 만약 클로저의 수명이 그 지역 변수나 매개변수의 수명보다 오래 지속되면, 클로저 안의 참조는 대상을 잃게 됩니다.
using FilterContainer =
std::vector<std::function<bool(int)>>;
FilterContainer filters;
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에 남아 있기 때문에, 이 필터 함수는 사실상 생성 직후 부터 미정의 행동을 유발합니다. 이는 명시적으로 지정해도 똑같은 문제가 발생합니다. 클로저를 즉시 사용한다면 문제가 발생하지 않을 것으로 기대되지만 위 코드가 안전하지만 다른 코드에서는 안전성이 깨질 수 있습니다.
만약 앞선 코드를 참조 갈무리가 아닌 기본값 갈무리를 사용한다면 문제를 해결할 수 있습니다. 하지만 기본값으로 들고오는 것이 포인터인 경우에는 지칭하는 값이 사라지는 경우에 문제가 발생합니다. 포인터를 들고 오는 경우는 잘 드러나지 않는 경우가 존재합니다.
class Widget {
public:
...
void addFilter() const;
private:
int divisor;
};
void Widget::addFilter() const
{
filters.emplace_back(
[=](int value) { return value % divisor == 0; }
};
}
위 코드를 살펴봤을때는 마치 문제가 없는 것처럼 보인다. 아래의 코드를 살펴보자.
void Widget::addFilter() const
{
filters.emplace_back(
[](int value) { return value % divisor == 0; } // 컴파일 오류
);
}
void Widget::addFilter() const
{
filters.emplace_back(
[divisor](int value) // 컴파일 오류
{ return value % divisor == 0; }
);
}
이는 갈무리에 사용할 수 있는 범위는 static이 아닌 지역 변수에만 적용되기 때문입니다. divisor는 지역 변수가 아닌 클래스의 한 자료 멤버이므로 문제가 발생하는 것입니다. 기본 갈무리 모드에서는 divisor를 들고 오는 것이 아닌 this 포인터를 복사하여 내부적으로 this->divisor로 대체하여 사용하였기에 문제가 발생하지 않았습니다. 이를 해결하기 위해서는 divisor를 지역 변수로 복사해서 사용하면 됩니다.
void Widget::addFilter() const
{
auto divisorCopy = divisor;
filters.emplace_back(
[divisorCopy](int value)
{ return value % divisorCopy == 0; }
);
}
// C++14
void Widget::addFilter() const
{
filters.emplace_back(
[divisor = divisor](int value)
{ return value % divisor == 0; }
);
}
클로저가 자기 완결적이고 클로저 바깥에서 일어나는 자료의 변화로부터 격리되어 있다고 생각하지만, 람다는 지역 변수 및 매개변수 뿐만 아니라 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;
}
앞서 말했듯이 static 이 아닌 지역변수만 캡쳐가 가능하기 때문에 위에서 사용하는 divisor는 복사본이 아닌 static 으로 선언된 divisor에 해당합니다.
이것만은 잊지 말자!
- 기본 참조 갈무리는 참조가 대상을 잃을 위험이 있다.
- 기본 값 갈무리는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.
32. Use init capture to move objects into closures
이동 전용 객체 및 복사는 비싸고 이동은 저렴한 객체를 클로저 안으로 들여오기 위해 이동을 바라는 경우에 C++11에서는 해결 방법이 없었지만 C++14에서 도입된 새로운 갈무리인 바로 초기화 갈무리를 통해 이를 해결할 수 있습니다. 초기화 갈무리로는 C++11의 갈무리 모드들이 할 수 있는 모든 것을 할 수 있으며, 그 외의 여러 가지 것들도 할 수 있습니다. 초기화 갈무리로는 다음과 같은 것들을 지정할 수 있습니다.
- 람다로부터 생성되는 클로저 클래스에 속한 자료 멤버의 이름
- 그 자료 멤버를 초기화하는 표현식
class Widget {
public:
...
bool isValidated() const;
bool isProcessed() const;
bool isArchived() const;
};
auto pw = std::make_unique<Widget>();
...
auto func = [pw = std::move(pw)] { return pw->isValidated() && pw->isArchived(); };
"="의 좌변은 클로저 클래스 안의 자료 멤버(클로저에서 사용할)의 이름이고, 우변은 그것을 초기화하는 표현식입니다. 흥미로운 점은, "="의 좌변의 범위는 해당 클로저 클래스의 범위이과 우변의 범위는 람다가 정의되는 지점의 범위로 서로 다르다는 점입니다. 뿐만 아니라 직접 초기화도 가능합니다.
auto func = [pw = std::make_unique<Widget>()] { return pw->isValidated() && pw->isArchived(); };
C++11에서 위와 같은 코드가 불가능하기에 이를 수행하려면 클래스를 만들어서 수행하도록 구성해야 합니다. 또 다른 방법으로는 갈무리할 객체를 std::bind 함수가 산출하는 함수 객체로 이동시키고 그 갈무리된 객체에 대한 참조를 람다에 넘겨주는 식의 코드도 존재합니다.
std::vector<double> data;
// C++14
auto func = [data = std::move(data)] { ... };
// C++11
auto func = std::bind([](const std::vector<double>& data) { ... }, std::move(data));
std::bind 함수가 돌려주는 객체를 바인드 객체라고 부르며, 첫번째 인자에는 호출 가능한 객체를, 나머지는 그 객체에 전달할 값들을 전달 해줍니다. C++14 와의 차이점이라면 매개변수에는 오른값이 전달되었지만 람다 안에서 사용할 떄는 data 의 복사본을 사용한다는 점입니다. 이로 인해 상수성의 차이가 생기게 됩니다. 그 이유는 람다로부터 만들어진 클로저 클래스의 operator() 함수는 const 인데 반해 바인드로 이동 생성된 data 의 복사본은 const 가 아니기 때문에 지금 보는 예제와 같이 const 에 대한 참조로 선언해야 합니다.
하지만 C++11 람다 의 한계를 우회하기 위해 std::bind를 사용하는 방법은 모순적이며 C++14 람다를 선호하는 편이 낫다.
이것만은 잊지 말자!
- 객체를 클로저 안으로 이동할 때에는 C++14의 초기화 갈무리를 사용하라.
- C++11에서는 직접 작성한 클래스나 std::bind로 초기화 갈무리를 흉내 낼 수 있다.
33. Use decltype on auto&& parameters to std::forward them
C++14에서 가장 고무적인 기능은 즉 매개변수 명세에 auto를 사용하는 람다입니다. 구현은 간단해서, 람다의 클로저 클래스의 operator()를 템플릿 함수로 만들면 된다. 이 람다가 산출하는 클로저 클래스의 함수 호출 연산자는 다음과 같은 모습이 됩니다.
auto f = [](auto x){ return normalize(x); };
class 컴파일러가_만든_어떤_클래스_이름 {
public:
template<typename T>
auto operator()(T x) const
{ return normalize(x); }
...
};
위의 예제는 단순히 normalize로 전달만 하지만, normalize가 왼값과 오른값에 대해 따로 처리하여 완벽 전달을 필요로 하는 경우에는 인자에는 auto&&를 사용하여 보편참조를 받으면서 함수로 보내는 인자는 std::forward를 통해서 처리를 해야합니다. 이때 std::forward에 들어갈 형식 매개변수 T에다가 decltype을 이용해야 합니다. decltype이 매개변수 형식을 그대로 사용하도록 하기에 decltype(x)를 사용한다면 참조 형식에 맞추어서 보내줄 수가 있으며, 가변 인수를 사용해줄 수도 있습니다.
auto f = [](auto&&... xs){ return normalize(std::forward<decltype(xs)>(xs)...); };
이것만은 잊지 말자!
- std::forward를 통해서 전달할 auto&& 매개변수에는 decltype을 사용하라.
34. Prefer lambdas to std::bind
C++11에서는 람다가 거의 항상 std::bind보다 나은 선택이고, C++14에서는 람다가 거의 항상 나은 선택이 아니라 확고하게 우월한 선택입니다. C++11이 람다를 지원하기 시작하면서 std::bind는 비권장 기능이 되었으며, C++14에서는 std::bind를 사용하는 것이 합당한 경우가 전혀 없습니다. 이 이유를 살펴보도록 하겠습니다.
using Time = std::chrono::steady_clock::time_point;
enum class Sound { Beep, Siren, Whistle };
using Duration = std::chrono::steady_clock::duration;
void setAlarm(Time t, Sound s, Duration d);
auto setSoundL = [](Sound s) {
using namesapce std::chrono;
using namespace std::literals;
setAlarm(steady_clock::now() + 1h, s, 30s);
};
한 시간 후부터 30초간 소리(setAlarm 실행)를 내는 알람 함수를 람다를 이용해서 만들면 위와 같습니다. 이를 std::bind를 이용해서 재구성하면 다음과 같이 만들어집니다.
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;
auto setSoundB = std::bind(setAlaram, steady_clock::now() + 1h, _1, 30s);
하지만 std::bind를 이용한 경우에는 문제가 여러가지 존재합니다.
[호출 시점의 문제]
setAlarm이 호출되는 시점이 steady_clock::now() + 1h로 함수 호출하고 나서 1시간을 기대하고 있습니다. 람다의 경우에는 이와 똑같이 실행되지만, std::bind의 경우에는 바인드 객체가 생성되는 시점의 시간이 되어 버리기 때문에, 이를 수정하기 위해서는 std::bind를 한번더 호출하여 setAlarm 때까지 기다리도록 해야합니다.
auto setsoundB = std::bind(setAlarm, std::bind(std::plus<>(),
std::bind(steady_clock::now), 1h), _1, 30s);
[Overloading 문제]
만약 setAlarm 함수를 overloading하게 되면 문제가 없는 람다와 달리 std::bind에서는 다음과 같이 타입을 지정해주지 않는 한 어떤 함수를 요청하는 지 모르기에 컴파일 오류가 발생합니다.
using SetAlram3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB =
std::bind(static_cast<SetAlram3ParamType>(setAlarm),
std::bind(std::plus<>(),
std::bind(steady_clock::now),
1h),
_1,
30s);
이는 다른 문제로 성능을 저하시키는데, 람다의 경우에는 setAlarm 호출을 인라인화 시키는데 반해 std::bind의 경우에는 함수 포인터를 사용하기 때문에 인라인화할 가능성이 매우 낮습니다.
[구현과 유지보수의 문제]
코드가 복잡해질수록 std::bind로의 구현이 어렵고 복잡해지기에 이를 보수하거나 구현하는 것이 더욱더 힘들어집니다.
[참조와 복사의 문제]
enum class CompLevel { Low, Normal, High };
Widget compress(const Widget& w,
CompLevel lev);
Widget w;
auto compressRateB = std::bind(compress, w, _1);
위 함수에서 바인드 객체에 전달되는 w 는 값으로 전달되며 이는 저장된 w에 변화를 줄 수 없습니다. 이는 std::bind에서는 알아보기 어렵지만 람다의 경우에는 의도가 명백하게 드러납니다.
auto compressRateL = [w](CompLevel lev) { return compress(w, lev); };
이러한 문제점들이 존재하지만 C++11에서는 std::bind가 필요한 경우가 존재합니다.
- 이동 갈무리 - 항목 32에서 얘기를 하였습니다
- 다형적 함수 객체 - 바인대 객체에 대한 함수 호출 연산자는 완벽 전달을 사용하기 때문에 어떤 형식의 인수도 받을 수 있습니다. 이는 C++14에서 auto가 도입되며 상관이 없지만, C++11에서는 불가능합니다.
이것만은 잊지 말자!
- std::bind를 사용하는 것보다 람다가 더 읽기 쉽고 표현력이 좋다. 그리고 더 효율적일 수 있다.
- C++14가 아닌 C++11에서는 이동 갈무리를 구현하거나 객체를 템플릿화된 함수 호출 연산자에 묶으려 할 때 std::bind가 유용할 수 있다.
'C++공부 > Effective Modern C++' 카테고리의 다른 글
Chapter 8. Tweaks (0) | 2022.05.28 |
---|---|
Chapter 7. The Concurrency API (0) | 2022.05.28 |
Chapter 5. Rvalue References, Move Semantics, and Perfect Forwarding (0) | 2022.05.27 |
Chapter 4. Smart Pointers (0) | 2022.05.26 |
Chapter 3. Moving to Modern C++ (0) | 2022.05.26 |