책정리/혼자 연구하는 C,C++ 2

38장 함수 객체

GONII 2015. 3. 13. 13:39

38-1 함수 객체

가. 함수 객체

STL의 알고리즘들은 전역 함수가 처리하여 문제를 풀기 위한 반복자 구간, 검색 대상, 채울 값 따위의 정보들이 함수의 인수로 전달된다. 알고리즘 함수들은 입력된 정보를 바탕으로 알아서 동작하지만 어떤 함수들은 내부에서 모든 동작을 다 처리하지 않거나 할 수 없는 경우도 있다. 검색하고자 하는 값이 정확하게 어떤 조건인지, 정렬을 위해 요소를 비교할 때 어떤 방식으로 비교할 것인질르 함수가 마음대로 결정할 수 없다.

이럴때 함수에게 좀 더 구체적인 처리 방식을 지정하기 위해 사용자가 미리 만들어 놓은 함수 객체를 전달한다. 똑같은 함수를 호출하더라도 함수 객체를 어떻게 작성하는가에 따라 알고리즘의 활용도가 대폭 향상되는 효과가 있다.

   

for_each 함수를 사용하면 순회를 대신 시킬 수 있으며 이때 순회 중에 어떤 작업을 할 것인가를 함수 객체로 지정한다.

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

  • 예제 for_each

#include <iostream>

#include <vector>

#include <algorithm>

using namespace std;

   

void print(int a)

{

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

}

   

void main()

{

int ar[] = { 2,8,5,1,9 };

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

   

sort( vi.begin(), vi.end() );

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

}

이 예제에서 for_each 함수의 세 번째 인수로 전달된 대상을 함수 객체(Function Object) 또는 펑크터(Functor)라고 하는데 예제에서는 함수 포인터를 넘겼다.

STL의 함수 객체는 함수 포인터에만 국한되는 것이 아니라 함수를 흉내낼 수 있는 모든 객체일 수 있다는 점이 다르다. 함수 객체는 함수 호출 연산자인 ()를 오버로딩한 객체를 의미하는데 이 연산자를 통해 마치 함수를 호출하듯이 객체를 호출할 수 있다.

  • 예제 functor

#include <iostream>

#include <vector>

#include <algorithm>

using namespace std;

   

struct print

{

void operator()(int a) const

{

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

}

};

   

void main()

{

int ar[] = { 2,8,5,1,9 };

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

   

sort( vi.begin(), vi.end() );

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

}

예ㅖ제에서 print()문은 print객체를 호출하는 것이 아니라 print의 임시 객체를 생성하는 문장이다.

for_each는 반복자 순서에 맞게 순회만 할 뿐이며 실제 작업은 함수 객체가 한다. 순회 중에 어떤 일을 할 것인가는 함수 객체가 인수로 전달받은 요소에 대해 무슨 일을 하는가에 따라 달라진다.

함수 객체는 클래스 안에 함수를 캡슐화 해 놓은 것으로 함수 포인터에 대한 일반화라고 할 수 있다. STL이 함수 포인터를 확장하여 함수 객체라는 더 일반화된 개념을 사용하는 이유는 함수 포인터에 비해 몇 가지 장점이 있고 더 유연하기 때문이다.

  1. 함수 객체는 인라인이 가능해서 처리 속도를 대폭적으로 개선할 수 있다. 클래스 내부에 선언된 멤버 함수는 자동으로 인라인이 되며 호출부에 본체의 코드가 직접 삽입된다. 그래서 함수 호출에 대한 부담이 전혀 없으며 알고리즘 처리가 빠르다.
  2. 함수 객체는 말 그대로 객체이기 때문에 함수 연산자 ()뿐만 아니라 처리에 필요한 멤버들을 추가로 더 가질 수도 있다. 연산 중에 필요한 변수가 있으면 멤버로 만들 수 있고 필요한 동작이 있다면 멤버 함수도 가질 수 있다.
  • 예제 functormem

#include <iostream>

#include <vector>

#include <algorithm>

using namespace std;

   

struct accum

{

int sum;

accum() { sum = 0 ; }

void operator()(int a)

{

sum += a;

}

};

   

void main()

{

int ar[] = { 2,8,5,1,9 };

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

   

sort( vi.begin(), vi.end() );

accum f;

f = for_each(vi.begin(), vi.end(), f);

printf("합 = %d\n", f.sum);

}

()연산자는 인수로 전달된 a를 sum에 계속 누적시킨다. for_each는 f를 사용한 후 다시 리턴하는데 이 값을 대입 받으면 f.sum에는 순회 중에 합산한 결과가 들어 있을 것이다.

  1. 멤버뿐만 아니라 멤버 함수도 가질 수 있으며 생성자와 소멸자도 활용할 수 있다.
  • 예제 functorctor

#include <iostream>

#include <string>

#include <vector>

#include <algorithm>

using namespace std;

   

struct print

{

string mes;

print(string &m) : mes(m) {}

void operator() (int a) const

{

cout << mes;

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

}

};

   

void main()

