38-2-나.부정자

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

 

부정자

적용대상

not1

단항 조건자 함수 객체(UniPred)

not2

이항 조건자 함수 객체(BinPred)

 

부정자의 사용예를 보기 위해 먼저 조건자를 사용하는 간단한 예제부터 만들어 보자. 다음 예제는 정수 벡터에서 3의 배수인 요소를 검색하여 출력한다.

 

: 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;

     }

}

 

IsMulti3 함수 객체는 a라는 정수형 인수 하나를 취하며 a를 3으로 나눈 나머지가 0인지를 조사하여 그 진위값을 리턴한다. main에서 1~10까지의 정수에 대해 이 조건자를 만족하는 정수값을 찾아 출력했으므로 3, 6, 9의 요소들이 검색될 것이다. 함수 객체를 이해한다면 아주 쉬운 예제이다. 그렇다면 이번에는 반대의 조건, 즉 3의 배수가 아닌 값을 검색하도록 수정해 보자. 가장 먼저 떠 오르는 방법은 역조건을 취하는 함수 객체를 새로 만드는 것이다.

 

struct IsNotMulti3 : public unary_function<int,bool> {

     bool operator()(int a) const {

          return (a % 3 != 0);

     }

};

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

 

IsMulti3가 a % 3 == 0을 점검하는데 비해 IsNotMulti3는 a % 3 != 0 조건을 점검한다. 이렇게 함수 객체를 새로 만들고 find_if로 이 함수 객체를 전달하면 3의 배수가 아닌 1, 2, 4, 5 따위들이 검색될 것이다. 이 예에서 함수 객체의 이름은 어디까지나 설명을 위한 이름일 뿐이므로 굳이 IsNotMulti3로 바꾸지 않아도 상관없다. 그러나 함수의 의미가 바뀌었으므로 이름도 바꾸는 것이 바람직하다.

문제를 풀기는 했지만 비슷한 일을 하는 함수 객체를 둘 씩이나 만들었다는 것이 별로 마음에 들지 않는다. 이런 식으로 조건이 필요할 때마다 함수 객체를 매번 만들어야 한다면 그 수가 엄청날 것이다. 이럴 때 부정자를 사용하면 이미 만들어져 있는 함수 객체를 조금만 변형하여 반대의 평가를 하도록 할 수 있다. 위 예제의 IsMulti3는 단항 조건자이므로 not1 부정자를 사용하면 된다. 다음과 같이 수정해 보자.

 

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

 

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

not1 분석

사용만을 목적으로 한다면 부정자의 동작과 사용법만 익혀 두면 충분하다. 반대로 만들고 싶은 조건자를 not1()로 감싸기만 하면 되므로 사용법은 지극히 쉬운 편이다. 그러나 not1이 어떻게 함수 객체의 평가 결과를 반대로 만드는지 지적 호기심이 생기고 원리까지 알고 싶다면 헤더 파일 내부를 들여다 보지 않을 수 없다. 내부를 분석해 보지 않으면 도대체 not1이 함수인지, 객체인지, 매크로인지조차도 파악하기 힘들고 STL이 뭔가 마술같은 사기를 치는 것 같아 기분이 그다지 상쾌하지 못하다. 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::argument_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 함수를 호출하되 ! 연산자를 적용하여 평가 결과를 반대로 만들어 리턴한다. 그래서 func가 3의 배수를 검색한다면 unary_negate는 !func 즉 3의 배수가 아닌 수를 검색하는 것이다.

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);

 

3의 배수를 판별하는 함수 객체 I를 선언하고 I를 캡슐화하는 부정자 N 객체를 선언하고 find_if에게 N 객체를 전달하는 것이다. 이 과정을 단순화해 놓은 것이 바로 not1이며 괄호안에 부정하고자 하는 함수 객체만 전달하면 된다. not1은 전달된 함수 객체로부터 unary_negate 임시 객체를 생성하는 역할을 한다.

find_if는 지정한 구간을 순회하면서 전달된 함수 객체의 () 연산자를 호출하도록 작성되어 있으며 () 연산자가 true를 리턴하면 그 때의 반복자를 리턴한다. 검색 조건을 판단해줄 대상이 함수 포인터인지 또는 () 연산자를 재정의한 함수 객체인지 또는 함수 객체를 캡슐화한 부정자인지 따위는 상관하지 않는다. 어쨌든 () 구문으로 호출 가능하고 bool값을 리턴하기만 하면 되는 것이다.

C++ 클래스는 표현력이 훌륭해서 세상의 모든 사물, 심지어 관념적인 것까지 캡슐화할 수 있다. 함수 객체는 함수라는 코드 덩어리를 캡슐화하고 함수 객체 어댑터는 이렇게 캡슐화된 함수 객체를 다시 한 번 더 캡슐화하되 호출할 때나 리턴한 후에 의미를 조작하여 원래의 함수 객체를 변형한다. 이 변형에 의해 STL 알고리즘들이 깜박 속아 넘어가는데 원리를 알고나면 참으로 절묘하다.

