본문 바로가기
프로그래밍/C & C++

[C++] 함수 객체 활용

by 별준 2022. 12. 12.

References

  • C++ Standard Library 2nd

Contents

  • Function Objects
  • Predicates vs. Function Objects
  • Predefined Function Objects and Binders
  • Lambda

[C++] Function Object (함수 객체)

[C++] Lambda Expression (람다 표현식)

(기존에 함수 객체와 람다에 대해 작성했던 포스팅(c++17,c++20 기준)

 

이번 포스팅에서 함수 객체(function object; functor)에 대해서 조금 더 자세히 알아보고, remove_if()와 for_each()에서 함수 객체를 어떻게 활용할 수 있는지도 살펴보겠습니다. 또한, 함수 객체와 관련된 바인더와 람다도 추가로 살펴보겠습니다.

 

The Concept of Function Objects

함수 객체는 functor라고도 부르는데, 이는 () 연산자를 정의한 객체를 의미합니다.

FunctionObjectType fo;
...
fo(...)

위의 표현식 fo()는 함수 fo()를 호출하는 것이 아닌 함수 객체 fo의 () 연산자를 호출합니다. 따라서, FunctionObjectType 클래스는 아래처럼 정의된 클래스 입니다.

class FunctionObjectType {
public:
  void operator()() {
    statements
  }
};

위와 같은 방식으로 정의하면 조금 더 복잡할 수는 있지만, 다음과 같은 장점들이 있습니다.

  1. 함수 객체는 상태를 가질 수 있어서 조금 더 스마트합니다. 따라서, 서로 똑같은 함수 객체 클래스의 인스턴스이지만, 서로 다른 상태를 가질 수 있습니다. 이는 일반 함수에서는 불가능합니다.
  2. 각 함수 객체는 자신만의 데이터 타입을 갖습니다. 따라서, 함수 객체의 데이터 타입을 템플릿에 전달하여 특정 행동을 지정할 수 있고, 다른 데이터 타입의 컨테이너를 사용할 수 있다는 장점이 있습니다.
  3. 함수 객체는 보통 함수 포인터보다 빠릅니다.

이어지는 내용에서 예제를 통해 함수 객체에 대해 조금 더 자세히 살펴보겠습니다.

 

Function Objects as Sorting Criteria

프로그램을 작성하다 보면 특별한 클래스를 요소로 갖는 콜렉션을 정렬할 필요가 있을 수 있습니다 (ex, Person 클래스의 collection). 하지만, 객체를 정렬하기 위해 일반적인 < 연산자를 구현하고 싶지 않거나 구현할 수 없을 수도 있습니다. 대신, 어떤 멤버 함수를 기반으로 하는 특별한 정렬 기준에 따라 객체를 정렬할 수 있습니다. 이러한 경우에 함수 객체가 유용하게 사용됩니다.

#include <iostream>
#include <string>
#include <set>
#include <algorithm>

using namespace std;

class Person {
public:
  string firstname() const;
  string lastname() const;
  ...
};

// class for function predicate
class PersonSortCriterion {
public:
  bool operator()(const Person& p1, const Person& p2) const {
    return p1.lastname() < p2.lastname() ||
          (p1.lastname() == p2.lastname() && p1.firstname() < p2.firstname());
  }
};

int main()
{
  set<Person, PersonSortCriterion> coll;
  ...
  // do something with the elements
  for (auto pos = coll.begin(); pos != coll.end(); ++pos) {
    ...
  }
}

위 코드에서 set인 coll은 함수 객체 클래스로 정의된 PersonSortCriterion이라는 특별한 정렬 기준을 사용합니다. PersonSortCriterion은 () 연산자를 정의하여 두 Person 객체를 lastname 으로 비교한 뒤, 이들이 같다면 firstname으로 비교합니다. coll의 생성자는 PersonSortCriterion 클래스의 인스턴스를 자동으로 생성하여 이 정렬 기준에 따라 요소들이 정렬되도록 합니다.

 

여기서 정렬 기준인 PersonSortCriterion은 데이터 타입입니다. 따라서, 집합을 위한 템플릿 인자로 사용할 수 있습니다. 만약 일반 함수로 정렬 기준을 만들었다면 이런 식으로 사용할 수 없습니다.

 

Function Objects with Internal State

이번에는 함수 객체를 하나 이상의 상태를 갖는 함수처럼 사용할 수 있는 방법을 살펴보겠습니다.

#include <iostream>
#include <string>
#include <list>
#include <algorithm>
#include <iterator>

using namespace std;

template<typename container>
void PRINT(container& c, string optstr = "")
{
  cout << optstr;

  for (auto pos = c.begin(); pos != c.end(); ++pos) {
    cout << *pos << " ";
  }
  cout << endl;
}

class IntSequence {
private:
  int value;

public:
  IntSequence(int initialValue) : value(initialValue) {}
  int operator()() {
    return value++;
  }
};

int main()
{
  list<int> coll;

  // insert value from 1 to 9
  generate_n(back_inserter(coll), 9, IntSequence(1));
  PRINT(coll);

  // replace second to last element but one with values starting at 42
  generate(next(coll.begin()), prev(coll.end()), IntSequence(42));
  PRINT(coll);
}

위 예제 코드에서 함수 객체 IntSequence는 정수값의 수열을 생성합니다. () 연산자가 호출될 때마다, 이 함수 객체는 자신의 value를 반환하고 value를 증가시킵니다. 생성자 인자로 초기값을 전달할 수 있습니다.

 

이 함수 객체는 generate()와 generate_n() 알고리즘에 사용되었습니다. generate_n() 알고리즘은 전달된 함수 객체를 사용하여 1부터 9까지의 값을 생성하고, 비슷하게 generate() 알고리즘은 42부터 시작하는 수열을 생성합니다. 여기서 generate() 함수는 두 번째 요소에서부터 끝에서 두 번째 요소까지 덮어씁니다.

다른 () 연산자를 정의하면 조금 더 복잡한 수열도 쉽게 만들 수 있습니다. 

 

기본적으로 함수 객체는 레퍼런스가 아닌 값으로 전달됩니다. 따라서, 알고리즘은 함수 객체의 상태를 수정하지 못합니다. 예를 들어, 아래 코드는 1부터 시작하는 수열을 두 번 생성합니다.

IntSequence seq(1); // integral sequence starting with value 1

// insert sequence beginning with 1
generate_n(back_inserter(coll), 9, seq);

// insert sequence beginning with 1 again
generate_n(back_inserter(coll), 9, seq);

함수 객체를 레퍼런스가 아닌 값으로 전달하면 상수와 임시 표현식을 전달할 수 있다는 장점을 가집니다. 따라서, IntSequence(1)과 같은 임시 표현식을 전달할 수 있습니다.

 

동시에 함수 객체를 값으로 전달하면 함수 객체의 상태를 수정할 수 없다는 단점이 있습니다. 알고리즘 내에서는 함수 객체의 상태를 수정할 수 있지만, 내부적으로 복사되기 때문에 이 함수 객체의 마지막 상태에 접근하거나 처리할 수는 없습니다. 하지만 마지막 상태에 접근할 필요는 거의 없기 때문에 어떻게 알고리즘에서 '결과'를 얻을 것이냐 하는 것이 키 포인트입니다.

알고리즘에 전달된 함수 객체로부터 '결과' 또는 '피드백'을 얻는 방법으로는 다음의 3가지 방법이 있습니다.

  1. 외부에 상태를 유지하고, 함수 객체가 이를 참조
  2. 함수 객체를 레퍼런스로 전달
  3. for_each() 알고리즘의 반환값을 사용

 

먼저 함수 객체를 참조로 전달하도록 변경하고 싶다면, 알고리즘 호출을 한정하여 함수 객체 타입이 레퍼런스가 되게 하면 간단히 해결됩니다.

#include <iostream>
#include <string>
#include <list>
#include <algorithm>
#include <iterator>

using namespace std;

template<typename container>
void PRINT(container& c, string optstr = "")
{
  cout << optstr;

  for (auto pos = c.begin(); pos != c.end(); ++pos) {
    cout << *pos << " ";
  }
  cout << endl;
}

class IntSequence {
private:
  int value;

public:
  IntSequence(int initialValue) : value(initialValue) {}
  int operator()() {
    return value++;
  }
};

int main()
{
  list<int> coll;
  IntSequence seq(1); // integral sequence starting with 1

  // insert value from 1 to 4
  // - pass function object by reference
  generate_n<back_insert_iterator<list<int>>,
          int, IntSequence&>(back_inserter(coll), 4, seq);
  PRINT(coll);

  // insert values from 42, to 45
  generate_n(back_inserter(coll), 4, IntSequence(42));
  PRINT(coll);

  // continue with first sequence - pass function object by value
  // so that it will continue with 5 again
  generate_n(back_inserter(coll), 4, seq);
  PRINT(coll);

  // continue with first sequence again
  generate_n(back_inserter(coll), 4, seq);
  PRINT(coll);
}

위 코드의 출력은 다음과 같습니다.

generate_n()을 처음 호출할 때 seq 함수 객체는 레퍼런스로 전달됩니다. 이를 위해서 generate_n() 함수의 템플릿 인자들을 명시적으로 지정했습니다. 그 결과 seq의 내부 상태는 호출 후에 수정되며, generate_n()의 세 번째 호출에서 seq 객체가 다시 사용될 때, 기존의 수열에서부터 이어나갈 수 있습니다. 이때는 값으로 전달되었습니다. 따라서, 세 번째 호출에서는 seq의 상태를 바꾸지 않으며, 결과적으로 마지막 호출 역시 5에서 시작하는 수열을 사용합니다.

 

The Return Value of for_each()

위에서 언급한 함수 객체의 내부 방법을 바꾸는 세 가지 방법 중 for_each()를 사용하는 방법에 대해 살펴보겠습니다. for_each() 알고리즘을 사용하면 마지막 상태에 접근하기 위해 함수 객체를 레퍼런스로 전달할 필요가 없습니다. for_each()는 자신의 함수 객체를 반환하는 특별한 기능을 가지고 있는데, 따라서, 자신의 함수 객체의 상태는 for_each()의 반환값을 검사하여 알아볼 수 있습니다.

 

아래 예제 코드는 for_each()의 반환값을 사용하는 예시입니다. 이 예제는 수열의 평균값을 처리하는 방법을 보여줍니다.

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

class MeanValue {
private:
  long num; // number of elements
  long sum; // sum of all element values

public:
  MeanValue() : num(0), sum(0) {}
  // process one more element of the sequences
  void operator()(int elem) {
    ++num;
    sum += elem;
  }
  // return mean value
  double value() {
    return static_cast<double>(sum) / static_cast<double>(num);
  }
};

int main()
{
  vector<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8 };

  // process and print mean value
  MeanValue mv = for_each(coll.begin(), coll.end(), MeanValue());
  cout << "mean value: " << mv.value() << endl;
}