{

int ar[] = { 2,8,5,1,9 };

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

   

sort( vi.begin(), vi.end() );

for_each(vi.begin(), vi.end(), print(string("요소값은")));

for_each(vi.begin(), vi.end(), print(string("다른 메세지 ")));

}

  1. 함수 객체는 타입이므로 템플릿의 인수로 사용될 수 있지만 함수 포인터는 단순한 값일 뿐이므로 템플릿의 인수로는 상요할 수 없다. 템플릿의 인수로 사용될 수 있으므로 컨테이너가 함수 객체를 소유할 수 있다.
  • 예제 functorpara

#include <iostream>

using namespace std;

   

template <typename T>

class some {};

   

struct print

{

void operator()(int a) const

{

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

}

};

   

void func(int a)

{

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

}

   

void main()

{

some<print> s1;                // 가능

//some<func> s2;        // 불가능

}

나. 알고리즘의 변형

컨테이너에서 값을 검색하는 find는 순회 중의 반복자 값과 세 번째 인수로 지정한 값을 == 연산자로 비교하여 정확하게 일치하는 요소를 찾아낸다. 그런데 사용자가 정의하는 방식으로 검색할 요소를 골라야 하는 경우도 있다. find의 함수 객체 버전음 다음과 같다.

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

세번째 인수 f는 () 연산자를 오버로딩하는 함수 객체이며 요소 값 하나를 인수로 전달받아 이 값이 원하는 조건이 맞는지 검사하여 bool형을 리턴한다.

  • 예제 find_if

#include <iostream>

#include <string>

#include <vector>

#include <algorithm>

using namespace std;

   

struct IsKim

{

bool operator()(string name) const

{

return (strncmp(name.c_str(),"김", 2) == 0);

}

};

   

void main()

{

string names[] = { "김유신","이순신","성삼문","장보고","조광조",

"신숙주","김홍도","정도전","이성계","정몽주"};

vector<string> vs(&names[0], &names[10]);

   

vector<string>::iterator it;

it = find_if(vs.begin(), vs.end(), IsKim());

if( it == vs.end() )

{

cout << "없다" << endl;

}

else

{

cout << "있다" << endl;

}

}

함수 객체로 만들 때와 함수 포인터를 쓸 때 인수의 형태가 조금 달라진다. 함수 포인터는 레퍼런스를 전달받는 것이 좋은데 왜냐하면 string 같이 덩치가 큰 객체를 전달할 때 값으로 받으면 복사가 발생하며 이 비용이 무시할 수 없을 정도로 커질 수 있기 때문이다. 반면 함수 객체는 크기에 상관없이 값으로 전달받아야 하는데 왜냐하면 인라인으로 삽입되기 때문에 인수 전달 과정이 필요없기 때문이다.

함수 객체는 고정된 의미를 가지는 알고리즘에 유연성을 부여하여 활용도를 대폭적으로 향상시킨다. 비교 조건을 직접 작성할 수 있으므로 정확하게 같은 것만 검색하는 것이 아니라 사용자가 원하는 어떤 조건으로도 검색할 수 있다.

find와 find_if 함수를 오버로딩 하지 않은 이유는 두 함수의 선언문이 사실상 동일하기 때문이다.

InIt find(InIt first, InIt last, const T& val);

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

세번째 인수의 타입이 달라 언뜻 보기에는 오버로딩 조건을 만족하는 것 같다. 그러나 실제로는 구분되지 않는데 왜냐하면 둘 다 템플릿이기 때문이다. 오버로딩은 함수 호출시에 실인수의 타입을 보고 결정되지만 템플릿 구체화는 그보다 훨씬 이전인 컴파일할 때 일어나므로 구체화할 템플릿을 먼저 선택할 수 있어야 한다. 똑같은 이름으로 두 개의 템플릿이 정의되어 있으면 컴파일러는 도대체 어떤 것을 참조하여 구체화해야 하는지 결정할 수 없는 것이다.

다. 미리 정의된 함수 객체

함수 객체는 통상 ( ) 연산자 하나만 정의하고 그 나마도 동작이 간단해 길이가 아주 짧다. 그래서 STL은 자주 사용할 만한 연산에 대해 미리 함수 객체를 정의하고 있다. 이런 객체들은 별다른 정의없이 그냥 사용하기만 하면 된다. 대표적으로 가장 간단한 함수 객체인 plus를 보자.

struct : public binary_function<T, T, T> {

T operator()(const T& x, const T& y)const {return {x+y}; }

}

  • 예제 plus

#include <iostream>

#include <functional>

   

using namespace std;

   

void main()

{

int a = 1, b = 2;

int c = plus<int>()(a,b);

cout << c << endl;

}

함수 객체와 그 지원 매크로, 타입 등은 모두 functional 헤더 파일에 정의되어 있으므로 이 헤더 파일을 인클루드해야 한다. plus 외에도 많은 함수 객체들이 미리 정의되어 있다.

함수 객체

연산

minus

두 인수의 차를 계산

multiplies

두 인수의 곱을 계산

divides

두 인수를 나누 후 몫을 리턴

