모두의 코드
씹어먹는 C ++ - <14. 함수를 객체로! (C++ std::function, std::mem_fn, std::bind)>

작성일 : 2019-02-24

이번 강좌에서는

에 대해 다룹니다.

안녕하세요 여러분! 지난 강좌에서 배우신 스마트 포인터 삼형제 (unique_ptr, shared_ptr, weak_ptr) 들은 어떠셨나요? 스마트 포인터를 도입함으로써 C++ 에서 메모리 제어를 훨씬 더 쉽게 수행할 수 있습니다.

이번 강좌에서는 C++ 에서 호출 가능한 모든 것을 포괄해서 나타내는 Callable 에 대해서 알아보도록 하겠습니다. 이번 강좌는 다음 강좌에서 쓰레드를 배우기 전에 짧게 거쳐가는 징검다리라고 보시면 됩니다.

Callable

Callable 이란, 이름 그대로 나타내듯이 호출(Call) 할 수 있는 모든 것을 의미합니다. 대표적인 예시로 함수를 들 수 있겠지요.

하지만 C++ 에서는 () 를 붙여서 호출할 수 있는 모든 것을 Callable 이라고 정의합니다. 예를 들어서

#include <iostream>
using namespace std;

struct S {
  void operator()(int a, int b) { cout << "a + b = " << a + b << endl; }
};

int main() {
  S some_obj;

  some_obj(3, 5);
}

성공적으로 컴파일 하였다면

a + b = 8

와 같이 나옵니다. 그렇다면 여기서 same_obj 는 함수 일까요? 아닙니다. 하지만 same_obj 클래스 S 의 객체이죠. 하지만, same_obj 는 마치 함수 처럼 () 를 이용해서 호출할 수 있습니다. (실제로는 same_obj.operator()(3, 5) 를 한 것이죠.)

또 다른 예시로 람다 함수를 생각해봅시다.

#include <iostream>
using namespace std;

int main() {
  auto f = [](int a, int b) { cout << "a + b = " << a + b << endl; };
  f(3, 5);
}

성공적으로 컴파일 하였다면

a + b = 8

와 같이 나옵니다. f 역시 일반적인 함수의 꼴을 하고 있지는 않지만, () 를 통해서 호출할 수 있기에 Callable 이라 할 수 있습니다.

std::function

C++ 에서는 이러한 Callable 들을 객체의 형태로 보관할 수 있는 std::function 이라는 클래스를 제공합니다. C 에서의 함수 포인터는 진짜 함수들만 보관할 수 있는 객체라고 볼 수 있다면 이 std::function 의 경우 함수 뿐만이 아니라 모든 Callable 들을 보관할 수 있는 객체 입니다.

std::function 을 어떻게 사용할 수 있는지 아래의 예시를 통해 보겠습니다.

#include <functional>
#include <iostream>
#include <string>
using namespace std;

int some_func1(const string& a) {
  cout << "Func1 호출! " << a << endl;
  return 0;
}

struct S {
  void operator()(char c) { cout << "Func2 호출! " << c << endl; }
};

int main() {
  function<int(const string&)> f1 = some_func1;
  function<void(char)> f2 = S();
  function<void()> f3 = []() { cout << "Func3 호출! " << endl; };

  f1("hello");
  f2('c');
  f3();
}

성공적으로 컴파일 하였다면

Func1 호출! hello
Func2 호출! c
Func3 호출! 

와 같이 나옵니다.

function<int(const string&)> f1 = some_func1;
function<void(char)> f2 = S();
function<void()> f3 = []() { cout << "Func3 호출! " << endl; };

일단 위와 같이 function 객체를 정의하는 부분부터 살펴봅시다. function 객체는 템플릿 인자로 전달 받을 함수의 타입을 갖게 됩니다. 여기서 함수의 타입이라 하면, 리턴값과 함수의 인자들을 말합니다.

따라서 예를 들어 some_func1 의 경우 int 를 리턴하며, 인자로 const string& 을 받기 때문에 위와 같이 function<int(const string&)> 의 형태로 정의 됩니다.