어댑터블 함수 객체

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

unary_negate 클래스의 () 연산자 정의문을 보면 호출원으로부터 전달되는 left를 인수로 받아 functor에게 중계하고 있다. 이 함수 정의문이 작성되려면 left의 타입이 무엇인지를 알아야 하는데 이 left는 구체적으로 find_if가 순회중의 반복자에 * 연산자를 적용하여 읽어내는 요소의 타입과 같고 이 타입은 곧 함수 객체가 받아들이는 인수의 타입이 된다. 그래서 unary_negate의 () 연산자가 정의되려면 함수 객체의 인수 타입인 argument_type을 정확하게 알고 있어야 하며 이 타입을 정의하는 역할을 unary_function 기반 클래스가 대신하는 것이다.

자동차는 앞으로 전진하는 것이 본래의 기능이지만 가는 차를 멈추는 브레이크도 필요하다. 왜 브레이크가 꼭 필요한지는 브레이크를 빼고 차를 만들어 보면 쉽게 알 수 있다.  같은 원리로 IsMulti3 클래스에 unary_function 상속문을 빼고 컴파일하면 에러 메시지가 출력되는데 이 에러 메시지의 의미를 분석해 보면 왜 unary_function이 필요한가를 컴파일러가 알려줄 것이다. argument_type이 도대체 뭐냐는 에러 메시지가 출력되는데 unary_negate는 자신이 캡슐화하는 함수 객체 F에 이 타입이 정의되어 있다는 가정하에 만들어졌기 때문이다. 물론 unary_function으로부터 상속받지 않고 다음과 같이 IsMulti3를 선언해도 효과는 같다.

 

struct IsMulti3 {

     typedef int argument_type;

     bool operator()(int a) const {

          return (a % 3 == 0);

     }

};

 

내가 사용하는 인수는 int형이라는 것을 argument_type이라는 약속된 이름으로 정의하는 것이다. 어쨌든 unary_negate는 이 타입만 정의되어 있으면 잘 돌아간다. 이 짓을 직접 하기 싫으니까 어댑터를 적용하기 위한 함수 객체는 unary_function으로부터 상속을 받는 것이다.

함수 객체의 const 여부는 어댑터가 정의하는 () 연산자의 const 여부와 같아야 한다. unary_negate의 () 연산자 함수가 const로 선언되어 있으므로 not1 어댑터와 함께 사용될 함수 객체도 반드시 const여야 한다. 위 예제의 IsMulti3에서 const를 빼 버리면 에러로 처리되는데 const 함수가 비 const 함수를 호출할 수 없기 때문이다. 조건자 함수 객체는 컨테이너의 요소가 조건을 만족하는지 점검하는 것만이 본연의 임무이므로 값을 읽기만 하면 되고 함수 객체 자체를 변경하지 않으므로(사실 변경할 멤버도 없다) const가 되는 것이 상식적으로 합당하다.

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

not2

not2 부정자는 이항 조건자의 평가 결과를 반대로 뒤집는다. sortfunctor 예제의 compare 함수 객체는 대소 구분없이 문자열을 비교하여 오름차순으로 정렬하도록 한다. 대소구분없이 내림차순으로 정렬하려면 다음 함수 객체를 만들어야 할 것이다.

 

struct comparedesc {

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

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

     }

};

 

함수 객체의 이름과 부등호 방향만 바뀌었는데 이름은 물론 굳이 변경하지 않아도 상관없다. 비슷한 연산을 하는 함수 객체를 별도로 만들 필요없이 이항 부정자인 not2를 사용하면 된다. compare 함수 객체가 이항 조건자이므로 not1을 사용해서는 안되며 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 it;

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

          cout << *it << endl;

     }

}

 

실행 결과는 다음과 같다. 대소구분없이 내림차순으로 정렬된다.

 

STL

Python

pascal

owl

MFC

html

Delphi

C/C++

basic

Ada

 

새로운 함수 객체를 만드는 대신 compare를 binary_function으로부터 상속받아 인수의 타입과 리턴 타입을 정의하도록 했으며 not2 부정자를 적용했다. 이 예제가 어떻게 실행되는지를 분석해 보고 싶다면 앞에서 했던 실습과 비슷하게 헤더 파일을 열어 not2가 어떻게 정의되어 있는지 살펴보면 된다. not2는 binary_negate 임시 객체를 생성하며 binary_negate 클래스는 이항 조건자를 캡슐화하여 호출하고 그 결과를 반대로 뒤집어 리턴한다.

 

 notKim

앞에서 만들었던 find_if 예제를 수정하여 김가가 아닌 사람의 목록 전부를 조사하도록 수정하라. 김가인지 조사하는 IsKim 함수 객체가 작성되어 있으므로 not1 부정자를 적용하면 쉽게 구현할 수 있다.