modulus

두 인수를 나눈 후 나머지를 리턴

negate

인수 하나를 전달받아 부호를 반대로 만듬

equal_to

두 인수가 같은지 비교하여 결과를 bool 타입으로 리턴

not_equal_to

두 인수가 다른지 비교

greater

첫 번째 인수가 두 번째 인수보다 큰지 조사

less

첫 번째 인수가 두번째 인수보다 작은지 조사

greater_equal

첫 번째 인수가 두 번째 인수보다 크거나 같은지 조사

less_equal

첫 번째 인수가 두 번째 인수보다 작거나 같은지 조사

logical_and

두 인수의 논리곱(&&) 결과를 리턴

logical_or

두 인수의 논리합(||) 결과를 리턴

logical_not

인수 하나를 전달받아 논리 부정(!)을 리턴

sort 함수는 요소의 < 연산자로 대소를 비교하므로 기본적으로 오름차순으로 정렬하는데 함수 객체를 취하는 다음 버전을 사용하면 정렬 순서를 원하는 대로 지정할 수 있다.

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

  • 예제 sortdesc

#include <iostream>

#include <string>

#include <vector>

#include <algorithm>

#include <functional>

using namespace std;

   

void main()

{

string names[] = {

"STL", "MF", "owl", "html", "pascal", "Ada", "Delphi", "C/C++", "Python", "basic"

};

vector<string> vs(&names[0], &names[10]);

   

//sort(vs.begin(), vs.end());

sort(vs.begin(), vs.end(), greater<string>());

   

vector<string>::iterator it;

for( it = vs.begin() ; it != vs.end() ; it++ )

{

cout << *it << endl;

}

}

만약 미리 제공되는 함수 객체가 아니라 사용자가 정의한 방식대로 정렬하고 싶다면 직접 함수 객체를 만들어 sort의 세번째 인수로 전달한다.

  • 예제 sortfunctor

#include <iostream>

#include <string>

#include <vector>

#include <algorithm>

using namespace std;

   

struct compare

{

bool operator()(string a, string b) const

{

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

}

};

   

void main()

{

string names[] = {

"STL", "MF", "owl", "html", "pascal", "Ada", "Delphi", "C/C++", "Python", "basic"

};

vector<string> vs(&names[0], &names[10]);

   

//sort(vs.begin(), vs.end());

sort(vs.begin(), vs.end(), compare());

   

vector<string>::iterator it;

for( it = vs.begin() ; it != vs.end() ; it++ )

{

cout << *it << endl;

}

}

비교 구문이 인라인으로 삽입되어 정렬 속도도 굉장히 빠르다.

라. 함수 객체의 종류

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

인수의 개수

bool이 아닌 리턴값

bool 리턴

없음

Gen

  

단항

UniOp

UniPred

이항

BinOp

BinPred

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

만약 알고리즘 함수가 요구하는 원형과 다른 함수 객체를 인수로 전달하면 어떻게 될까? 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의 본체에 맞는 함수 객체이기만 하면 되고 sort가 구현하는 코드를 제대로 실행할 수 있으면 되는 것이다. 알고리즘의 목적과 동작 과정을 잘 생각해 보면 아주 상식적이다. 비교 함수는 bool을 리턴하는게 당연하고 for_each의 인수는 하나일 수밖에 없다.

  • 예제 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());

}

void(*)(int)타입의 함수를 받기도 하고 void(*)(double) 타입의 ( ) 연산자가 정의된 객체를 받기도 한다. 가변 인수도 아닌 함수가 두 개의 다른 타입을 어떻게 받아들일 수 있을까?? for_each는 함수가 아니라 함수를 만들 수 있는 템플릿일 뿐이며 호출부에서 전달되는 타입에 맞게 매번 구체화된다. 어떤 타입을 정해 놓고 받는게 아니라 들어오는 대로 받아들여 구체화되는 것이다. 물론 전달된 타입은 템플릿 본체의 코드를 100% 지원하는 타입이어야 한다.

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

38-2 어댑터

가. 어댑터

어댑터(Adapter)란 이미 만들어진 컴포넌트의 구현은 그대로 활용하고 인터페이스만 조금 변경하여 컴포넌트를 일부 변형시키는 것이다. 어댑터는 컴포넌트를 조금씩 변형함으로써 활용도를 높인다. 새로 만들고자 하는 부품이 이미 만들어진 부품의 기능 중 일부만을 필요로 할 경우 처음부터 새로 만들 필요 없이 기존 부품을 변형해서 사용하면 훨씬 더 빠르고 간편하다. 없는 기능을 만들 수는 없지만 있는 기능의 일부를 막아 버린다거나 고정하는 것은 가능하다.

비록 STL이 제공하는 컴포넌트의 수가 많고 일반화되어 있기는 하지만 그래도 특수한 프로그래밍 환경에 두루 사용하기에는 결코 충분하지 않다. 그렇다고 컴포넌트의 수를 무한정 늘리기만 할 수는 없으므로 기존 컴포넌트를 변형할 수 있는 어댑터라는 방법을 제공한다. 어댑터는 일반화된 컴포넌트의 용도를 더욱 확장하는 역할을 한다. 어댑터는 컴포넌트, 반복자, 함수 객체에 대해 적용되며 다음과 같이 분류할 수 있다.

