38-1-라.함수 객체의 종류

함수 객체가 하는 일은 비교, 대입, 합산 등 알고리즘 구현중에 필요한 연산을 처리하는 것이라고 할 수 있다. 취하는 피연산자 개수로 연산자를 분류하듯이 함수 객체도 필요한 인수의 개수로 분류할 수 있으며 리턴값의 타입도 중요한 분류 기준이다. STL은 인수와 리턴값, 즉 원형에 따라 함수 객체를 다음과 같이 분류하고 고유의 이름을 부여한다.

 

인수의 개수

bool 아닌 리턴값

bool 리턴

없음

Gen

 

단항

UniOp

UniPred

이항

BinOp

BinPred

 

UniOp는 인수 하나를 취하는 단항 함수 객체이며 BinPred는 인수 둘을 취해 bool형을 리턴하는 조건자 함수 객체이다. 피연산자를 하나도 취하지 않는 함수 객체를 생성기(Generator)라고 하는데 입력없이 혼자 무엇인가를 만들어 내는 역할만 한다. 대표적으로 난수를 생성하는 함수 객체가 생성기이다. 함수 객체를 칭하는 이 표기만 보면 필요한 함수의 원형을 쉽게 유추할 수 있다.

알고리즘 함수들은 예외없이 템플릿 함수로 구현되어 있는데 함수 객체에 해당하는 템플릿 인수의 이름에 어떤 종류의 함수 객체가 요구되는지 표기된다. 마치 함수의 형식 인수 이름에 의미있는 이름을 붙여 유용한 정보를 표기하는 것과 같다. 앞에서 배운 몇 개의 알고리즘 함수 원형을 살펴보면 마지막 인수인 함수 객체에 이러한 정보가 포함되어 있다.

 

InIt find_if(InIt first, InIt last, UniPred F);

void sort(RanIt first, RanIt last, BinPred F);

T accumulate(InIt first, InIt last, T val, BinOp op);

 

find_if의 세 번째 인수는 UniPred로 되어 있으므로 인수 하나를 취하고 bool형을 리턴하는 단항 조건자임을 쉽게 알 수 있다. find_if와 함께 사용할 수 있는 함수 또는 함수 객체의 () 연산자 원형은 다음과 같을 것이다.

 

bool Pred(T &val) { }

 

여기서 T는 물론 검색 대상 컨테이너의 요소 타입이며 함수 호출문의 실인수 타입으로 구체화된다. 검색 대상인 val 인수는 값으로 받든 레퍼런스로 받든 함수 본체에서 val을 참조하는 구문에는 영향을 주지 않으므로 아무래도 상관없다. sort 함수는 두 개의 인수를 전달받아 두 인수를 비교한 후 bool형을 리턴하는 함수 객체를 요구하며 accumulate의 함수 객체는 두 인수를 전달받아 모종의 연산을 한다는 것을 알 수 있다.

만약 알고리즘 함수가 요구하는 원형과 다른 함수 객체를 인수로 전달하면 어떻게 될까? for_each 함수를 테스트하는 functor 예제의 print 함수 객체를 다음과 같이 수정해 보자. for_each는 단항 함수 객체(UniOp)를 요구하는데 에러를 유발시키기 위해 일부러 두 개의 인수를 받도록 했다.

 

struct print {

     void operator()(int a, int b) const {

          printf("%d\n",a);

     }

};

 

문법상의 문제는 없으므로 이 객체 정의문 자체는 에러가 아니다. 그러나 이 객체를 사용하는 곳에서 문제가 발생하는데 for_each의 본체에서, 즉 algorithm 헤더 파일에서 에러가 발생한다. for_each는 아마도 다음과 같이 구현되어 있을 것이다.

 

UniOp for_each(InIt first, InIt last, UniOp op)

{

     for (;first != last; ++first)

          op(*first);                    // 여기서 에러 발생

     return (op);

}

 

for_each는 구간을 순회하면서 매 요소마다 op 함수 객체를 호출하는데 인수는 현재 순회중인 반복자의 값 *first 하나밖에 없다. 하지만 이 값을 전달받는 객체의 () 연산자 함수와는 원형이 맞지 않으므로 호출할 수 없다는 컴파일 에러가 발생하는 것이다. 정확하게는 템플릿 함수가 구체화되는 과정의 템플릿 본체에서 구문 에러가 발생한다.

런타임 중에 발생하는 것이 아니라 컴파일중에 뭔가 잘못되었다는 것을 즉시 알 수 있으므로 위험하지는 않다. 이런 특성을 타입에 대한 안정성이라고 하는데 오동작할 소지가 있는 코드를 컴파일중에 명백한 에러로 처리하여 실생시의 버그를 최소화한다. 이번에는 다음과 같이 리턴값의 타입만 다르게 수정해 보자.

 

struct print {

     int operator()(int a) const {

          return printf("%d\n",a);

     }

};

 

for_each는 함수 객체를 호출하기만 할 뿐 리턴값을 요구하지는 않는다. 하지만 이렇게 수정해도 별 문제는 없다. 리턴값을 넘기더라도 for_each에서 이 값을 무시할 수 있고 for_each 템플릿의 본체와 충돌하는 부분이 없기 때문이다. 만약 템플릿 본체에서 리턴값을 명시적으로 요구할 때는 리턴값 타입도 항상 정확해야 한다. sortfunctor 예제의 compare 함수 객체를 다음과 같이 수정해 보자.

 

