람다 표현식 Lambda
- 보통 함수와 같은 의미를 지니는 표현식이다.
- 클로저 Closure는 람다에 의해 만들어진 실행 시점의 객체이다. 갈무리 모드 Capture mode에 따라 갈무리된 자료의 복사본 혹은 참조를 가질 수 있다.
- 클로저 클래스는 람다에 대해 컴파일러가 만든 고유한 클래스이다.
보통 STL 함수의 인수로 많이 사용된다.
std::vector<int> container;
std::find_if(container.begin(), container.end(), [](int val) {return 0 < val && val < 10; });
1. 기본 갈무리 모드를 피하라
기본 갈무리 모드 중 하나는 참조에 의한 갈무리 모드와 값에 의한 갈무리 모드가 있다. 두 모드에 내재된 위험성은 참조대상이 잃을(dangling) 가능성이 있다는 것이고 값에 의한 갈무리는 람다 절 안에서 완전 무결(self-contained)하지 않다는 것이다.
- 갈무리(캡쳐)되는 객체의 수명과 람다의 수명
// 기본 갈무리 모드
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;
void addDivisorFilter()
{
auto calc1 = computeSomeValue1();
auto calc2 = computeSomeValue2();
auto divisor = computeDivisor(calc1, calc2);
// divisor는 참조 대상을 잃을 수 있음.
filters.emplace_back([&](int value) {return value % divisor == 0; });
}
어떤 addDivisorFilter라는 함수내에서 filter라는 함수 객체를 모아두는 컨테이너에 람다 함수를 추가한다 하자. 이 람다가 갈무리하는 대상은 divisor가 있는데 해당 값을 참조형으로 갈무리하고 있다. 문제는 함수 종료 후 지역변수 divisor는 파괴되고 해당 참조는 미정의 값이 된다는 것이다.
기본 참조 모드가 아니라 특정 변수를 참조 갈무리하여도 참조 대상을 잃게 된다.
// divisor는 참조 대상을 잃을 수 있음.
filters.emplace_back([&divisor](int value) {return value % divisor == 0; });
참조가 아니라 값으로 전달하면 대상은 클로저 안으로 복사되기 때문에 문제가 되지 않는다.
// 이제 기본 값 갈무리모드로 전달함
filters.emplace_back([=](int value) {return value % divisor == 0; });
- 갈무리되는 객체가 포인터 일 경우
포인터가 갈무리된다면 포인터가 delete 되면서 대상을 잃을 가능성이 생긴다. 다음 Widget 클래스가 있다하자.
class Widget {
public:
void addFilter() const;
private:
int divisor;
};
Widget의 addFilter는 멤버 변수 divisor를 갈무리 하려고한다.
void Widget::addFilter() const
{
filters.emplace_back([=](int value) {return value % divisor == 0; });
}
갈무리는 람다가 존재하는 범위에서 보이는 정적 변수가 아닌 지역 변수만 가능하다. 따라서 divisor는 멤버 변수이기 때문에 겉으로는 갈무리 될 수 없다.
// divisor는 멤버 변수 -> 컴파일 되지 않음
filters.emplace_back([divisor](int value) {return value % divisor == 0; });
첫번째의 경우 컴파일이 되는데, 기본 값 갈무리모드에는 사실 this 포인터가 숨겨져 있다. 따라서 divisor는 this포인터를 이용하여 멤버 변수를 클로저 안에서 사용할 수 있게 되는 것이다.
// 기본 값 갈무리 모드 람다
filters.emplace_back([=](int value) {return value % divisor == 0; });
// 아래 내용과 동일
auto objPtr = this;
filters.emplace_back([objPtr](int value) {return value % objPtr->divisor == 0; });
문제는 포인터를 사용하기 때문에 바깥쪽 클라이언트 코드에서 객체가 삭제되면 대상을 잃게 된다.
// 기본 갈무리 모드
using FilterContainer = std::vector<std::function<bool(int)>>;
FilterContainer filters;
void doSomeWork()
{
auto pw = std::make_unique<Widget>();
pw->addFilter();
// 함수 종료후 pw는 파괴된다.
}
따라서 마찬가지로 값으로 갈무리하여 클로저 내로 복사하는 것이 필요하다.
// 자료 멤버를 복사한다.
auto divisorCopy = divisor;
// 값으로 갈무리
filters.emplace_back([divisorCopy](int value) {return value % divisorCopy == 0; });
// C++14 일반화된 람다 갈무리
filters.emplace_back([divisor = divisor](int value) {return value % divisor == 0; });
C++14 문법에서 사용가능한 일반화된 람다 갈무리는 해당 divisor 멤버 변수를 값으로 복사하여 사용할 수 있게 한다.
- 람다가 갈무리한 객체가 클로저 내에서 안전할 거라는 오해
void addDiVisiorFilter()
{
static auto calc1 = computeSomeValue1();
static auto calc2 = computeSomeValue2();
static auto divisor = computeDivisor(calc1, calc2);
// 아무것도 갈무리하지 않는다.
// divisor는 위 정적 변수를 지칭한다.
filters.emplace_back([=](int value) {return value % divisor == 0; });
// 매번 divisor값이 변한다.
++divisor;
}
람다는 지역변수만 갈무리 하므로 위 코드에서 람다는 아무것도 갈무리하지 않는다. 따라서 divisor는 함수내 선언된 정적 변수를 가리키고 있고 매 함수 호출마다 값이 변하므로 값 복사가 일어난 것이 아니다.
2. 객체를 클로저 안으로 이동하려면 초기화 갈무리를 사용해라
만약 갈무리 하려는 객체를 값 복사 및 참조가 아니라 이동하여 가져오고 싶은 경우가 있을 수 있다. C++11에서는 이러한 기능이 없다. 복사보다 이동이 저렴한 경우 그렇게 사용하고 싶은 경우가 있을 수도 있다.
- C++14 초기화 갈무리
// 항목 32. 초기화 갈무리
auto pw = std::make_unique<Widget>();
// 좌변의 범위는 클로저 클래스의 범위
// 우변의 범위는 람다가 정의되는 지점의 범위
auto func = [pw = std::move(pw)]
{
return pw->isValidated() && pw->isArchived();
};
1 항목에서 일반화된 람다 갈무리 표현에서 등장한 [좌변 식= 우변 식] 표현은 초기화 갈무리(init capture)라고도 한다. 좌변 식에는 클로저 클래스에 속하는 자료 멤버의 이름 그리고 우변 식에는 초기화하는 식으로 표현된다. 좌변 식은 클로저 내에서 유효하고 오른쪽 식은 람다가 정의되는 식에서 유효 해야한다.
람다 표현식은 컴파일러가 하나의 클래스를 자동으로 작성해서 클래스의 객체를 생성하게 만드는 수단이기 때문에 다음과 같이 정의 가능하다.
class IsValAndArch
{
public:
using DataType = std::unique_ptr<Widget>;
explicit IsValAndArch(DataType&& ptr)
: pw(std::move(ptr)) {}
bool operator()()const
{
return pw->isValidated() && pw->isArchived();
}
private:
DataType pw;
};
auto func = IsValAndArch(std::make_unique<Widget>());
클래스를 정의하는 것보다 람다 표현식을 사용하는 것이 더 간편한 이유이다.
- C++11에서 초기화 갈무리를 흉내내는 법 : std::bind
// C++ 14 초기화 갈무리
auto func = [data = std::move(data)]
{
// data를 사용한다.
};
위 초기화 갈무리 표현을 C++11으로 표현하면 다음과 같다.
auto func = std::bind([](const std::vector<double>& data)
{
// 데이터를 사용
}, std::move(data));
bind의 사용으로 해당 람다의 인수를 move를 통해 이동 생성하고 있는 것이다. std::bind의 반환값은 바인드 객체로 첫번째 인수는 호출가능한 객체이고 나머지 인수들은 객체에 전달할 인수를 std::bind로 전달한다.
func이 호출되면 func에 저장된 data의 복사본 혹은 이동 생성본이 std::bind 호출 시 지정한 람다에 하나의 인수로서 전달된다.
bind를 사용하여 초기화 갈무리를 흉내내는 원리는 다음과 같다.
// C++14
auto func = [pw = std::make_unique<Widget>()]
{
return pw->isValiated() && pw->isArchived();
};
// C++11
auto func = std::bind([](const std::unique_ptr<Widget>& pw)
{
return pw->isValiated() && pw->isArchived();
}, std::make_unique<Widget>());
1) 객체를 C++11 클로저 안으로 이동 생성하는 것은 불가능하나 객체를 C++11 바인드 객체 안으로 이동 생성하는 것은 가능하다.
2) C++11 에서 이동 갈무리를 흉내 내는 방법은 객체를 바인드 객체안으로 이동 생성하고 이동 생성된 객체를 람다에 참조로 전달하는 것이다.
3) 바인드 객체의 수명이 클로저의 수명과 같으므로 바인드 객체 안의 객체들을 클로저 안에 있는 것으로 취급하는 것이 가능하다.
3. std::forward를 통해서 전달할 auto&& 매개변수에는 decltype를 사용해라
C++14에서 일반적 람다는 auto를 매개변수에 사용할 수 있다는 점이다.
// 일반적 람다 Generic Lambdas
auto f = [](auto x) {return normalize(x); };
람다가 산출하는 클로저 클래스는 다음과 같은 모습이다.
class f {
public:
template<typename T>
auto operator()(T x)const
{
// 항상 왼값을 전달
return normalize(x);
}
};
이 람다는 normalize에 항상 왼값을 전달한다.
람다의 auto 매개변수를 사용해서 보편 참조를 사용하는 클로저 클래스를 만들 수 있다. 보편 참조는 해당 함수 내에서 인자를 전달할시 forward<T>를 사용해야한다. auto를 매개변수로 사용하는 람다는 T를 어떻게 정의해야할까
auto f = [](auto&& x)
{
return normalize(std::forward< ? ? ? >(x));
};
- decltype(x)
답은 decltype(x)이라고 할 수 있는데, decltype(x)은 x의 실제 타입을 산출한다.
Widget x = //...
decltype(x); // x가 왼값 참조라면 Widget&
decltype(x); // x가 오른값 참조라면 Widget&&
f는 보편참조를 사용하고 있으므로 왼값을 전달한다면 auto 연역에 의해 왼값이라면 Widget&를 오른값이라면 비참조 형식 Widget으로 연역된다. 하지만 다음 forward에서 decltype(x)를 인수로 선언하면,
auto f = [](auto&& x)
{
return normalize(std::forward<decltype(x)>(x));
};
- forward<T> 함수 정의
template<typename T>
T&& forward(remove_reference_t<T>& param)
{
return static_cast<T&&>(param);
}
forward<T>에서 T = Widget& 인 경우와 Widget&& 인 경우가 있을 수 있다. 왼값 참조인 경우 왼값 참조 인수를 전달하는 것이 맞지만 오른값 참조인 경우 T는 비참조 형식 Widget을 전달하는 것이 관례이다.
결론적으로는 참조 축약이 일어나기 때문에 동일한 의미를 지닌다.
std::forward<decltype(x)>(x); // 호출시 오른값 참조라면
Widget&& && forward(Widget& param)
{
return static_cast<Widget&& &&>(param);
};
// 참조 축약이 일어남
Widget&& forward(Widget& param)
{
return static_cast<Widget&&>(param);
};
4. std::bind 보다 람다를 선호하라.
C++11에서 람다가 거의 항상 std::bind보다 나은 선택이다.
- 람다와 std::bind을 사용하는 것의 차이점
어떤 알람을 설정하기 위한 함수가 선언되어 있다하자. 함수는 알림이 일어날 시간 t와 d 기간 동안 소리 s를 출력한다.
// 별칭 선언과 알람 사운드 enum 클래스
using Time = std::chrono::steady_clock::time_point;
enum class Sound{Beep, Siren, Whistle};
using Duration = std::chrono::steady_clock::duration;
// 시간 t에서 d기간 동안 s소리를 출력
void SetAlarm(Time t, Sound s, Duration d);
람다를 사용하여 알람을 설정하면 다음과 같다.
// C++ 11
auto setSoundL = [](Sound s) {
using namespace std::chrono;
SetAlarm(steady_clock::now() + hours(1), s, seconds(30));
};
// C++ 14 , 리터럴을 이용
auto setSoundL = [](Sound s) {
using namespace std::chrono;
using namespace std::literals;
SetAlarm(steady_clock::now() + 1h, s, 30s);
};
bind를 사용하여 알람을 설정하면 다음과 같다.
using namespace std::chrono;
using namespace std::literals;
using namespace std::placeholders;
// bind 객체 생성 시점에서 인자 값을 평가
auto setSoundB = std::bind(SetAlarm, steady_clock::now() + 1h, _1, 30s);
문제는 bind를 사용할 경우 바인드 객체가 생성될 때 전달할 인자를 평가하는 것이 기본 동작 방식이라는 것이다. 보통의 경우에는 문제가 없지만 실제 호출 시점에서 1시간이 흐른 뒤 알람이 울려야한다면 람다 표현식은 의도한 대로 실현되지만 bind의 경우 바인드 객체가 생성 시점에서 1시간이 흐른 뒤를 기점으로 하고 있기 때문이다.
bind가 만약 호출 시점에서 알람을 설정하려면 인수를 또다른 bind 함수를 전달하여 다음과 같이 길게 정의해야한다.
// C++14 실제 호출 시점에서 인자 값을 평가하도록 작성
auto setSoundB = std::bind(SetAlarm, std::bind(std::plus<>(), std::bind(steady_clock::now), 1h)
, _1,
30s);
// C++ 11
auto setSoundB = std::bind(SetAlarm, std::bind(std::plus<steady_clock::time_point>(),std::bind(steady_clock::now), hours(1))
, _1,
seconds(30));
위 표현식으로 실제 호출될 때 std::plus<> 함수가 호출되어 연산이 그 시점에서 일어나도록 하는 것이다.
- 중복 적재의 선택
다음 SetAlarm의 중복 적재 버전을 정의한다. 이번에는 Volume을 추가 인자로 받고 있다.
// 중복 적재
enum class Volume {Normal, Loud, LoudPlusPlus };
void SetAlaram(Time t, Sound s, Duration d, Volume v);
auto SetSoundL = [](Sound s) { SetAlarm(steady_clock::now() + 1h, s, 30s); };
// 중복 적재 해소가 되지 않음
auto SetSoundB = std::bind(SetAlarm, std::bind(std::plus<>(), std::bind(steady_clock::now), 1h), _1, 30s);
람다 표현은 중복 적재 버전을 선택하지만 bind는 어떤 함수에 인자를 넘겨야하는 지 컴파일 시점에서 알 수 없기 때문에 컴파일 되지 않는다. 따라서 다음과 같이 귀찮지만 함수 포인터 형태로 캐스팅하여 명시적으로 전달해야한다.
// 명시적으로 타입을 캐스팅하여 선언
using SetAlarm3ParmType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = std::bind(static_cast<SetAlarm3ParmType >(SetAlarm), std::bind(std::plus<>(), std::bind(steady_clock::now), 1h), _1, 30s);
이러한 함수 포인터로 전달하는 방식은 바인드 객체가 호출되는 함수가 함수 포인터에 간접 호출되므로 최적화, 즉 인라인화 될 가능성이 작다는 것이다.
SetSoundL(Sound::Siren); // 인라인화될 여지가 크다.
SetSoundB(Sound::Siren); // 인라인화될 여지가 작다.
- 함수의 명세를 명시적으로 알 수 있음
enum class CompLevel { Low, Normal, High };
Widget compress(const Widget& w, CompLevel lev);
Widget w;
// w는 값으로 갈무리된다.
auto compressRateL = [w](CompLevel lev) {return compress(w, lev); };
위 코드에서 람다는 w를 값으로 갈무리 하고 lev를 값으로 인수를 받고 있는 것을 명시적으로 알 수 있다.
// w는 값? 참조?
auto compressRateB = std::bind(compress, w, _1);
bind는 겉으로 해당 함수로 전달되는 인자, w가 값인지 참조로 전달되는 지 알 수없다.
실제 동작에서는 w는 값으로 전달되기 때문에 참조로 전달하려면 다음과 같이 함수를 사용해야한다.
// w를 참조로 전달하려면,
auto compressRateB = std::bind(compress, std::ref(w), _1);
- bind를 사용하는 경우
1) C++11에서 초기화 갈무리를 지원하지 않으므로 bind를 사용해 흉내내는 방법을 사용해야할 때
2) 다형적 함수 객체: 바인드 객체에 대한 함수 호출 연산자는 바인드된 인수에 대해 완벽 전달을 실행하기 때문에 어떤 인수도 받을 수 있다. 그런 기능은 템플릿 함수를 묶을 때 유용하다.
class PolyWidget
{
public:
template<typename T>
void operator()(const T& param) const;
};
PolyWidget pw;
auto boundPW = std::bind(pw, _1);
// 템플릿화 된 함수를 호출할 수 있다.
boundPW(1930);
boundPW(nullptr);
boundPW("Rosebud");
C++14에서는 auto 매개변수로 구현할 수 있다.
// C++14
auto boundPWL = [pw](const auto& param)
{
pw(param);
};
'Advanced C++' 카테고리의 다른 글
[Modern C++] (7-2) 동시성 API (0) | 2021.05.01 |
---|---|
[Modern C++] (7-1) 동시성 API (0) | 2021.05.01 |
[Modern C++] (5-2) 오른값 참조, 이동 의미론, 완벽전달 (0) | 2021.04.14 |
[Modern C++] (5-1) 오른값 참조, 이동 의미론, 완벽전달 (0) | 2021.04.12 |
[C++] 형 변환 (2) dynamic_cast / static_cast / reinterpret_cast / const_cast (0) | 2021.04.01 |