함수 객체의 기능을 조금이라도 변경하려면 어댑터를 적용할 수 있도록 만들어야 하는데 이런 함수 객체를 어댑터블(Adaptable) 함수 객체라고 한다. 기능을 변경하는 어댑터는 대상 함수 객체가 취하는 인수의 타입은 무엇인지, 리턴 타입은 무엇인지 등 함수 객체에 대한 충분한 정보를 얻을 수 있어야 한다. 만드는 방법은 아주 쉬운데 functional 헤더 파일에 정의되어 있는 다음 두 템플릿 클래스 중 하나를 상속받으면 된다.

template<class Arg, class Result>

struct unary_function

{

typedef Arg argument_type;

typedef Result result_type;

};

template<class Arg1, class Arg2, class Result>

struct binary_function

{

typedef Arg1 first_argument_type;

typedef Arg2 second_argument_type;

typedef Result result_type;

};

인수의 개수에 따라 단항 함수 객체는 unary_function을 상속받고 이항 함수 객체는 binary_function을 상속받는다.

인수나 리턴 타입은 함수 객체와 관련이 있는 중요한 정보인데 이 정보들을 템플릿 인수로 전달받아 argument_type, result_type 등의 이름으로 획일화하여 타입 정의한다. 이항 함수 객체는 두 개의 인수를 가지므로 first, second 인수의 타입을 각각 따로 정의한다. 이 두 클래스로부터 상속받으면 타입들이 미리 약속된 이름으로 정의되므로 어댑터는 약속된 이름으로 해당 정보를 쉽게 얻을 수 있다.

어댑터들은 함수 객체의 기능을 변형하기 위해 이 정보들이 필요한데 함수 객체가 약속된 이름으로 직접 이 타입들을 정의해도 상관없다. 그러나 아무래 직접 정의하는 것은 번거로우므로 상기 두 클래스로부터 상속을 받는 것이 편리하다. 어댑터를 적용할 필요가 없다면 굳이 이 타입들을 정의할 필요는 없다. functor 예제의 print 함수 객체는 단독으로 사용되므로 이 타입들을 정의하지 않았는데 어댑터로 사용하려면 다음과 같이 정의하는 것이 원칙이다.

#include <functional>

struct print : public unary_function<int,void> {

void operator()(int a) const {

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

}

};

unary_function으로부터 상속받되 인수는 int타입이고, 리턴타입은 void임을 템플릿 인수로 지정했다. 이 상속에 의해 두 개의 타입이 약속된 이름으로 정의되며 print 함수 객체를 다음처럼 선언하는 것과 같다. argument_type은 int가 되고, result_type은 void가 된다.

struct print {

typedef int argument_type;

typedef void result_type;

void operator()(int a) const {

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

}

};

plus, greater 등의 미리 제공되는 함수 객체들은 모두 이 클래스들로부터 상속받으므로 어댑터를 항상 적용할 수 있다.

나. 부정자

부정자는 bool을 리턴하는 조건자 함수 객체의 평가 결과를 반대로 뒤집는 또 다른 함수 객체이다. 변형하는 함수 객체의 형태에 따라 다음 두 개의 부정자가 정의되어 있다.

부정자

적용대상

not1

단항 조건 함수 객체(UniPred)

not2

이항 조건 함수 객체(BinPred)

  • 예제 Predicate

#include <iostream>

#include <vector>

#include <algorithm>

#include <functional>

using namespace std;

   

struct IsMulti3 : public unary_function<int,bool>

{

bool operator()(int a) const

{

return ( a % 3 == 0 );

}

};

   

void main()

{

int ari[] = { 1,2,3,4,5,6,7,8,9,10 };

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

   

vector<int>::iterator it;

for( it = vi.begin() ; ; it++ )

{

it = find_if(it, vi.end(), IsMulti3());

if( it == vi.end() ) break;

cout << *it << "가있음" << endl;

}

}

위 예제는 3의 배수를 검색하는 함수 객체를 만들었다. 3의 배수가 아닌 값을 검색하려 할 때 새로운 함수를 만들기 보다는 부정자를 사용하면 이미 만들어져 있는 함수 객체를 조금만 변형하여 반대의 평가를 하도록 할 수 있다.

IsMulti3 단항 조건자 이므로 not1 부정자를 사용하면 된다.

it = find_if(it, vi.end(), not1(IsMulti3()));

IsMulti3은 인수로 받은 수가 3의 배수인지를 판단하지만 not1이 그 결과를 반대로 만들어 리턴하므로 find_if가 IsMulti3의 역조건을 검색하게 된다. 두 개의 함수 객체를 따로 만들 필요 없이 하나만 만들되 반대로 뒤집는 것은 어댑터로 쉽게 할 수 있다. 이항 조건자에 대해서는 not2를 사용하면 된다.

   