위 코드에서 for_each()의 인자로 MeanValue()가 전달됩니다. 이 표현식은 요소의 갯수를 세고 모든 요소 값을 더하는 함수 객체를 생성합니다. 그리고 for_each() 함수 호출 이후, 함수 객체는 반환되어 mv에 할당됩니다. 따라서, mv.value()를 호출하여 함수 객체의 상태를 확인할 수 있습니다.

 

참고로 아래와 같이 double로 자동 형 변환을 정의하여 MeanValue를 조금 더 스마트하게 만들 수 있습니다. 그러면 for_each()에서 처리된 평균값을 직접 사용할 수 있습니다.

class MeanValue {
private:
  long num; // number of elements
  long sum; // sum of all element values

public:
  MeanValue() : num(0), sum(0) {}
  // process one more element of the sequences
  void operator()(int elem) {
    ++num;
    sum += elem;
  }
  // return mean value
  operator double() {
    return static_cast<double>(sum) / static_cast<double>(num);
  }
};

int main()
{
  vector<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8 };

  // process and print mean value
  double mv = for_each(coll.begin(), coll.end(), MeanValue());
  cout << "mean value: " << mv << endl;
}

 

Predicates versus Function Objects

Predicates란 boolean 값을 반환하는 함수 또는 함수 객체 입니다. 그러나, boolean 값을 반환하는 모든 함수가 STL에서 유효한 predicate는 아닙니다. 이 때문에 신기한 동작이 발생할 수 있는데, 아래 코드를 살펴보도록 하겠습니다.

