C++ 이야기 열두번째: 함수 객체 #1

Posted at 2007. 6. 1. 08:25 // in S/W개발/C++ 이야기 // by 김윤수


C++ 이야기 열두번째입니다. 이번에도 역시 어떤 글을 쓸까 고민 고민했습니다. 갈수록 제 내공이 바닥나다 보니 퍼낼 밑천이 없어서... ㅎㅎㅎ 그러다가 C++ 표준 함수 객체부터 시작해서 Boost 에 포함되어 있는 lambda 템플릿을 소개하기로 맘을 먹었습니다. 왜냐구요 ? 그건 다음 글을 읽으면서 한 번 느껴보시죠.

그럼 제일 먼저 함수 객체가 무엇인지 알아 보죠. 이 글을 읽으시는 독자 대부분이 함수 객체가 무엇인지를 대강 아실듯 하지만... 그래도 기본을 다시 짚어 본다는 의미에서 설명해 보도록 하겠습니다.

'함수 객체란 무엇인가 ?' 그 이름에서 뜻을 유추해 본다면 함수처럼 작동하는 객체다라고 말씀드릴 수 있겠네요. 그럼 일단은 객체라고 했으니, class 나 struct 로 정의를 해야하는 것 같은데... 함수처럼 작동하게 하려면 어떻게 하면 될까요 ? 이 질문에 답을 할 수 있으려면 '함수처럼 작동한다'라는 것이 어떤 것을 뜻하는 것인지를 짚어 봐야 할 것입니다. C++에서 "함수!" 하면 가장 먼저 어떤 것이 떠오르시나요 ? 함수를 정의하는 것과 함수를 호출하는 것 이 두 가지 떠오르실 겁니다. (아니면 말구요 ^_^)

// 다음은 함수 정의
void foo()
{
  statements;
}

// 다음은 함수 호출
// C++에서는 어떤 식별자에 '()' 가 따라오면 함수 호출로 인식합니다.
foo();

그럼 다시 질문 들어갑니다. 함수처럼 작동하게 하려면 어떻게 하면 될까요 ? (자! 잠깐 글에서 눈을 떼시고 한 번 생각해 보세요~)
.
.
.
.
.
.
제가 힌트 하나 드릴까요 ? C++ 에서 '+', '-', '*', '<<' 등 대부분의 연산자를 재정의하실 수 있는 거 기억하시나요 ? 그걸 이용하시면 됩니다.
.
.
.
.
.
.
그래도 잘 모르시겠다구요 ? 그럼 하나더 힌트를 드리죠. 이거 가르쳐 드리면 거의 답을 가르쳐 드리는 건데... C++에서 '()' 가 함수 호출 연산자라는 거 아시나요 ?
.
.
.
.
.
.
아하! 그렇죠. 클래스 정의 안에서 함수 호출 연산자 '()'를 재정의하시면 되는 겁니다. 이해가 안되신다면 이론을 백번 설명하는 것보다 간단한 예가 훨씬 낫죠. 자~ 예를 한 번 들어볼까요 ? 다음은 합계와 평균을 구해주는 함수 객체입니다.

class Statistics {
private:
  int count;
  int sum;
  double average;

public:
  Statistics (): count(0), sum(0), average(0.0) {}

  // '()' 연산자를 재정의. 이게 핵심입니다.
  int operator () (int i) {
    sum += i;
    count++;
   
return sum;
  }

  int N() {
    return count;
  }

  int SUM() {
    return sum;
  }

  double AVG() {
    average = static_cast<double>(sum)/count;
    return average;
  }
};

이 함수 객체를 사용할 때는 다음과 같이 할 수 있습니다.

int
main(void)
{
  Statistics s;

 
 for (int i = 1; i <= 100; i++) {
    s(i);  // 함수 객체 호출. 그냥 함수 호출과 구분이 안되죠 ?
  }

 
cout << "sum = " << s.SUM() << ", avg = " << s.AVG() << endl;

 
return 0;
}