한편 Functor 인 클래스 S 의 객체의 경우 단순히 S 의 객체를 전달해도 이를 마치 함수 인양 받게 됩니다. S 의 경우 operator() 가 인자로 char 을 받고 리턴타입이 void 이므로 function<void<char)> 의 꼴로 표현할 수 있게 됩니다.

마지막으로 람다 함수의 경우 마찬가지로 리턴값이 없고 인자를 받지 않기 때문에 function<void()> 로 정의되겠지요.

이렇든 std::function 은 C++ 의 모든 Callable 을 마음대로 보관할 수 있는 유용한 객체 입니다. 만약에 함수 포인터로 이를 구현하려고 했다면 Functor 와 같은 경우를 성공적으로 보관할 수 없었겠지요.

멤버 함수를 가지는 std::function

앞서 function 은 일반적인 Callable 들을 쉽게 보관할 수 있었지만, 멤버 함수들의 경우 이야기가 조금 달라집니다. 왜냐하면, 멤버 함수 내에서 this 의 경우 자신을 호출한 객체를 의미하기 때문에, 만일 멤버 함수를 그냥 function 에 넣게 된다면 this 가 무엇인지 알 수 없는 문제가 발생하게 됩니다.

아래의 예시를 보실까요.

#include <functional>
#include <iostream>
#include <string>
using namespace std;

class A {
  int c;

 public:
  A(int c) : c(c) {}
  int some_func() { cout << "내부 데이터 : " << c << endl; }
};

int main() {
  A a(5);
  function<int()> f1 = a.some_func;
}

컴파일 한다면 아래와 같은 컴파일 오류가 나게 됩니다.

test2.cc: In function ‘int main()’:
test2.cc:17:26: error: invalid use of non-static member function ‘int A::some_func()’
   function<int()> f1 = a.some_func;
                        ~~^~~~~~~~~