#include <iostream>
#include <list>
#include <algorithm>

using namespace std;

template<typename container>
void PRINT(container& c, string optstr = "")
{
  cout << optstr;

  for (auto pos = c.begin(); pos != c.end(); ++pos) {
    cout << *pos << " ";
  }
  cout << endl;
}

class Nth {
private:
  int nth;
  int count;

public:
  Nth(int n) : nth(n), count(0) {}
  bool operator()(int) {
    return ++count == nth;
  }
};

int main()
{
  list<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  PRINT(coll, "coll:        ");

  // remove thrid element
  auto pos = remove_if(coll.begin(), coll.end(), Nth(3));
  coll.erase(pos, coll.end());

  PRINT(coll, "3rd removed: ");
}

위 코드에서는 n번재 호출할 때 true를 반환하는 Nth라는 함수 객체를 정의했습니다. 그리고 이를 remove_if()로 전달했을 때의 결과는 다음과 같습니다.

세 번째 요소만 삭제될 것이라고 예상되지만, 세 번째와 여섯 번째 요소가 삭제되었습니다.

이러한 문제의 원인은 이 알고리즘의 일반적인 구현이 내부적으로 알고리즘이 실행되는 동안 predicate를 복사하기 때문입니다 (플랫폼에 따라 결과가 다를 수 있습니다).