:: not1 분석

not1은 functional헤더 파일에 다음과 같이 정의되어 있는 함수 템플릿이다.

template<class F>

unary_negate<F> not1(const F& func)

{

return (unary_negate<F>(func));

}

F타입의 함수 객체 func를 인수로 전달받아 unary_negate<F> 클래스의 객체를 생성하되 생성자의 인수로 func가 전달된다. unary_negate는 다음과 같이 정의된 클래스 템플릿이며 인수로 함수 객체의 타입 F를 전달받는다

template<class F>

class unary_negate : public unary_function<typename F::argumnet_type, bool>

{

protected:

F functor;

public:

explicit unary_negate(const F& func) : functor(func) {}

bool operator()(const typename F::argument_type& left) const {

return (!functor(left));

}

};

F 타입의 멤버 변수 functor가 선언되어 있고 생성자에서 인수로 전달된 func로 이 멤버를 초기화한다. functor가 함수 객체 타입의 멤버 변수이므로 결국 unary_negate는 함수 객체 하나를 캡슐화한다고 할 수 있다. () 연산자 함수는 functor 함수를 호출하되 ! 연산자를 적용하여 평가 결과를 반대로 만들어 리턴한다.

unary_negate가 인수로 전달된 함수 객체를 캡슐화하고 있다가 호출시 캡슐화한 함수 객체의 반대 결과를 리턴하므로 함수 객체의 원래 의미를 부정하는 부정자가 된다. find_if로 IsMulti3을 캡슐화한 unary_negate 객체 하나를 만들어서 던져 주면 원하는 목적을 달성할 수 있다.

it = find_if(it, vi.end(), unary_negate<IsMulti3>)(IsMulti3())));

한줄로 간단하게 표기하면 위와 같고 제대로 쓰면 다음 세줄과 같다.

IsMulti3 i;

unary_negate<IsMulti3> N(i);

it = find_if(it, vi.end(), N);

함수 객체라는 함수는 코드 덩어리를 캡슐화하고 함수 객체 어댑터는 이렇게 캡슐화된 함수 객체를 다시 한 번 더 캡슐화하되 호출할 때나 리턴한 후에 의미를 조작하여 원래의 함수 객체를 변형한다.

   

:: 어댑터블 함수 객체

어댑터를 적용할 수 있으려면 함수 객체는 어댑터가 요구하는 타입 정보를 제공해야 한다. 타입 정보를 제공하지 않는 함수 객체는 단독으로는 사용될 수 있지만 어댑터와 함께는 사용할 수 없다. 어댑터 적용을 위해 타입을 공개하는 함수 객체를 어댑터블 함수 객체라고 한다.

unary_negate클래스의 () 연산자 정의문을 보면 호출원으로부터 전달되는 left를 인수로 받아 functor에게 중계하고 있다. 이 함수 정의문이 작성되려면 left의 타입이 무엇인지를 알아야 하는데 이 left는 구체적으로 find_if가 순회 중의 반복자에 *연산자를 적용하여 읽어내는 요소의 타입과 같고 이 타입은 곧 함수 객체가 받아들이는 인수의 타입이 된다. 그래서 unary_negate의 () 연산자가 정의되려면 함수 객체의 인수 타입인 argument_type을 정확하게 알고 있어야 하며 이 타입을 정의하는 역할을 unary_function 기반 클래스가 대신 하는 것이다. IsMulti3 클래스에 unary_function 상속문을 빼고 컴파일하면 에러 메시지가 출력되는데 이 에러 메시지의 의미는 argument_type이 선언되지 않았다고 나온다. IsMulti3에 typedef int argumnet_type; 처럼 직접 선언할 수 있지만 매번 이렇게 하기 싫으니까 어댑터를 적용하기 위한 함수 객체는 unary_function을 상속받는 것이다.

함수 객체의 const 여부는 어댑터가 정의하는 () 연산자의 const 여부와 같아야 한다. unary_negate의 ()연산자 함수가 const로 선언되어 있으므로 not1 어댑터와 함께 사용될 함수 객체도 반드시 const여야 한다. 조건자 함수 객체는 컨테이너의 요소가 조건을 만족하는지 점검하는 것만이 본연의 임무이므로 값을 읽기만 하면 되고 함수 객체 자체를 변경하지 않으므로 const가 되는 것이 상식적이다.

어댑터블 함수 객체는 인수를 레퍼런스로 전달받아도 안 되며 반드시 값으로 전달받아야 한다. 왜냐하면 unary_negate 함수의 () 연산자가 레퍼런스로 값을 중계하고 있기 때문이다. 함수 객체가 레퍼런스를 받아들이면 결국 레퍼런스의 레퍼런스를 넘기는 꼴이 되는데 C++은 이중 포인터는 허용해도 이중 레퍼런스 라는 것은 허용하지 않는다. 함수 객체는 어차피 인라인이므로 효율을 위해 레퍼런스를 넘길 필요가 없다.

   

:: not2