test2.cc:10:9: note: declared here
     int some_func() {
         ^~~~~~~~~

왜냐하면 f1 을 호출하였을 때, 함수의 입장에서 자신을 호출하는 객체가 무엇인지 알 길이 없기 때문에 c 를 참조 하였을 때 어떤 객체의 c 인지를 알 수 없겠지요. 따라서 이 경우 f1a 에 관한 정보도 추가로 전달해야 합니다.

그렇다면 이를 어떻게 할까요? 사실 멤버 함수들은 구현 상 자신을 호출한 객체를 인자로 암묵적으로 받고 있었습니다.

따라서 이를 받는 function 은 아래와 같은 형태로 나타나야 합니다.

#include <functional>
#include <iostream>
#include <string>
using namespace std;

class A {
  int c;

 public:
  A(int c) : c(c) {}
  int some_func() {
    cout << "비상수 함수: " << ++c << endl;
    return c;
  }

  int some_const_function() const {
    cout << "상수 함수: " << c << endl;
    return c;
  }

  static void st() {}
};

int main() {
  A a(5);
  function<int(A&)> f1 = &A::some_func;
  function<int(const A&)> f2 = &A::some_const_function;

  f1(a);
  f2(a);
}

성공적으로 컴파일 하였다면

비상수 함수: 6
상수 함수: 6

와 같이 나옵니다.

function<int(A&)> f1 = &A::some_func;
function<int(const A&)> f2 = &A::some_const_function;

위와 같이 원래 인자에 추가적으로 객체를 받는 인자를 전달해주면 됩니다. 이 때 상수 함수의 경우 당연히 상수 형태로 인자를 받아야 하고 (const A&), 반면에 상수 함수가 아닌 경우 단순히 A& 의 형태로 인자를 받으면 되겠습니다.

참고로 이전의 함수들과는 다르게 &A::some_func 와 같이 함수의 이름 만으로는 그 주소값을 전달할 수 없습니다. 이는 C++ 언어 규칙에 때문에 그런데, 멤버 함수가 아닌 모든 함수들의 경우 함수의 이름이 함수의 주소값으로 암시적 변환이 일어나지만, 멤버 함수들의 경우 암시적 변환이 발생하지 않으므로 & 연산자를 통해 명시적으로 주소값을 전달해줘야 합니다.

따라서 아래와 같이 호출하고자 하는 객체를 인자로 전달해주면 마치 해당 객체의 멤버 함수를 호출한 것과 같은 효과를 낼 수 있습니다.

f1(a);
f2(a);

위와 같이 말이지요.

멤버 함수들을 함수 객체로 - mem_fn

예를 들어서 vector 들을 가지는 vector 가 있을 때, 각각의 vector 들의 크기들을 벡터로 만들어주는 코드를 생각해봅시다.

#include <algorithm>
#include <functional>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

int main() {
  vector<int> a(1);
  vector<int> b(2);
  vector<int> c(3);
  vector<int> d(4);

  vector<vector<int>> container;
  container.push_back(b);
  container.push_back(d);
  container.push_back(a);
  container.push_back(c);

  vector<int> size_vec(4);
  transform(container.begin(), container.end(), size_vec.begin(),
            &vector<int>::size);
  for (auto itr = size_vec.begin(); itr != size_vec.end(); itr++) {
    cout << "벡터 크기 :: " << *itr << endl;
  }
}

transform 함수는 <algorithm> 라이브러리에 있는 함수인데, 각 원소들에 대해 인자로 전달된 함수를 실행시킨 다음 그 결과를 전달된 컨테이너에 넣어줍니다. 함수 정의를 살짝 살펴보면 아래와 같습니다.

template <class InputIt, class OutputIt, class UnaryOperation>
OutputIt transform(InputIt first1, InputIt last1, OutputIt d_first,
                   UnaryOperation unary_op) {
  while (first1 != last1) {
    *d_first++ = unary_op(*first1);
    first1++;
  }
  return d_first;
}

여기서 문제는 해당 함수를 아래와 같이 호출한다는 점입니다.

*d_first++ = unary_op(*first1);

unary_op 가 멤버 함수가 아닐 경우 위와 같이 호출해도 괜찮습니다. 하지만 문제는 unary_op 가 멤버함수 일 경우 입니다.

사실 위 코드를 컴파일 하면 아래와 같은 컴파일 오류가 나게 됩니다.

In file included from /usr/include/c++/7/algorithm:62:0,
                 from test2.cc:5:
/usr/include/c++/7/bits/stl_algo.h: In instantiation of ‘_OIter std::transform(_IIter, _IIter, _OIter, _UnaryOperation) [with _IIter = __gnu_cxx::__normal_iterator<std::vector<int>*, std::vector<std::vector<int> > >; _OIter = __gnu_cxx::__normal_iterator<int*, std::vector<int> >; _UnaryOperation = long unsigned int (std::vector<int>::*)() const noexcept]’:
test2.cc:21:85:   required from here
/usr/include/c++/7/bits/stl_algo.h:4306:24: error: must use ‘.*’ or ‘->*’ to call pointer-to-member function in ‘__unary_op (...)’, e.g. ‘(... ->* __unary_op) (...)’
  *__result = __unary_op(*__first);
              ~~~~~~~~~~^~~~~~~~~~

왜 그럴까요? 이 역시 전달된 size 함수가 멤버 함수여서 발생하는 문제 입니다. 위 템플릿에 &vector<int>::size 가 들어간다면 해당 unary_op 를 호출하는 부분은 아래와 같이 변환됩니다.

unary_op(*first1);

&vector<int>::size(*first);

꼴로 되는데, 멤버 함수의 경우

(*first).(*&vector<int>::size)

혹은

first->(*&vector<int>::size)

와 같이 호출해야 하기 때문 입니다. (이는 C++ 의 규칙 이라 생각하시면 됩니다. 위 컴파일러 오류 메세지를 읽어보세요!) 따라서 이를 위해서는 제대로 std::function 으로 변환해서 전달해줘야 합니다.

#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
using namespace std;

int main() {
  vector<int> a(1);
  vector<int> b(2);
  vector<int> c(3);
  vector<int> d(4);

  vector<vector<int>> container;
  container.push_back(a);
  container.push_back(b);
  container.push_back(c);
  container.push_back(d);

  function<size_t(const vector<int>&)> sz_func = &vector<int>::size;

  vector<int> size_vec(4);
  transform(container.begin(), container.end(), size_vec.begin(), sz_func);
  for (auto itr = size_vec.begin(); itr != size_vec.end(); itr++) {
    cout << "벡터 크기 :: " << *itr << endl;
  }
}

성공적으로 컴파일 했다면

벡터 크기 :: 1
벡터 크기 :: 2
벡터 크기 :: 3
벡터 크기 :: 4

와 같이 잘 나옴을 알 수 있습니다.

하지만 매번 위 처럼 function 객체를 따로 만들어서 전달하는 것은 매우 귀찮습니다. 따라서 C++ 개발자들은 라이브러리에 위 function 객체를 리턴해버리는 함수를 추가하였습니다.

#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
using namespace std;

int main() {
  vector<int> a(1);
  vector<int> b(2);
  vector<int> c(3);
  vector<int> d(4);

  vector<vector<int>> container;
  container.push_back(a);
  container.push_back(b);
  container.push_back(c);
  container.push_back(d);

  vector<int> size_vec(4);
  transform(container.begin(), container.end(), size_vec.begin(),
            mem_fn(&vector<int>::size));
  for (auto itr = size_vec.begin(); itr != size_vec.end(); itr++) {
    cout << "벡터 크기 :: " << *itr << endl;
  }
}

성공적으로 컴파일 하였다면

벡터 크기 :: 1
벡터 크기 :: 2
벡터 크기 :: 3
벡터 크기 :: 4

와 같이 잘 나옵니다. mem_fn 함수는 이름 그대로, 전달된 멤버 함수를 function 객체로 예쁘게 만들어서 리턴해줍니다.

std::bind

재미있게도 함수 객체 생성 시에 인자를 특정한 것으로 지정할 수 도 있습니다. 아래 예제를 보실까요.

#include <functional>
#include <iostream>
using namespace std;

void add(int x, int y) { cout << x << " + " << y << " = " << x + y << endl; }

void subtract(int x, int y) {
  cout << x << " - " << y << " = " << x - y << endl;
}
int main() {
  auto add_with_2 = bind(add, 2, placeholders::_1);
  add_with_2(3);

  // 두 번째 인자는 무시된다.
  add_with_2(3, 4);

  auto subtract_from_2 = bind(subtract, placeholders::_1, 2);
  auto negate = bind(subtract, placeholders::_2, placeholders::_1);

  subtract_from_2(3);  // 3 - 2 를 계산한다.
  negate(4, 2);        // 2 - 4 를 계산한다
}

성공적으로 컴파일 하였다면

2 + 3 = 5
2 + 3 = 5
3 - 2 = 1
2 - 4 = -2

와 같이 나옵니다.

bind 함수는 이름 그대로 원래 함수에 특정 인자를 붙여(bind) 줍니다. 예를 들어서

bind(add, 2, placeholders::_1);

위 예시의 경우 add 라는 함수에 첫 번째 인자로 2 를 bind 시켜주고, 두 번째 인자로는 새롭게 만들어진 함수 객체의 첫 번째 인자를 전달해줍니다. 따라서;

add_with_2(3);

를 하였을 때, 원래 add 함수의 첫 번째 인자로는 2 가 들어가게 되고, 두 번째 인자로는 add_with_2 의 첫 번째 인자인 3 이 들어가겠지요. 만약에

add_with_2(3, 4);

처럼 인자를 여러개 전달하더라도 뒤에 것들은 무시 됩니다.

auto negate = bind(subtract, placeholders::_2, placeholders::_1);

위 경우는 어떨까요? negate 함수는 첫 번째 인자와 두 번째 인자의 순서를 바꿔서 subtract 함수를 호출하게 됩니다. 즉 negate(3, 5) 를 호출할 경우 실제로는 subtract(5, 3) 이 호출되겠지요.

placeholders_1, _2 들은 일일히 정의된 객체들 입니다. 그 개수는 라이브러리 마다 다른데, libstdc++ 의 경우 (g++ 에서 사용하는 C++ 라이브러리 입니다.) _1 부터 _29 까지 정의되어 있습니다.

한 가지 주의할 점은, 레퍼런스를 인자로 받는 함수들의 경우 입니다.

#include <functional>
#include <iostream>
#include <string>
using namespace std;

struct S {
  int data;
  S(int data) : data(data) { cout << "일반 생성자 호출!" << endl; }
  S(const S& s) {
    cout << "복사 생성자 호출!" << endl;
    data = s.data;
  }

  S(S&& s) {
    cout << "이동 생성자 호출!" << endl;
    data = s.data;
  }
};

void do_something(S& s1, const S& s2) { s1.data = s2.data + 3; }

int main() {
  S s1(1), s2(2);

  cout << "Before : " << s1.data << endl;

  // s1 이 그대로 전달된 것이 아니라 s1 의 복사본이 전달됨!
  auto do_something_with_s1 = bind(do_something, s1, placeholders::_1);
  do_something_with_s1(s2);

  cout << "After :: " << s1.data << endl;
}

성공적으로 컴파일 하였다면

일반 생성자 호출!
일반 생성자 호출!
Before : 1
복사 생성자 호출!
After :: 1

와 같이 나옵니다.

보시다싶이 do_something 함수의 경우 첫 번째 인자의 data 를 두 번째 인자의 data + 3 으로 만들어주지만, 실제로 do_something_with_s1 함수를 실행하였을 때 첫 번째 인자로 s1 을 전달했음에도 불구하고 s1data 가 바뀌지 않음을 알 수 있습니다.

그 이유는 위 생성자 호출 메세지에서 확인할 수 있듯이 bind 함수로 인자가 복사 되서 전달되기 때문입니다. 따라서 이를 해결 하기 위해서는 명시적으로 s1 의 레퍼런스를 전달해줘야 합니다.

#include <functional>
#include <iostream>
#include <string>
using namespace std;

struct S {
  int data;
  S(int data) : data(data) { cout << "일반 생성자 호출!" << endl; }
  S(const S& s) {
    cout << "복사 생성자 호출!" << endl;
    data = s.data;
  }

  S(S&& s) {
    cout << "이동 생성자 호출!" << endl;
    data = s.data;
  }
};

void do_something(S& s1, const S& s2) { s1.data = s2.data + 3; }

int main() {
  S s1(1), s2(2);

  cout << "Before : " << s1.data << endl;

  // s1 이 그대로 전달된 것이 아니라 s1 의 복사본이 전달됨!
  auto do_something_with_s1 = bind(do_something, ref(s1), placeholders::_1);
  do_something_with_s1(s2);

  cout << "After :: " << s1.data << endl;
}

성공적으로 컴파일 하였다면

일반 생성자 호출!
일반 생성자 호출!
Before : 1
After :: 5

와 같이 실제로 s1 의 값이 잘 바뀌었음을 알 수 있습니다. ref 함수는 전달받은 인자를 복사 가능한 레퍼런스 로 변환해줍니다. 따라서 bind 함수 안으로 s1 의 레퍼런스가 잘 전달 될 수 있게 됩니다.

참고로 const 레퍼런스의 경우 cref 함수를 호출하면 됩니다.

그럼 이것으로 이번 강좌를 마치도록 하겠습니다. function, mem_fn, bind 들을 적재 적소에 잘 쓴다면, C++ 의 강력한 라이브러리를 좀 더 풍요롭게 사용할 수 있을 것입니다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <14. 함수를 객체로! (C++ std::function, std::mem_fn, std::bind)> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
프로필 사진 없음
댓글에 글쓴이에게 큰 힘이 됩니다