내부 구현을 나타내면 다음과 같습니다.

template <typename _ForwardIterator, typename _Predicate>
_ForwardIterator
__remove_if(_ForwardIterator __first, _ForwardIterator __last,
                _Predicate __pred)
{
  __first = std::__find_if(__first, __last, __pred);
  if (__first == __last)
    return __first;
    
  _ForwardIterator __result = __first;
  ++__first;
  for (; __first != __last; ++__first)
    if (!__pred(__first))
    {
      *__result = _GLIBCXX_MOVE(*__first);
      ++__result;
    }
  return __result;
}

remove_if 알고리즘은 find_if()를 사용하여 삭제해야 할 첫 번째 요소를 찾습니다. 하지만 이 알고리즘은 남은 요소를 처리할 때 for 루프를 반복하면서 전달받은 predicate인 op의 복사본을 사용합니다. 여기서 기존의 상태의 Nth가 다시 사용되어 남은 요소들 중 세 번째 요소가 삭제되는데, 이것이 바로 여섯 번째 요소입니다.

 

이러한 동작은 버그는 아니고, C++ 표준에서 알고리즘이 내부적으로 predicate를 얼마나 자주 복사할 것인가에 대해 명시하지 않았습니다. 따라서 C++ 표준 라이브러리에서 보장하는 동작을 원한다면 predicate가 얼마나 자주 복사되거나 호출되는지에 따라 동작이 바뀌는 함수 객체를 전달하면 안됩니다. 만약 두 개의 인자를 받아 그 두 인자가 같은지 비교하는 predicate를 사용한다면 항상 같은 결과를 낼 것입니다.

즉, predicate는 상태가 없어야만 합니다. 따라서, predicate는 호출된다고 해서 자신의 상태가 바뀌어서는 안되며 predicate의 복사본은 원본과 동일한 상태이어야 합니다. 이를 보장하기 위해서 () 연산자를 상수 멤버 함수로 선언하는 것이 좋습니다.

 

아니면 원하는 동작을 수행하는 커스텀 remove_if()를 구현하여 사용하면 됩니다.

template<typename _ForwardIterator, typename _Predicate>
_ForwardIterator
custom_remove_if(_ForwardIterator beg, _ForwardIterator end, _Predicate op)
{
  if (beg == end)
    return beg;
  
  _ForwardIterator __result = beg;
  for (; beg != end; ++beg) {
    if (!op(*beg)) {
      *__result = std::move(*beg);
      ++__result;
    }
  }
  return __result;
}

