[Modern C++] 항목 34: std::bind보다 람다를 선호하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다.
e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 6. 람다 표현식
👉🏻 항목 34: std::bind보다 람다를 선호하라
🔍 배경
- C++11의
std::bind는 C++98의std::bind1st,std::bind2nd의 후신이다. 비공식적으로는 2005년 TR1(기술 보고서)에std::tr1::bind로 포함되어 있었다. - C++11에서는 람다가 거의 항상
std::bind보다 나은 선택이고, C++14에서는 확고하게 우월한 선택이다. std::bind가 돌려준 함수 객체를 바인드 객체라고 부른다.- 람다를 선호할 가장 중요한 이유는 가독성이다.
🔍 가독성 비교: 경보 설정 예제
// 시간상의 한 지점을 대표하는 타입 별칭(using 구문은 항목 9 참고)
using Time = std::chrono::steady_clock::time_point;
// enum class에 관해서는 항목 10을 보라
enum class Sound { Beep, Siren, Whistle };
// 시간 길이를 나타내는 타입에 대한 별칭 선언
using Duration = std::chrono::steady_clock::duration;
// 시간 t에서 소리 s를 기간 d만큼 출력한다
void setAlarm(Time t, Sound s, Duration d);
- 한 시간 후부터 30초간 소리를 내는 경보를 설정하되, 경보음은 미리 결정되지 않는다고 하자. 소리만 지정하면 되는 인터페이스를 람다로 작성하면 편리하다.
// setSoundL('L'은 lambda를 뜻함)은 경보음을
// 직접 지정해서 한 시간 후부터 30초간 울리게
// 하는 함수 객체이다
auto setSoundL =
[](Sound s)
{
// std::chrono의 구성요소들을 한정자 없이 사용할 수 있게 한다
using namespace std::chrono;
setAlarm(steady_clock::now() + hours(1), // 한 시간 후부터
s, // 지정된 경보음을
seconds(30)); // 30초 간 재생
};
- C++14에서는 C++11의 사용자 정의 리터럴 기능에 기초한 표준 접미사(
s,ms,h등)로 더 간결하게 만들 수 있다.
auto setSoundL =
[](Sound s)
{
using namespace std::chrono;
using namespace std::literals; // C++14 접미사들을 위해
setAlarm(steady_clock::now() + 1h, // C++14 버전;
s, // 의미는 앞의
30s); // 예제와 동일함
};
⚠️ std::bind 버전의 문제 ①: 시도와 오류
첫 번째 시도 (오류 포함)
using namespace std::chrono; // 이전과 동일
using namespace std::literals;
using namespace std::placeholders; // _1을 사용하는 데 필요함
auto setSoundB = // B는 bind를 뜻함
std::bind(setAlarm,
steady_clock::now() + 1h, // 문제가 있음! 아래 설명 참고
_1,
30s);
setAlarm호출 부분을 강조할 수 없다. 자리표_1은 마법처럼 보이며,setSoundB의 첫 인수가setAlarm의 둘째 인수로 전달된다는 점을 파악하려면 자리표 번호와std::bind매개변수 목록의 위치를 머릿속에서 연결해야 한다. 인수의 타입도 알 수 없어서setAlarm선언을 살펴봐야 한다.- 정확하지 않은 이유: 람다에서는
steady_clock::now() + 1h가setAlarm이 호출되는 시점에 평가된다. 그러나std::bind에서는 이 표현식이std::bind로 전달되므로std::bind호출 시점에 평가되어 그 시간이 바인드 객체에 저장된다. 결과적으로 경보는setAlarm을 호출하고 한 시간 후가 아니라std::bind를 호출하고 한 시간 후에 울린다. - 해결하려면 그 표현식을
setAlarm호출 때까지 지연하라고std::bind에게 알려야 한다. 이를 위해 첫std::bind안에 두 번째std::bind호출을 내포시켜야 한다.
// C++14 버전
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<>(),
std::bind(steady_clock::now),
1h),
_1,
30s);
std::plus<>처럼 템플릿 타입 인수를 생략하는 것은 C++14에서 표준 연산자 템플릿에 대해 가능하다. C++11에는 이런 기능이 없으므로 타입을 명시해야 한다.
// C++11 버전
using namespace std::chrono;
using namespace std::placeholders;
auto setSoundB =
std::bind(setAlarm,
std::bind(std::plus<steady_clock::time_point>(),
std::bind(steady_clock::now),
hours(1)),
_1,
seconds(30));
(역주: 이 C++11 코드는 setSoundB의 정의 자체는 컴파일되지만 실제로 호출하는 코드는 컴파일되지 않는다. std::plus<steady_clock::time_point>는 time_point 매개변수 두 개를 받는데 hours(1)은 duration이기 때문이다.)
⚠️ std::bind 버전의 문제 ②: 오버로딩
setAlarm을 오버로딩해서 음량(Volume)을 네 번째 매개변수로 받는 버전을 추가한다고 하자.
enum class Volume { Normal, Loud, LoudPlusPlus };
void setAlarm(Time t, Sound s, Duration d, Volume v);
- 람다는 이전처럼 작동한다. 오버로딩 해소에 의해
setAlarm의 인수 세 개짜리 버전이 선택되기 때문이다.
auto setSoundL = // 이전과 동일
[](Sound s)
{
using namespace std::chrono;
setAlarm(steady_clock::now() + 1h, // OK; setAlarm의
s, // 3 인수 버전을
30s); // 호출한다
};
- 반면
std::bind버전은 컴파일되지 않는다.
auto setSoundB = // 오류! 어떤
std::bind(setAlarm, // setAlarm인지?
std::bind(std::plus<>(),
std::bind(steady_clock::now),
1h),
_1,
30s);
- 컴파일러가 알고 있는 것은 함수 이름뿐이며, 이름만으로는 중의성을 해소할 수 없다. 컴파일되려면
setAlarm을 적절한 함수 포인터 타입으로 캐스팅해야 한다.
using SetAlarm3ParamType = void(*)(Time t, Sound s, Duration d);
auto setSoundB = // 이제는
std::bind(static_cast<SetAlarm3ParamType>(setAlarm), // OK
std::bind(std::plus<>(),
std::bind(steady_clock::now),
1h),
_1,
30s);
⚠️ std::bind 버전의 문제 ③: 인라인화(inlining)
- 람다
setSoundL의 함수 호출 연산자 안에서setAlarm호출은 컴파일러가 통상적인 방식으로 인라인화할 수 있는 보통의 함수 호출이다.
setSoundL(Sound::Siren); // 여기서 setAlarm의 본문이
// 인라인화될 가능성이 크다
- 그러나
std::bind호출은setAlarm을 가리키는 함수 포인터를 전달하므로,setSoundB의 함수 호출 연산자 안에서setAlarm호출은 함수 포인터를 통해 일어난다. 함수 포인터를 통한 호출은 컴파일러가 인라인화할 가능성이 더 낮다.
setSoundB(Sound::Siren); // 여기서 setAlarm의 본문이
// 인라인화될 가능성은 작다
- 따라서
std::bind보다 람다를 사용할 때 더 빠른 코드가 생성될 수 있다.
🔍 복잡한 코드에서 람다의 장점
- 주어진 인수가 최솟값(
lowVal)과 최댓값(highVal) 사이에 있는지 판별하는 예제.
auto betweenL = // C++14
[lowVal, highVal]
(const auto& val)
{ return lowVal <= val && val <= highVal; };
std::bind로도 표현할 수 있으나, 마치 코드를 남들이 이해하지 못하게 작성해서 자신의 밥그릇을 지키려는 프로그래머가 짠 코드처럼 보인다.
using namespace std::placeholders; // 이전과 동일
auto betweenB =
std::bind(std::logical_and<>(), // C++14
std::bind(std::less_equal<>(), lowVal, _1),
std::bind(std::less_equal<>(), _1, highVal));
- C++11에서는 비교할 타입들을 명시적으로 지정해야 한다. 람다도
auto매개변수를 받지 못하므로 타입을 지정해야 한다.
auto betweenB = // C++11 버전
std::bind(std::logical_and<bool>(),
std::bind(std::less_equal<int>(), lowVal, _1),
std::bind(std::less_equal<int>(), _1, highVal));
auto betweenL = // C++11 버전
[lowVal, highVal]
(int val)
{ return lowVal <= val && val <= highVal; };
- 어떤 경우든 람다 버전이 더 짧을 뿐만 아니라 이해하기도 쉽고 유지보수하기도 쉽다.
⚠️ std::bind 버전의 문제 ④: 인수 저장/전달 방식이 불투명하다
Widget객체의 압축 복사본을 만드는 함수가 있다고 하자.
enum class CompLevel { Low, Normal, High }; // 압축 수준
Widget compress(const Widget& w, // w의 압축 복사본을
CompLevel lev); // 만든다
- 특정한
Widget객체w의 압축 수준을 지정할 수 있는 함수 객체를std::bind로 만든다.
Widget w;
using namespace std::placeholders;
auto compressRateB = std::bind(compress, w, _1);
w는 이후의compress호출을 위해 바인드 객체 안에 저장된다. 그런데w가 값으로 저장될까, 참조로 저장될까? 이 구분은 중요하다.std::bind호출과compressRateB호출 사이에서w가 수정된다면, 참조로 저장된w에는 변화가 반영되지만 값으로 저장된w는 그렇지 않기 때문이다.- 답은 “값으로 전달된다”이다. 그런데 이 답을 얻으려면
std::bind의 작동 방식을 알고 있어야 하며, 호출 구문 자체로는 추론할 수 없다. (std::bind는 주어진 인수들을 항상 복사하지만, 호출자가 인수에std::ref를 적용하면 참조로 전달되는 효과를 얻을 수 있다.)
auto compressRateB = std::bind(compress, std::ref(w), _1);
// compressRateB는 마치 w의 참조를(복사본이 아니라) 담고 있는 것처럼 행동한다
- 반면 람다에서는
w가 값으로 캡처되는지 참조로 캡처되는지가 소스 코드에 명백히 드러나고, 매개변수가 전달되는 방식도 마찬가지로 명백하다.
auto compressRateL = // w는 값으로 캡처되고
[w](CompLevel lev) // lev는 값으로 전달된다
{ return compress(w, lev); };
compressRateL(CompLevel::High); // 인수는 값으로 전달된다
- 그러나
std::bind로 얻은 바인드 객체를 호출할 때에는 인수의 전달 방식이 명확하지 않다.
compressRateB(CompLevel::High); // 인수는 어떻게 전달될까?
- 답은, 바인드 객체에 전달되는 모든 인수는 참조로 전달된다는 것이다. 이는 그런 객체의 함수 호출 연산자가 완벽 전달을 사용하기 때문이다.
🔍 C++11에서 std::bind가 유용한 두 가지 경우
std::bind를 사용하는 코드는 람다에 비해 읽기 힘들고 표현력이 낮으며 효율성도 떨어질 가능성이 있다. C++14에서는std::bind를 사용하는 것이 합당한 경우가 없다. C++11에서는 다음 두 경우라면 정당화할 수 있다.
① 이동 캡처(move capture)
- C++11 람다는 이동 캡처를 지원하지 않지만, 람다와
std::bind의 조합으로 흉내 낼 수 있다(항목 32 참고). C++14에서는 초기화 캡처 덕분에 그런 흉내가 필요 없다.
② 다형적 함수 객체(polymorphic function object)
- 바인드 객체의 함수 호출 연산자는 완벽 전달을 사용하므로 그 어떤 타입의 인수도 받을 수 있다(단, 항목 30의 제약 안에서). 객체를 템플릿화된 함수 호출 연산자와 묶으려 할 때 유용하다.
class PolyWidget {
public:
template<typename T>
void operator()(const T& param) const;
…
};
PolyWidget pw;
auto boundPW = std::bind(pw, _1);
boundPW(1930); // PolyWidget::operator()에 int를 전달
boundPW(nullptr); // PolyWidget::operator()에 nullptr를 전달
boundPW("Rosebud"); // PolyWidget::operator()에 문자열을 전달
- C++11의 람다로는 이런 일이 불가능하다. 그러나 C++14에서는
auto매개변수를 가진 람다로 간단히 구현할 수 있다.
auto boundPW = [pw](const auto& param) // C++14
{ pw(param); };
- 이들은 극단적인 경우이며, C++14 람다를 지원하는 컴파일러가 늘어남에 따라 사라질 것이다. 2005년에
bind는 C++98의 비슷한 함수들보다 크게 개선된 것이었으나, C++11이 람다를 지원하면서std::bind는 사실상 비권장(deprecate) 기능이 되었다.
🧐 정리
std::bind를 사용하는 것보다 람다가 더 읽기 쉽고 표현력이 좋다. 그리고 더 효율적일 수 있다.- C++14가 아닌 C++11에서는 이동 캡처를 구현하거나 객체를 템플릿화된 함수 호출 연산자에 묶으려 할 때
std::bind가 유용할 수 있다.
댓글남기기