struct compare {

     void operator()(string a,string b) const {

          stricmp(a.c_str(),b.c_str()) < 0;

     }

};

 

이 함수 객체는 두 개의 정렬 대상을 전달받아 앞 뒤를 가려 주는 역할을 하므로 비교 결과를 반드시 리턴해야 하는데 void형으로 잘못 작성했다. 이렇게 되면 sort 템플릿 본체에서 비교 결과를 사용하는 부분에서 에러가 발생한다. sort의 내부에는 아마 다음과 같은 코드가 작성되어 있을 것이다. 물론 실제 코드는 컴파일러마다 다르다.

 

if (op(*first, *(first-1))

 

op 함수 객체로 두 요소를 넘겨 비교하도록 하고 그 결과에 따라 요소를 재배치해야 하는데 op의 결과가 없으므로 if문에 사용할 수 없는 것이다. compare 객체의 () 연산자가 int를 리턴하도록 수정하는 것은 가능하다. int는 bool형과 호환 타입이고 if문의 조건절로 사용될 수 있기 때문이다.

어떤 건 되고 어떤 건 안되고 함수 객체의 올바른 형태를 결정하는 것이 굉장히 어려운 규칙인 것 같지만 원칙은 지극히 간단하다. 템플릿의 타입은 본체의 모든 조건을 만족해야 한다는 동일한 알고리즘 조건이라는 것이 있는데 바로 이 원칙에만 맞게 작성하면 된다. for_each의 본체에 맞는 함수 객체이기만 하면 되고 sort가 구현하는 코드를 제대로 실행할 수 있으면 되는 것이다. 알고리즘의 목적과 동작 과정을 잘 생각해 보면 아주 상식적이다. 비교 함수는 bool을 리턴하는게 당연하고 for_each의 인수는 하나일 수밖에 없다.

만약 이 내용들이 헷갈린다면 C++ 템플릿의 정의와 특징, 그리고 컴파일 시에 임의의 타입에 대해 구체화된다는 것을 이해하지 못해서일 확률이 높다. 다음 예제의 for_each는 과연 어떤 타입의 함수 객체를 받아들이는지 생각해 보자. 이 문제를 확실히 이해하면 템플릿의 본질을 이해했다고 볼 수 있으며 앞으로 STL을 활용하는데 별 문제가 없을 것이다.

 

: dualinstance

#include <iostream>

#include <list>

#include <vector>

#include <algorithm>

using namespace std;

 

void functor1(int a)

{

     printf("%d ",a);

};

 

struct functor2 {

     void operator()(double a) const {

          printf("%f\n",a);

     }

};

 

void main()

{

     int ari[]={1,2,3,4,5};

     vector<int> vi(&ari[0],&ari[5]);

     double ard[]={1.2,3.4,5.6,7.8,9,9};

     list<double> ld(&ard[0],&ard[5]);

 

     for_each(vi.begin(),vi.end(),functor1);

     cout << endl;

     for_each(ld.begin(),ld.end(),functor2());

}

 

main에서 벡터와 리스트 두 개의 컨테이너를 정의하고 for_each를 두 번 호출하여 두 컨테이너의 내용을 출력했다. 이때 각각 다른 함수 객체를 사용했는데 첫 번째 for_each는 함수 포인터를, 두 번째 for_each는 함수 객체를 사용했다. 이 둘은 원형도 다르고 값을 출력하는 방식도 다르다. 실행 결과는 다음과 같다.

 

1 2 3 4 5

1.200000

3.400000

5.600000

7.800000

9.000000

 

그렇다면 for_each 함수의 세 번째 인수는 도대체 어떤 타입이라고 설명할 수 있을까? 예제가 잘 동작하는 걸 보면 void (*)(int) 타입의 함수를 받기도 하고 void(*)(double) 타입의 () 연산자가 정의된 객체를 받기도 한다. 가변 인수도 아닌 함수가 두 개의 다른 타입을 어떻게 받아들일 수 있는가 말이다.

이 문제의 해답은 간단하다. for_each는 함수가 아니라 함수를 만들 수 있는 템플릿일 뿐이며 호출부에서 전달되는 타입에 맞게 매번 구체화된다. 어떤 타입을 정해 놓고 받는게 아니라 들어오는대로 받아들여 구체화되는 것이다. 물론 전달된 타입은 템플릿 본체의 코드를 100% 지원하는 타입이어야 한다. 위 예에서 for_each 함수의 실체는 두 개 존재하며 각 버전이 받아들이는 타입이 다르다.

STL은 알고리즘이 어떤 함수를 호출할 것인지에 대한 모든 결정을 컴파일시에 수행한다. 조건만 맞다면 그게 함수건 객체건 가리지 않으며 그래서 일반적이라고 하는 것이다. 컴파일 타임에 모든 점검과 결정이 이루어지므로 컴파일 시간은 조금 더 걸리겠지만 실행시의 효율은 좋을 수밖에 없다.