int main()
{
  list<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  PRINT(coll, "coll:        ");

  // remove thrid element
  auto pos = custom_remove_if(coll.begin(), coll.end(), Nth(3));
  coll.erase(pos, coll.end());

  PRINT(coll, "3rd removed: ");
}

 

Predefined Function Objects and Binders

C++ 표준 라이브러리는 많은 함수 객체와 바인더를 미리 정의해두었고, 이를 사용하여 더 정교한 함수 객체를 생성할 수 있습니다. 이 기능을 functional composition이라고 부르는데, 이는 기본적으로 함수 객체와 어답터를 필요로 합니다. 여기서는 이 둘에 대해서 살펴봅니다.

함수 객체와 바인더를 사용하려면 <functional> 헤더를 include 해주어야 합니다.

 

Predefined Function Objects

C++11 기준으로 미리 정의된 함수 객체는 위와 같습니다.

참고로 less<>는 객체가 정렬될 때, 혹은 정렬 함수와 연관 컨테이너에서 비교할 때 사용됩니다. 따라서 기본 정렬 기준은 항상 오름차순입니다. equal_to<>는 비정렬 컨테이너에서 사용되는 equivalence criterion 입니다.

 

Function Adapters and Binders

함수 어답터는 함수 객체를 다른 함수 객체나 특정 값 혹은 특수한 함수와 조합할 수 있도록 해주는 특별한 함수 객체입니다. C++98에서 제공했던 기능들은 C++11에서부터 폐기 예정(C++17에서 삭제)이며, C++11에서 조금 더 편리하고 유연한 어답터들이 도입되었는데, 이에 대해서 살펴보도록 하겠습니다.

C++11에서 도입된 함수 어답터는 위와 같습니다. 다만, not1과 not2는 C++17부터 폐기예정이고 C++20부터 삭제되므로 여기서 따로 다루지는 않겠습니다.

 

여기서 가장 중요한 어답터는 bind() 입니다. 이를 사용하면, 아래의 동작이 가능합니다.

  • 이미 존재하거나 미리 정의된 함수 객체를 수정하거나 조합하여 새로운 함수 객체를 생성할 수 있음
  • 전역 함수를 호출할 수 있음
  • 객체, 객체에 대한 포인터, 객체에 대한 스마트 포인터에 대한 멤버 함수를 호출할 수 있음

 

bind()

일반적으로 bind()는 callable 객체에 파라미터를 바인딩합니다. 따라서, 만약 함수, 멤버 함수, 함수 객체, 또는 람다가 어떤 파라미터를 원할 때, 그 파라미터를 명시된 혹은 전달된 인자로 바인딩할 수 있습니다. 전달된 인자의 경우, std::placeholders로 정의된 placeholder인 _1, _2, _3 등을 사용할 수 있습니다.

 

바인더에 대한 기본적인 예제는 다음과 같습니다.

#include <iostream>
#include <functional>

using namespace std;

int main()
{
  auto plus10 = bind(plus<int>(),
                     placeholders::_1,
                     10);
  cout << "+10:    " << plus10(7) << endl;

  auto plus10times2 = bind(multiplies<int>(),
                           bind(plus<int>(), placeholders::_1, 10),
                           2);
  cout << "+10 *2: " << plus10times2(7) << endl;

  auto pow3 = bind(multiplies<int>(),
                   bind(multiplies<int>(), placeholders::_1, placeholders::_1),
                   placeholders::_1);
  cout << "x*x*x:  " << pow3(7) << endl;

  auto inversDivide = bind(divides<double>(),
                           placeholders::_2,
                           placeholders::_1);
  cout << "invdiv: " << inversDivide(49, 7) << endl;
}

위 코드에서는 함수 객체를 나타내는 4개의 바인더가 정의되었습니다.

예를 들어, plus10의 정의는 다음과 같습니다.

auto plus10 = bind(plus<int>(),
                   placeholders::_1,
                   10);

위 표현식은 내부적으로 plus<>를 호출하되 placeholder _1을 첫 번째 파라미터로, 10을 두 번째 파라미터로 사용하는 함수 객체를 나타냅니다. _1은 표현식에 전달된 첫 번째 인자를 나타냅니다. 따라서, 이 표현식에 전달된 첫 번째 인자에 대해 함수 객체는 +10을 계산합니다.

 