not2 부정자는 이항 조건자의 평가 결과를 반대로 뒤집는다. 비슷한 연산을 함수 객체를 별도로 만들 필요 없이 이항 부정자인 not2를 사용하면 된다.

  • 예제 not2

#include <iostream>

#include <string>

#include <vector>

#include <algorithm>

#include <functional>

using namespace std;

   

struct compare : public binary_function<string, string, bool>

{

bool operator()(string a, string b) const

{

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

}

};

   

void main()

{

string names[] = {

"STL", "MFC", "owl", "html", "pascal", "Ada",

"Delphi", "C/C++", "Python", "basic",

};

vector<string> vs(&names[0], &names[10]);

   

sort(vs.begin(), vs.end(), not2(compare()));

vector<string>::iterator iter;

   

for ( iter = vs.begin() ; iter != vs.end() ; iter++ )

{

cout << *iter << endl;

}

}

not2는 binary_negate 임시 객체를 생성하며 binary_negate 클래스는 이항 조건자를 캡슐화하여 호출하고 그 결과를 반대로 뒤집어 리턴한다.

다. 바인더

IsMulti3 함수 객체는 정수값이 3의 배수인지를 조사하는데 임의 정수의 배수를 조사할 수 있도록 좀 더 일반화해 보자.

  • 예제 IsMulti

#include <iostream>

#include <vector>

#include <algorithm>

#include <functional>

using namespace std;

   

struct IsMulti : public binary_function<int, int, bool>

{

bool operator()(int a, int b) const

{

return ( a % b == 0 ) ;

}

};

   

void main()

{

IsMulti im;

if( im(6,3) ) { cout << "6은 3의 배수이다." << endl; }

if( im(9,2) ) { cout << "9는 2의 배수이다." << endl; }

}

두 개의 인수를 전달받음으로써 일반성을 확보한 것은 좋은데 이렇게 되면 단항 조건자를 요구하는 find_if와는 함께 사용할 수 없다. find_if는 컨테이너를 순회하면서 요소값 하나만 조건자의 인수로 전달하므로 인수 두개를 받는 이항 조건자와는 타입이 맞지 않은 것이다. 사용하고자 하는 함수 객체의 항이 요구된느 함수 객체와 다를 때 바인더 어댑터를 사용한다.

바인더는 이항 함수 객체의 나머지 한 인수를 특정한 값으로 고정하여 단항 함수 객체로 변환한다. find_if처럼 단항 조건자 객체를 요구하는 함수에게 이미 만들어 놓은 이항 함수 객체를 전달하려면 단항으로 변환해야 흔ㄴ데 이때 바인더가 필요하다. 바인더는 다음 두 가지 형식으로 사용된다.

bind1st(이항 객체, 고정값)

bind2nd(이항 객체, 고정값)

  • 예제 bind2nd

#include <iostream>

#include <vector>

#include <algorithm>

#include <functional>

using namespace std;

   

struct IsMulti : public binary_function<int, int, bool>

{

bool operator()(int a, int b) const

{

return ( a % b == 0 ) ;

}

};

   

void main()

{

int ari[] = { 1,2,3,4,5,6,7,8,9,10 };

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

   

vector<int>::iterator it;

for( it = vi.begin() ; ; it++ )

{

it = find_if(it, vi.end(), bind2nd(IsMulti(),3));

if( it == vi.end() ) break;

cout << *it << "있다" << endl;

}

}

bind2nd(IsMulti(),3)은 이항 조건자 IsMulti의 두 번째 인수를 3으로 고정하여 단항 조건자로 변환하며 그래서 이조건자를 find_if와 함께 사용할 수 있다. IsMulti는 binary_function으로부터 상속받았으므로 어댑터블 함수 객체이다. 사용자가 직접 만든 함수 객체 외에 미리 제공되는 함수 객체에도 어댑터를 적용할 수 있다.

it = find_if(it, vi.end(), bind2nd(greater<int>(), 5));

it = find_if(it, vi.end(), bind2nd(less_equal<int>(), 5));

IsMulti는 두 정수의 배수 관계를 조사하는데 bind2nd에 의해 나누는 수가 3으로 고정되고 not1에 의해 결과를 반대로 뒤집으므로 결국 3으로 나누어지지 않는 값을 찾게 되는 것이다.

it = find_if(it, vi.end(), not2(bind2nd(IsMulti),3)));

bind2nd를 bind1st로 수정하면 나누어지는 수가 고정되고 나누는 수에 컨테이너의 요소들이 전달되므로 고정된 인수의 약수들이 조사될 것이다.

it = find_if(it, vi.end(), bind1st(IsMulti(),6));

bind2nd는 아래와 같이 정의되어 있을 것이다.

template<class F, class T>

binder2nd<F> bind2nd(const F& func, const T& right)

{

typename F::second_argument_type value(right);

return (binder2nd<F>(func, value));

}

binder2nd 클래스는 다소 복잡하게 선언되어 있다.

template<class F>

class binder2nd

: public unary_function<typename F::first_argument_type, typename F::result_type>

