[Effective Modern C++] Chapter 6. 람다 표현식 [항목 31~34]
Chapter 6. 람다 표현식
항목 31. 기본 갈무리 모드를 피하라.
참고로, 갈무리는 'capture'를 의미한다. (첨엔 어색했으나 나도 이제 완전히 이 번역에 익숙해져버렸다.)기본 갈무리 모드 (default capture mode)는 '참조 갈무리 모드'와 '값 갈무리 모드'가 있다. 기본 갈무리 모드를 피해야 하는 이유는 크게 dangling reference 위험과 명시적이지 않다는 점(그래서 착각/실수 할 수 있다는 점) 때문이다.
일단, 참조 갈무리 모드에서 dangling reference 문제가 발생할 수 있다는 것은 너무 당연하고, 값 갈무리 모드에서도 pointer가 capture되는 경우에 그러한 문제가 발생 할 수 있다는 것은 당연하다.
사실 결정적인 문제는 명시적이지 않다는 점이다. 이로 인해 문제가 발생 할 수 있을 만한 상황을 정리하면 다음과 같다.
1. this pointer의 캡처
- 숨겨져있던 this pointer가 캡처됨으로 인해 dangling pointer 등의 문제가 발생할 수 있다.
2. 람다표현식을 복붙(Copy&Paste) 할 경우, 실수할 가능성이 커진다.
- 유용한 람다표현식을 그대로 다른 곳에서 쓸 경우가 있을 수 있다. 이 경우, capture list가 명시적이지 않아서 문제들이 발생할 소지가 있다.
3. 전역 변수나 static 같이 global scope를 가진 변수들 (즉, non-automatic variable들)의 값이 복사로서 capture될 것이라는 착각을 할 수 있다.
- 오직 automatic variable들(즉, 지역변수들) 만 capture될 수 있는데, 기본 값 갈무리 모드를 사용하면, non-automatic variable들도 값 복사로서 capture될 것이라는 착각을 할 여지가 있다.
즉, 기본 갈무리 모드의 문제는 결국 '명시적이지 않다'는 것이다. 필요한 것들만 명시적으로 capture해주는 게 더 좋은 코드라고 할 수 있겠다.
[기억해 둘 사항들]
- 기본 참조 갈무리는 참조가 대상을 잃을 위험이 있다.
- 기본 값 갈무리는 포인터(특히 this)가 대상을 잃을 수 있으며, 람다가 자기 완결적이라는 오해를 부를 수 있다.
항목 32. 객체를 클로저 안으로 이동하려면 초기화 갈무리를 사용하라.
std::unique_ptr, std::future, std::future과 같이 이동 전용인 경우나 효율성 측면에서 이동이 필요할 경우, 클로저안으로 객체를 이동할 수 있어야 한다.일단 C++14에서는 초기화 갈무리 (init capture)를 사용하면 되므로 너무 쉽다.
그러나 C++11에서는 초기화 갈무리가 없으므로 문제다. (C++11 얘기하기 너무 싫다... 맘편히 C++14이상만 생각하고 싶다!!!)
이에 대한 해결책은 다음과 같다.
1. Functor를 만들어서 해결한다.
- 당연히 이렇게 하면 가능하긴 하지만... 이건 람다가 아니잖아?! 빼애애액
2. std::bind를 활용한다.
- std::bind를 통해 람다의 매개변수에 왼값 참조로서 이동된 객체를 묶는다. (아래 코드 참고)
#include <memory>
#include <functional>
#include <iostream>
class Example {};
int main()
{
std::unique_ptr<Example> p;
auto func_in_cpp_11 = std::bind(
[](const std::unique_ptr<Example>& p) {
std::cout << "I'm an example in C++11 :(" << std::endl;
},
std::move(p)
);
auto func_in_cpp_14 = [p = std::move(p)]() {
std::cout << "I'm an example in C++14 :)" << std::endl;
};
func_in_cpp_11();
func_in_cpp_14();
}
void lambda_mutable_test()
{
class Lambda
{
int& a;
int b;
public:
Lambda(int &a, int b) : a(a), b(b) {}
void operator()() const {
a = 1;
b = 2; // Compiler Error
}
};
int a, b;
[&a, b]() {
a = 1;
b = 2; // Compiler Error
};
Lambda lambda(a, b); lambda();
////////////////////////////////////////////
// Think "int &" is similar to "const int *".
class MutableLambda
{
int & a;
int b;
public:
MutableLambda(int &a, int b) : a(a), b(b) {}
void operator()() {
a = 1;
b = 2;
}
};
int a, b;
[&a, b]() mutable {
a = 1;
b = 2;
};
MutableLambda mutableLambda(a, b); mutableLambda();
}
이번 항목에 직접적인 연관은 없지만, Lambda mutable에 관한 간단한 실험 코드도 같이 첨부하였다.
[기억해 둘 사항들]
- 객체를 클로저 안으로 이동할 때에는 C++14의 초기화 갈무리를 사용하라.
- C++11에서는 직접 작성한 클래스나 std::bind로 초기화 갈무리를 흉내 낼 수 있다.
항목 33. std::forward를 통해서 전달할 auto&& 매개변수에는 decltype을 사용하라.
이번 항목은 C++14에만 해당하는 내용이다. C++14에 generic lambdas가 추가되면서, 매개변수 명세에 auto를 사용할 수 있게 되었다. 만약 lambda 내에서 perfect forwarding을 하고 싶을 경우, std::forward<decltype(...)>(...) 처럼 하면 된다.[기억해 둘 사항들]
- std::forward를 통해서 전달할 auto&& 매개변수에는 decltype을 사용하라.
항목 34. std::bind보다 람다를 선호하라.
std::bind보다 람다를 선호해야 할 이유는 '가독성'과 '성능'이다.일단 가독성 측면에서, std::bind로 하려면 난해하고 복잡한 것을 람다로는 간단하고 명료하게 할 수 있다.
그리고, 최적화 가능성이 람다가 더 많기 때문에 성능 측면에서도 std::bind보다 람다가 우월하다. (자세한 건 아래 참조)
/*
https://godbolt.org/g/25uYMS
*/
#include <vector>
#include <functional>
class Functor {
private:
int a;
int b;
public:
Functor(int a, int b) : a(a), b(b) {}
bool operator()(int n) const { return a < n && n < b; }
};
bool comp(int a, int b, int n) { return a < n && n < b; }
bool test_bind_function_pointer(int a, int b, int c)
{
auto bind_func = std::bind(comp, a, b, std::placeholders::_1);
std::vector<decltype(bind_func)> vec;
vec.emplace_back(bind_func);
return vec.back()(c);
}
bool test_bind_functor(int a, int b, int c)
{
auto bind_func = std::bind(Functor(a, b), std::placeholders::_1);
std::vector<decltype(bind_func)> vec;
vec.emplace_back(bind_func);
return vec.back()(c);
}
bool test_functor(int a, int b, int c)
{
std::vector<Functor> vec;
vec.emplace_back(a, b);
return vec.back()(c);
}
bool test_lambda(int a, int b, int c)
{
auto lambda = [a, b](int n) { return a < n && n < b; };
std::vector<decltype(lambda)> vec;
vec.emplace_back(lambda);
return vec.back()(c);
}
int main()
{
test_bind_function_pointer(1, 2, 3);
test_bind_functor(1, 2, 3);
test_functor(1, 2, 3);
test_lambda(1, 2, 3);
}
위 코드는 std::bind와 lambda의 최적화를 비교하기 위해 작성한 코드로서, https://godbolt.org/g/25uYMS 에서 assemble된 결과를 볼 수 있다. gcc 6.3 -O3 -std=c++14 에서의 결과를 분석해보겠다.
일단 test_bind_function_pointer의 경우를 보면, std::bind 에 function pointer를 넘겨주기 때문에 어셈블리 상에서 call [QWORD PTR [rax-16]] 와 같이 동작하는 것을 볼 수 있다. 이러한 동작은 std::bind의 반환형이 std::_Bind<bool (*(int, int, std::_Placeholder<1>))(int, int, int)> 라는 것에 기인한다. callable object 부분의 type이 function pointer이므로 이와 같이 동작할 수 밖에 없는 것이다. 그러나 사실 그냥 comp를 호출하거나 comp를 inlining 하도록 최적화되기를 바랄 것이다. 실제로 똑같은 코드를 clang으로 컴파일할 경우, 이러한 최적화를 수행해줌을 확인할 수 있다. 하지만, 이러한 최적화를 범용적으로 기대하는 것은 힘들다.
두 번째로, test_bind_functor 같은 경우는 std::bind가 반환하는 타입이 std::_Bind<Functor (std::_Placeholder<1>) 이다. 즉, 호출될 functor object의 타입이 class로서 명확히 드러나므로, Functor::operator() 를 직접적으로 호출하거나 이 것이 inlining 될 것임을 기대할 수 있다. test_functor 도 마찬가지 이유에서 최적화를 기대할 수 있다.
마지막으로, test_lambda 이다. 이 경우, 가장 많은 최적화가 된 것을 볼 수 있다. lambda는 내부적으로 functor 로서 구현된다. 그런데 유독 람다의 경우 최적화가 더 많이 되는 이유는 무엇일까? 그 것은 바로 람다의 경우는 컴파일러가 자동적으로 생성하는 functor 를 사용하기 때문이다. 즉, test_bind_functor 나 test_functor 에서는 사용자가 정의한 functor 를 사용하므로 컴파일러가 사용자 타입에 대해 자세히 알지 못하지만, 람다의 경우 functor 를 컴파일러가 만들기 때문에 더 많은 정보를 가질 수 있고, 이로 인해 더 많은 최적화를 수행할 수 있다.
즉, 결론적으로 이러한 이유에서 std::bind보다 람다를 선호해야한다.
그러나 C++11에서는 어쩔 수 없이 std::bind를 활용해야 하는 경우가 다음과 같이 2가지 있다.
1. move semantics의 활용 (항목 32에서 다룸)
2. polymorphic function object
그러나, C++14에서는 각각 초기화 갈무리와 generic lambdas에 의해 해결되었으므로, C++14부터는 람다를 적극 사용해야 한다.
[기억해 둘 사항들]
- std::bind를 사용하는 것보다 람다가 더 읽기 쉽고 표현력이 좋다. 그리고 더 효율적일 수 있다.
- C++14가 아닌 C++11에서는 이동 갈무리를 구현하거나 객체를 템플릿화된 함수 호출 연산자에 묶으려 할 때 std::bind가 유용할 수 있다.
재밌는 4분짜리 영상으로 포스팅을 마치겠습니다~ 람다 만세!!!
https://www.youtube.com/watch?v=3wm5QzdddYc
댓글
댓글 쓰기