바인더를 직접 호출할 수도 있습니다.

cout << bind(plus<int>(),placeholders::_1,10)(32) << endl;

 

나머지 바인더들은 조금 더 복잡한 함수 객체를 조합하기 위해서 바인더를 중첩하여 사용하는 방법을 보여줍니다. 사용 방법은 심플하기 때문에 따로 언급하지는 않겠습니다. 위 코드의 출력은 다음과 같습니다.

 

Calling Global Functions

아래 예제 코드는 bind()를 사용하여 전역 함수를 호출하는 방법을 보여줍니다.

#include <iostream>
#include <functional>
#include <algorithm>
#include <locale>
#include <string>

using namespace std;

char myToupper(char c)
{
  locale loc;
  return use_facet<ctype<char>>(loc).toupper(c);
}

int main()
{
  string s("Internationalization");
  string sub("Nation");
  // search substring case insentive
  auto pos = search(s.begin(), s.end(), sub.begin(), sub.end(),
                    bind(equal_to<char>(),
                         bind(myToupper,placeholders::_1),
                         bind(myToupper,placeholders::_2)));
  if (pos != s.end()) {
    cout << "\"" << sub << "\" is part of \"" << s << "\"\n";
  }
}

 

위 코드에서 myToupper()는 문자열의 모든 문자를 대문자로 바꿉니다.

참고로 bind()는 내부적으로 전달받은 인자를 복사합니다. 함수 객체가 인자를 레퍼런스로 전달받으려면 ref() 또는 cref()를 사용하면 됩니다.

 

Calling Member Functions

아래 예제 코드는 bind()를 사용하여 멤버 함수를 호출하는 방법을 보여줍니다.

#include <iostream>
#include <functional>
#include <algorithm>
#include <vector>
#include <string>

using namespace std;
using namespace std::placeholders;

class Person {
private:
  string name;

public:
  Person(const string& n) : name(n) {}
  void print() const {
    cout << name << endl;
  }

  void print2(const string& prefix) const {
    cout << prefix << name << endl;
  }
};

int main()
{
  vector<Person> coll = { Person("Tick"), Person("Trick"), Person("Track") };

  // call member function print() for each person
  for_each(coll.begin(), coll.end(), bind(&Person::print, _1));
  cout << endl;

  // call member function print2() with additional argument for each person
  for_each(coll.begin(), coll.end(), bind(&Person::print2,_1,"Person: "));
  cout << endl;

  // call print2() for temporary Person
  bind(&Person::print2, _1, "This is: ")(Person("nico"));
}

위 코드에서 아래의 표현식은 전달된 Person에 대해 param1.print()를 호출하는 함수 객체를 정의합니다.

bind(&Person::print,_1);

즉, 첫 번째 인자가 멤버 함수이므로 두 번째 인자는 이 멤버 함수가 호출될 객체를 정의합니다.

추가 인자를 받는 print2()를 호출하기 위해 다른 인자를 추가로 전달할 수도 있습니다. 따라서, 아래의 표현식은 전달된 Person에 대해 param1.print2("Person: ")를 호출하는 함수 객체를 정의합니다.

bind(&Person::print2,_1,"Person: ")

여기서 전달된 객체는 coll의 멤버이지만 원칙적으로 직접 객체를 전달할 수도 있습니다.

Person n("nico");
bind(&Person::print2,_1,"This is: ")(n);

위 표현식은 n.print2("This is: ")를 호출합니다.

 

위의 예제 코드의 출력은 다음과 같습니다.

 

상수 멤버 함수가 아닌 일반 멤버 함수나 가상 멤버 함수도 호출할 수 있습니다.

 

mem_fn()

멤버 함수에 대해서는 mem_fn() 어답터를 사용할 수 있습니다. 이를 사용하면, 멤버 함수가 호출될 객체에 대한 placeholder를 생략할 수 있습니다. 하지만, 함수 객체에 추가적인 인자를 바인딩하기 위해서는 또 다시 bind를 사용해야 합니다.