어때요 ? 함수 객체가 함수보다 좋다는 감이 오시나요 ? 전 젤 처음에 함수 객체라는 개념을 봤을 때 "요거 물건이네..."라는 감이 오더군요. 제가 생각하기에 함수 객체의 가장 큰 장점은 함수는 함수인데 상태를 가질 수 있는 함수이다라는 것이더군요. 위에 보시는 Statistics 의 예처럼 상태를 가질 수 있다는 특성 때문에 보통 함수보다는 할 수 있는 일이 훨씬 다양해 지게 되거든요. 거기에다가 함수가 쓰일 수 있는 곳이라면 어디에든 쓰일 수가 있게 되는 것이죠. 또 다른 예로, 함수 객체를 써서 구구단을 한 번 찍어 볼까요 ?

먼저 인자로 주어진 수에 생성시에 정해진 수를 곱하는 함수 객체를 작성해 보겠습니다.

class PrintTimes {
private:
  int _n;

public:
  PrintTimes(int n): _n(n) {}
  void operator() (int i) {
   cout << _n << " * " << i << " = " << i * _n << endl;
  }
};

이 함수를 써서 구구단을 출력하려면 다음과 같이 하면 되겠죠 ?

int
main(void)
{
  vector<int> v;

  for (int i = 1; i < 10; i++) {
    v.push_back(i);
  }

  for (vector<int>::iterator ii = ++v.begin(); // 2단부터 출력
      ii != v.end();
      ++ii) {
    cout << "<< " << *ii << "단 >>" << endl;
    // PrintTimes(*ii) 가 호출되면서 _n 이 차례로 2 ~ 9로 초기화됩니다.
    // 그리고 for_each() 알고리즘을 사용합니다.
    for_each(v.begin(), v.end(), PrintTimes(*ii));
  }

  return 0;
}



위 코드에서 설명한 대로 PrintTimes(*ii) 와 같이 호출하면 PrintTimes 함수 객체의 임시 객체가 생성되면서 _n 멤버 변수가 각 단계별로 2 ~ 9로 설정됩니다. 그러면, 구구단의 2단부터 9단까지를 for_each() 알고리즘을 사용해서 출력할 수 있게 되는 것이지요. 이런식으로 함수 객체는 실행시에 그 행동 방식을 바꿀 수가 있게 됩니다. 이번엔 다른 식으로 이 문제를 해결해 볼까요 ? 이번엔 PrintTimes 함수 객체를 둘로 나누어 m x n = mn 으로 찍어주는 함수 객체와 m 을 고정시켜 주는 객체로 나누어 보도록 하겠습니다. 다음과 같이 말이죠.

// m x n = mn 을 출력하는 함수 객체
struct PrintMultiplication {
  void operator() (int m, int n) {
    cout << m << " * " << n << " = " << m * n << endl;
  }
};

// m 을 고정시켜주는 객체
class Binder {
public:
  // 호출할 함수 객체와 고정할 인자를 기억해 놓습니다
  Binder(const PrintMultiplication& op, int arg): _op(op), _arg(arg) {}
  // 첫번째 인자를 고정된 값으로 호출합니다
  void operator() (int i) {
    _op(_arg, i);
  }

private:
  PrintMultiplication _op;    // 호출할 함수 객체
  int _arg;                   // 고정할 인자의 값
};