{        // functor adapter _Func(left, stored)

public:

typedef unary_function<typename F::first_argument_type, typename F::result_type> _Base;

typedef typename _Base::argument_type argument_type;

typedef typename _Base::result_type result_type;

   

binder2nd(const F& _Func, const typename F::second_argument_type& _Right)

: op(_Func), value(_Right) { }

result_type operator()(const argument_type& _Left) const { return (op(_Left, value)); }

result_type operator()(argument_type& _Left) const { return (op(_Left, value)); }

protected:

F op;        // the functor to apply

typename F::second_argument_type value;        // the right operand

};

binder2nd 클래스는 unary_function으로부터 상속받으므로 결국 단항 함수 객체이다. 내부에 이항 함수 객체 op와 고정된 두 번째 인수의 값 value를 멤버로 가지며 생성자에서 이 둘을 인수로 전달받아 초기화한다. 래핑한 이항 함수 객체를 호출할 수 있는 만반의 준비를 해 놓는 것이다.

()연산자 함수는 op 함수 객체를 호출하되 첫 번째 인수 left는 자신이 전달받은 인수를 그대로 넘기고 두 번째 인수는 생성자에서 미리 받아 놓은 value를 넘긴다. 그래서 이 함수는 단항이며 호출할 때 left 인수 하나만 전달하면 된다.

라. 함수 포인터 어댑터

함수 포인터 어댑터는 일반 함수의 번지인 함수 포인터를 함수 객체처럼 포장한다. 함수 포인터도 어차피 () 연산자로 호출할 수 있으므로 굳이 함수 객체로 만들지 않아도 알고리즘 함수와 함께 사용할 수 있다. 그러나 함수 포인터는 객체가 아니므로 어댑터는 적용할 수 없다.

함수 포인터에 어댑터를 쓰고 싶다면 이 포인터를 래핑해야 하며 이때 함수 포인터 어댑터를 사용한다.

  • 예제 ptr_fun

#include <iostream>

#include <vector>

#include <string>

#include <algorithm>

#include <functional>

using namespace std;

   

bool IsMultiFunc(int a, int b)

{

return ( a % b == 0 );

}

   

void main()

{

int ari[] = {1,2,3,4,5,6,7,8,9,10};

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

   

vector<int>::iterator it;

   

for( it = vi.begin() ; ; it++ )

{

it = find_if(it, vi.end(), bind2nd(ptr_fun(IsMultiFunc),3));

if( it == vi.end() ) break;

cout << *it << "있다" << endl;

}

}

bind2nd 어댑터가 요구하는 것은 함수 객체와 고정된 2번째 인수인데 함수 포인터를 곧바로 쓸 수는 없다. 함수 포인터는 함수의 시작 번지를 가리키는 단순한 상수일 뿐이므로 템플릿의 인수가 될 수없기 때문이다. ptr_fun 함수 포인터 어댑터가 이 함수 포인터를 함수 객체로 포장하며 이렇게 포장되면 바인더, 부정자 등의 어댑터를 적용할 수 있다.

template<class Arg, class Result>

pointer_to_unary_function<Arg, Result> ptr_fun(Result(*pfunc)(Arg))

{

return (pointer_to_unary_function<Arg, Result>(pfunc));

}

pointer_to_unary_function이라는 클래스의 객체를 만들어 리턴하는 역할을 한다. 이 클래스는 이름이 의미하는 바대로 단항 함수 포인터를 단항 함수 객체로 만든다. 헤더파일에 다음과 같이 정의되어 있다.

template<class Arg, class Result>

class pointer_to_unary_function : public unary_function<Arg, Result>

{

public:

explicit pointer_to_unary_function(Result(*pfunc)(Arg)) : pFunc(pfunc) { }

Result operator()(Arg left) const { return(pFunc(left)); }

};

결국 이 클래스는 함수 포인터를 래핑하고 있으며 () 연산자가 함수 포인터를 대신 호출한다. 래핑보다 더 중요한 역할은 이 함수 포인터의 인수와 리턴 타입을 정의하기 위해 unary_function으로부터 상속을 받는다는 점이며 따라서 이 클래스의 객체는 어댑터블하다.

마. 멤버 함수 어댑터

ptr_fun은 일반 함수를 함수 객체로 만드는데 비해 mem_fun은 클래스의 멤버 함수를 함수 객체로 만든다. 멤버 함수는 반드시 this와 함께 호출되어야 한다는 점에서 함수 포인터와도 다른데 mem_fun은 이것을 가능하게 한다.

  • 예제 mem_fun

#include <iostream>

#include <vector>

#include <algorithm>

#include <functional>

using namespace std;

   

class natural

{

private:

int num;

public:

natural(int anum) : num(anum)

{

setNum(anum);

}

void setNum(int anum)

{

if ( anum > 0 )

{

num = anum;

}

}

int getNum() { return num; }

bool IsEven() { return num % 2 == 0 ; }

};

   

void delnatural(natural *pn)

{

delete pn;

}

   

void main()