int main()
{
  vector<Person> coll = { Person("Tick"), Person("Trick"), Person("Track") };

  // call member function print() for each person
  for_each(coll.begin(), coll.end(), mem_fn(&Person::print));
  cout << endl;

  // call member function print2() with additional argument for each person
  for_each(coll.begin(), coll.end(),
           bind(mem_fn(&Person::print2), _1, "Person: "));
  cout << endl;

  // call print2() for temporary Person
  mem_fn(&Person::print2)(Person("nico"), "This is: ");
}

 

Binding to Data Member

데이터 멤버 역시 바인딩할 수 있습니다.

map<string, int> coll; // map of int values associated to strings
...
// accumulate all values (member second of the elements)
int sum = accumulate(coll.begin(), coll.end(),
                     0,
                     bind(plus<int>(), _1, bind(&map<string,int>::value_type::second,
                                                _2)));

위 예제 코드에서는 모든 요소의 값을 더하기 위해 binary predicate인 accumulate()를 사용합니다. 그러나, 요소가 key/value인 맵을 사용했기 때문에 요소의 값에 액세스해야 합니다. 위 코드의 마지막 bind는 predicate가 매번 호출될 때마다 전달된 두 번째 인자를 자신의 멤버 second에 바인딩합니다.

 

User-Defined Function Objects for Function Adapters

사용자 정의 함수 객체에 대해 바인더를 사용할 수도 있습니다. 아래 예제 코드는 첫 번째 인자를 두 번째 인자만큼 곱하는 함수 객체를 구현합니다.

#include <cmath>

template<typename T1, typename T2>
struct fopow
{
  T1 operator()(T1 base, T2 exp) const {
    return std::pow(base, exp);
  }
};

아래 코드는 위에서 정의한 함수 객체 fopow<>()를 사용하는 방법을 보여줍니다.

#include <iostream>
#include <functional>
#include <algorithm>
#include <vector>
#include <iterator>
#include <cmath>

using namespace std;
using namespace std::placeholders;

template<typename T1, typename T2>
struct fopow
{
  T1 operator()(T1 base, T2 exp) const {
    return std::pow(base, exp);
  }
};

int main()
{
  vector<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };

  // print 3 raised to the power of all elements
  transform(coll.begin(), coll.end(), ostream_iterator<float>(cout, " "),
            bind(fopow<float,int>(), 3, _1));
  cout << endl;

  // print all elements raized to the power of 3
  transform(coll.begin(), coll.end(), ostream_iterator<float>(cout, " "),
            bind(fopow<float,int>(), _1, 3));
  cout << endl;
}

 

Lambda

람다는 C++11에서 도입되었으며, 지역적으로 특정 기능을 제공하는 강력하고 편리한 방법입니다. 특히 알고리즘과 멤버 함수의 세부사항을 명시할 때 편리합니다. 람다는 알고리즘과 컨테이너 멤버 함수에 개별적인 행동을 직관적이고 이해하기 쉬운 방식으로 전달할 수 있는데, 알고리즘에 특정 행동을 전달하고 싶다면 동작을 원하는 장소에서 다른 함수처럼 명시해주면 됩니다.

 

bind()에 대해 언급할 때 살펴봤던 예제에서 람다로만 수정한 예제 코드들로 람다에 대해 살펴보도록 하겠습니다. 기본적인 람다 사용법에 대해서는 따로 언급하지 않으며, 필요하시다면 아래 포스팅 참조 바랍니다 !

[C++] Lambda Expression (람다 표현식)

 

[C++] Lambda Expression (람다 표현식)

References Professional C++ https://en.cppreference.com/w/ Contents Lambda Expression 람다 표현식(Lambda expression)이란 함수나 함수 객체를 별도로 정의하지 않고 필요한 지점에서 곧바로 함수를 직접 만들어 사용할

junstar92.tistory.com

 

Lambda versus Binders

bind()에서 봤었던 첫 번째 예제 코드(plus10, plus10times2 등)를 람다로 변경하면 다음과 같습니다.

#include <iostream>

using namespace std;