int
main(void)
{
  vector<int> v;

  for (int i = 1; i < 10; i++) {
    v.push_back(i);
  }

  for (vector<int>::iterator ii = ++v.begin(); // 2단부터 출력
      ii != v.end();
      ++ii) {
    cout << "<< " << *ii << "단 >>" << endl;
    // Binder() 가 호출되면서 PrintMultiplication() 함수 객체의 첫번째
    // 인자가 *ii 로 for_each() 알고리즘이 수행되는 동안 고정됩니다
    for_each(v.begin(), v.end(), Binder(PrintMultiplication(), *ii);
  }

  return 0;
}

이해가 되시나요 ? 위 코드만 보면 왜 괜히 PrintMultiplication 과 Binder 를 굳이 나누었을까가 이해되지 않겠지만 Binder를 템플릿화시키면 그 장점이 확연히 드러나게 됩니다. Binder 를 템플릿화시키면 다음과 같이 작성할 수 있을 것입니다.

template <class OP, typename ARG>
class Binder {
public:
  // 호출할 함수 객체와 고정할 인자를 기억해 놓습니다
  Binder(const OP& op, ARG arg): _op(op), _arg(arg) {}
  // 첫번째 인자를 고정된 값으로 호출합니다
  void operator() (int i) {
    _op(_arg, i);
  }

private:
  OP _op;                     // 호출할 함수 객체
  ARG _arg;                   // 고정할 인자의 값
}


위와 같이 작성하면 ARG 타입의 인자를 갖는 임의의 OP 타입 함수 객체에 대해 인자 binding이 가능하게 됩니다. 사실 C++ 표준 라이브러리에 정의된 bind1st, bind2nd 의 배경이 되는 아이디어도 위 코드에서 정의한 Binder 클래스와 정확히 일치합니다. 게다가 C++ 표준 라이브러리에서는 기본적인 연산에 대해서 이미 함수 객체로 정의해 놓고 있습니다. 다음은 미리 정의된 함수 객체들입니다.

표현식

효과

negate<type>()

- 인자

plus<type>()

인자1 + 인자2

minus<type>()

인자1 – 인자2

multiplies<type>()

인자1 * 인자2

divides<type>()

인자1 / 인자2

modules<type>()

인자1 % 인자2

equal_to<type>()

인자1 == 인자2

not_equal_to<type>()

인자1 != 인자2

less<type>()

인자1 < 인자2

greater<type>()

인자1 > 인자2

less_equal<type>()

인자1 <= 인자2

greater_equal<type>()

인자1 >= 인자2

logical_not<type>()

! 인자

logical_and<type>()

인자1 && 인자2

logical_or<type>()

인자1 || 인자

bind1st(op,value)

op(value, 인자)

bind2nd(op,value)

op(인자, value)

not1(op)

!op(인자)

not2(op)

!op(인자1,인자2)


이러한 함수 객체들을 사용하면 다양한 작업을 손쉽게 할 수 있습니다. 예를 들어, vector에 있는 모든 수에 3씩 더하고 싶다 그런 경우에는 transform() 알고리즘을 사용해서 다음과 같이 할 수 있습니다.

// 아래의 Print 템플릿 함수는 다음과 같이 정의되어 있습니다.
template <typename T>
struct Print {
  void operator() (T v) {
    cout << v << " ";
  }
};

for_each(v.begin(), v.end(), Print<int>());
cout << "\n";
transform(v.begin(), v.end(), v.begin(), bind2nd(plus<int>(), 3));
for_each(v.begin(), v.end(), Print<int>());
cout << "\n";

plus<int>() 함수 객체의 두번째 인자를 3으로 고정하여 원하는 결과를 얻어낼 수 있습니다. 다음은 위 코드를 실행한 결과입니다.

1 2 3 4 5 6 7 8 9
4 5 6 7 8 9 10 11 12

마지막으로 한 가지만 더 예를 들고 이번 글을 마칠까 합니다.

pos = find_if(v.begin(), v.end(), not1(bind2nd(modulus<int>(), 2)));

위 코드에서 bind2nd(modulus<int>(), 2) 는 2로 나눈 나머지를 리턴하게 되므로 홀수에 대해서는 1을 리턴하게 될 것입니다. 그리고, 그 결과에 not1이 수행되므로 짝수가 참이 되어 결국 위 코드는 첫번째 짝수 위치를 찾게 될 것입니다. 마지막으로 다음 두 가지를 기억해 두시면 좋을 것 같습니다. find_if() 알고리즘에 대한 것은 Alones님의 std::find_if 사용해보기 를 참고하세요

1. 함수 객체는 함수처럼 동작하는 객체이다.
2. 함수 객체는 '()' 연산자를 재정의해서 정의할 수 있다.

역시나 이번에도 생각보다 글이 많이 길어졌습니다. 항상 시작할 때는 간단하게 써야겠다고 다짐을 하는데도, 제 성격상 어쩔 수 없이 길어지나 봅니다. 너무 길어지면 지루할테니 이쯤에서 이번 글을 마무리하고 다음에도 함수 객체에 대해 못다한 이야기를 이어가렵니다. 혹시 틀린 부분이나 보완하고 싶으시 부분이 있으면 언제라도 알려주시고, 소프트웨어 관련된 저의 다른 글들도 참고로 읽어 보세요.