{

vector<natural*> vn;

vn.push_back(new natural(1));

vn.push_back(new natural(2));

vn.push_back(new natural(3));

vn.push_back(new natural(4));

   

vector<natural*>::iterator it;

   

for( it = vn.begin() ; ; it++ )

{

it = find_if(it, vn.end(), mem_fun(&natural::IsEven));

if( it == vn.end() ) break;

cout << (*it)->getNum() << "있음" << endl;

}

for_each(vn.begin(), vn.end(), delnatural);

}

natural클래스는 0초과의 자연수를 표현하는데 짝수인지를 판별하는 IsEven 조건자가 클래스의 멤버 함수로 작성되어 있다. 이 멤버 함수를 조건자 함수 객체로 만들기 위해 mem_fun함수를 사용한다. mem_fun은 멤버 함수 포인터를 캡슐화하여 함수 객체로 만드는 객체를 생성하며 이 객체의 ( ) 연산자를 통해 컨테이너에 저장된 각 개체 포인터(this)와 함께 멤버 함수가 호출된다.

멤버 함수를 객체로 캡슐화하는 원리도 ptr_fun과 비슷하다. 멤버 함수에 대한 래퍼는 인수를 취하지 않는 멤버 함수와 인수 하나를 취하는 멤버 함수에 대해 각각 작성되어 있으며 이 객체를 만드는 mem_fun 함수는 두 객체에 대해 오버로딩되어 있다.

template<class Result, class T>

mem_fun_t<Result, T> mem_fun(Result (T::*Pm)())

{

return (mem_fun_t<Result, T>(Pm));

}

T타입의 멤버 함수 Pm에 대한 멤버 함수 포인터를 받아 생성자로 이 함수 포인터를 넘겨 mem_fun_t 객체를 생성하여 그 객체를 리턴한다. mem_fun_t 클래스는 다음과 같이 선언되어 있다.

template<class Result, class T>

class mem_fun_t : public unary_function<T*, Result>

{

public:

explicit mem_fun_t(Result (T::*Pm)()) : Pmemfunc(Pm) { }

Result operator()(T *pObj) const

{

return ((pObj->*Pmemfun)());

}

private:

Result (T::*Pmemfun)();

}

리턴 타입과 클래스 타입을 인수로 전달받으며 내부에 인수를 취하지 않는 멤버 함수 포인터를 Pmemfun이라는 이름의 멤버 변수로 선언해 두었다. 생성자에서는 이 멤버 함수의 포인터를 전달받아 Pmemfun멤버에 대입해 둔다. 그리고 () 연산자는 인수로 전달된 *pObj 객체 포인터에 대해 멤버 함수를 호출한다. 컨테이너에 저장된 요소가 객체의 포인터이므로 find_if에 의해 각 객체의 멤버 함수들이 호출될 것이다.

만약 컨테이너가 객체의 포인터가 아니라 객체 그 자체를 저장하고 싶다면 mem_fun_ref를 사용한다.

바. 할당기

앞에서 컨테이너, 반복자, 알고리즘을 소개했고, 함수객채와 어댑터에 대해 알아보았다. STL을 구성하는 나머지 한 요소는 메모리를 관리하는 할당기(Allocator)라는 것이다.

컨테이너들은 메모리 할당만을 전문적으로 관리하는 할당기 객체를 가지는데 보통 컨테이너의 템플릿 인수로 전달된다. 다음은 벡터 템플릿의 선언문이다.

template<class Type, class Allocator = allocator<Type> > class vector

첫 번째 인수로 요소의 타입을 지정하며 두 번째 인수로 할당기를 지정하는데 할당ㅇ기에는 디폴트가 있다. Type형에 대한 디폴트 할당기는 allocator<Type>이며 Type형의 자료를 저장하기 위한 메모리 관리를 담당한다. 메모리 할당 방식은 여러 가지가 있는데 디폴트 할당기는 C++의 new, delete 연산자를 사용한다. 만약 다른 방식으로 메모리를 직접 관리하고 싶다면 디폴트 할당기가 아닌 직접 만든 할당기를 사용할 수도 있다.

예를 들어 malloc/free를 사용할 수도 있고 COM 인터페이스를 쓸 수도 있고 운영체제의 가상 메모리를 직접 다룰 수도 있다. 할당기를 직접 만들어 쓰는 가장 실용적인 예는 할당, 해제가 아주 빈번하며 필요량이 많을 때 메모리를 미리 왕창 할당하여 메모리 풀을 만들어 놓고 이 메모리를 내부에서 관리하며 번갈아 쓰도록 할 때이다. 운영체제의 간섭에서 벗어나 메모리에 대한 완전한 통제권을 행사하고 싶을 때 이런 방법이 동원된다.

특별한 이유가 없는 한 디폴트만 사용해도 충분하다.

반응형

'책정리 > 혼자 연구하는 C,C++ 2' 카테고리의 다른 글

40장 시퀀스 컨테이너  (0) 2015.03.16
39장 반복자  (0) 2015.03.14
37장 STL 개요  (0) 2015.03.12
36장 표준 라이브러리  (0) 2015.03.11
34장 네임 스페이스  (0) 2015.03.10