int main()
{
  auto plus10 = [](int i) { return i + 10; };
  cout << "+10:    " << plus10(7) << endl;

  auto plus10times2 = [](int i) { return (i+10)*2; };
  cout << "+10 *2: " << plus10times2(7) << endl;

  auto pow3 = [](int i) { return i*i*i; };
  cout << "x*x*x*: " << pow3(7) << endl;

  auto inversDivide = [](double d1, double d2) { return d2/d1; };
  cout << "invdiv: " << inversDivide(49, 7) << endl;
}

 

Lambdas versus Stateful Function Objects

이번에는 평균값을 계산하는 함수 객체 예제를 람다로 변경해보도록 하겠습니다.

#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int main()
{
  vector<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8 };
  
  // process and print mean value
  long sum = 0;
  for_each(coll.begin(), coll.end(),
           [&sum](int elem) {
            sum += elem;
           });
  double mv = static_cast<double>(sum) / static_cast<double>(coll.size());
  cout << "mean value: " << mv << endl;
}

여기서 계산한 값을 저장하는 곳은 람다 외부에 있는 sum 입니다. 따라서, 최종 평균값은 sum을 이용하여 계산할 수 있습니다. 함수 객체를 사용하면 sum이라는 상태를 완전히 캡슐화할 수 있다는 것을 위에서 살펴봤습니다. 따라서, 호출 방식에 따라 사용자 정의 함수 객체가 람다보다 코드를 더 압축시키고 에러를 덜 발생시킬 수 있다고 볼 수 있습니다.

 

상태를 다룰 때, mutable 사용에 주의해야 합니다. 아래 코드는 위에서 봤던 세 번째 요소를 찾아서 제거하는 코드입니다. 위에서는 함수 객체를 search criterion으로 전달했는데, 이것을 람다로 바꾸면 상태를 나타내는 내부 카운터는 알고리즘 외부에서 필요없기 때문에 값으로 전달해야 한다고 볼 수 있습니다. 이때 mutable을 사용하면 값으로 전달된 값(상태)에 write를 수행할 수 있습니다.

#include <iostream>
#include <algorithm>
#include <list>

using namespace std;

template<typename container>
void PRINT(container& c, string optstr = "")
{
  cout << optstr;

  for (auto pos = c.begin(); pos != c.end(); ++pos) {
    cout << *pos << " ";
  }
  cout << endl;
}

int main()
{
  list<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  PRINT(coll, "coll:        ");

  // remove third element
  int count = 0; // call counter
  auto pos = remove_if(coll.begin(), coll.end(),
                       [count](int) mutable {
                        return ++count == 3;
                       }
  );
  coll.erase(pos, coll.end());
  PRINT(coll, "3rd removed: ");
}

하지만, 위에서 봤던 문제가 여기서도 동일하게 발생합니다. 이는 람다 객체가 remove_if() 알고리즘이 실행되는 동안 복사되기 때문입니다. 따라서, 두 개의 람다 객체가 존재하게 되고 각각은 자신의 세 번째 요소를 삭제하려고 하기 때문에 상태가 중복되게 됩니다.

여기서 인자는 레퍼런스로 전달하고 mutable을 사용하지 않는다면 remove_if()가 내부적으로 사용하는 두 람다 객체가 같은 상태를 공유하기 때문에 원하는 대로 동작합니다.

int main()
{
  list<int> coll = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
  PRINT(coll, "coll:        ");

  // remove third element
  int count = 0; // call counter
  auto pos = remove_if(coll.begin(), coll.end(),
                       [&count](int) {
                        return ++count == 3;
                       }
  );
  coll.erase(pos, coll.end());
  PRINT(coll, "3rd removed: ");
}

 

Lambdas as Hash Function, Sorting, or Equivalence Criterion

람다는 hash, ordering, sorting criterion으로 사용할 수 있습니다. 예를 들면, 아래와 같습니다.

class Person {
  ...
};

auto hash = [](const Persont& p) {
  ...
};
auto eq = [](const Persont& p1, const Person& p2) {
  ...
};

// create unordered set with user-defined behavior
unordered_set<Person, decltype(hash), decltype(eq)> pset(10, hash, eq);

여기서 람다의 데이터 타입을 unordered_set으로 전달하기 때문에 decltype을 사용합니다.

댓글