[Modern C++] 항목 34: std::bind보다 람다를 선호하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다.
이펙티브 모던 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가 유용할 수 있다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기