술어 구문은 순수 함수로 만들자


  • 술어구문(predicate)이란 bool값을 반환하는 함수를 말한다.
    ex) find_if의 매개변수인 함수 등등

  • 순수 함수란 이 함수가 반환하는 값이 그 함수의 매개변수에 종속된 함수이다.
    ex) f가 순수함수고 x, y 가 객체일때 f(x,y) 의 반환값은 x나 y의 값이 바뀔때만 변한다.

  • 술어 구문 클래스 란 operator()가 술어 구문인 클래스를 말한다.
EffectiveSTL38에 의하면 함수 객체는 값으로 전달되기 때문에 유의 해야 할 사항이 있다. 왜냐 하면 알고리즘에 함수 객체를 넘기면 알고리즘 내부에서도 다른 알고리즘을 호출함으로써 함수객체가 또 다시 복사되는 일이 일어날수 있기 때문이다.
즉 함수객체에 맴버변수가 있고 이 변수를 초기화 하여 알고리즘에 넘겼을때 알고리즘이 실행되는 중간에 이 변수가 변하고 그것이 결과에 영향을 미치는 것일 경우 알고리즘 내부에서 함수객체의 복사가 어디서 몇번이루어 지는지 알수 없기 때문에 문제가 발생할수 있다.

이 문제는 술어구문 클래스의 operator()를 const로 만듬으로써 어느정도 예방이 가능하지만 const 맴버함수라고 해도 이것저것(책참조)에 접근이 가능하며 값을 바꿀수 있기 때문에 순수 함수로 만들어야 하는 것이다.

자세한 코드는 책참조.

함수자 클래스는 값으로 전달되도록 설계하자

STL의 함수 객체는 일반적으로 값으로 전달(복사) 된다. 하지만





for_each<vector<Object>::iterator, DoSomething&>(vi.begin(), vi.end(), d);

와 같이 참조를 명시적으로 선언하는 것도 불가능 한것은 아니다. 문제는 제품에 따라서 이런 코드가 컴파일이 되지 않을 수도 있다는 것이다.
함수 객체를 값으로 전달하는 방식 때문에 다형성을 구현하기 어려워 지는 단점이 있지만 이것은 프록시 클래스로 극복이 가능하다.

// 실제 구현부 함수
template<typename T>
class BPFCImpl{
private:
Widget w;
int x;

virtual ~BPFCImpl();
// 다형성으로 구현된 함수
virtual void operator()(const T& val) const;
// 프록시 클래스가 접근가능하게 한다.
friend class BPFC<T>
};

// 프록시 클래스
template<typename T>
class BPFC: public unary_function<T, void>
{
private:
BPFCImpl<T> *pImpl;

public:
void operator()(const T& val) const
{
// 실제 구현객체를 호출한다.
pImpl->operator()(val);
}
};

크기가 작고 단형성을 유지하면서 많은 데이터의 접근이 가능하며 다형성을 구현할수 있는 방식이다. 위의 방식은 GOF에서 브릿지패턴(BridgePattern)으로 불리고 있다.

위의 내용은 상당히 간략히 구현된 내용이다. 추가적으로 BPFCImpl에 참조카운팅에 관련된 내용과 BPFC의 복사 생성자에 관한 구현 내용을 좀더 고민해 봐야 한다.

범위 내의 데이터 값을 요약하거나 더하는 데에는 accumulate 나 for_each를 사용하자

STL 에서는 아래와 같이 특정 범위에 대한 연산을 준비해 놓고 있다.














count 범위 내의 요소 개수를 센다.
count_if 조건에 맞는 요소 개수를 센다.
min_element 범위내의 최소값을 얻는다.
max_element 범위내의 최대값을 얻는다.

하지만 위의 네가지 알고리즘 보다 좀더 융통성 있는 무언가를 해야 할 경우가 있다. 이 경우 accumulatefor_each 를 사용 할수 있다. 예를 들어 double 형 리스트의 총합을 알고 싶을 경우 accumulate를 아래와 같이 사용할 수 있다.
list<double> ld;
….
double sum = accumulate(ld.begin(), ld.end(). 0.0);
와 같은 형태로 쓸수 있다. (마지막 인자인 초기값에 주의. 0으로 초기값을 줄 경우 accumulate는 내부적으로 int형으로 동작되어 double 형의 값이 잃어버릴수 있다.) 위와 같은 수치형에서의 간단한 누적 합이외에 특정 클래스의 컨테이너 범위에서 클래스의 요약정보를 얻기 위해서도 사용이 가능하다.
예를 들어 컨테이너 안에 들어있는 string 객체의 문자열 길이의 합을 계산하는데 사용가능하다.
string::size_type
stringLengthSum(string::size_type sumSoFar, const string& s)
{
return sumSoFar+s.size();
}
stringLengthSum은 accumulate가 받아들이는 요약용 함수의 전형이다. 이 함수는 바로 전까지 요약된 값과 현재의 요소를 받아 들여서 새 요약값을 반환한다. 즉 컨테이너안의 string 사이즈의 요약 정보를 얻을때 string::size_type sumSoFar는 이전 까지의 항목까지의 요약 정보고 현재 항목의 string 객체를 받는다. 그리고 다시 요약한 정보를 리턴한다.
set<string> ss;

string::size_type lengthSum =
accumulate(ss.begin(), ss.end(), 0, stringLengthSum);
위와 같은 식으로 사용이 가능하다. 범위 내의 수를 곱하는 것은 더욱 쉽다. STL에 해당 함수가 존재하기 때문이다.
vector<float> vf;

float product =
accumulate(vf.begin(), vf.end(), 1.0, multiplies<float>() );
(곱셉연산이기 때문에 1.0 으로 시작한다.)
사용자 클래스에 대한것 역시 가능하다.
// 사용자 클래스
struct Point{
Point(double initX, double initY): x(initX), y(initY) {}
double x,y;
}

// 적용될 함수자. 자세한 내용은 EffectiveSTL40을 참조
class PointAverage : public binary_function<Point, Point, Point> {
public:
PointAverage() : xSum(0), ySum(0), numPoints(0) {}
const Point operator() (const Point& avgSoFar, const Point& p)
{
++numPoints;
xSum += p.x;
ySum += p.y;
return Point(xSum/numPoint, ySum/numPoint);
}

private:
size_t numPoints;
double xSum;
double ySum;
};

// 실제 사용예
list<Point> lp;

Point avg =
accumulate(lp.begin(), lp.end(), Point(0,0) PointAverage());

PointAverage는 자인이 계산에 사용한 Point객체의 개수와 x,y 좌표의 합을 내부적으로 유지해 가며 동작한다. 하지만 위의 PointAverage은 표준안의 26.4.1절의 두번째 단락의 내용인 accumulate에 넘겨지는 함수의 부가적 효과는 없어야 한다에 어긋나는 클래스이다.(실질적으로 문제가 생기지는 않지만 어쩌면 정의되지 않는 동작이 일어날수 있다고 한다.)
위의 문제를 해결하기 위해 for_each를 사용할 수 있다. 이 알고리즘은 범위 내의 데이터를 요약할 수 있는 알고리즘이면서 accumulate가 가진 제한을 받지 않는다. for_each는 범위와 그 범위 내의 요소에 대해 호출할 함수를 받아들이는데, 이 알고리즘에 넘겨지는 함수는 자신이 처리할 단 하나의 요소만을 받아들이며, for_each는 자신의 수행을 마칠때 이 함수의 사본을 반환한다.


// EffectiveSTL 40 참조
class PointAverage : public unary_function<Point, void> {
public:
PointAverage() : xSum(0), ySum(0), numPoints(0) {}
void operator()(const Point& p )
{
++numPoints;
xSum += p.x;
ySum += p.y;
}

Point result() const
{
return Point(xSum/numPoints, ySum/numPoints);
}

private:
size_t numPoints;
double xSum;
double ySum;
};

// 사용예
list<Point> lp;

Point avg = for_each(lp.begin(), lp.end(), PointAverage()).result();

copy_if를 적절히 구현해 사용하자

STL에는 여러가지 copy 알고리즘을 제공하지만 copy_if 는 존재하지 않는다. 즉 원하는 것만을 복사를 하는 알고리즘은 직접 구현해야 한다.


template< typename IIter
typename OIter,
typename Predicate>
OIter copy_if(IIterbegin,
IIter end,
OIter destBegin,
Predicate p)
{
while(begin != end)
{
if( p(*begin) ) *destBegin++ = *begin;
++begin;
}
return destBegin;
}

대략 이런 코드로 구현할수 있다고 한다.

정렬된 범위에 대해 동작하는 알고리즘이 어떤 것들인지 파악해 두자.

특정 알고리즘들은 미리 정렬시킨 범위를 넣어주어야 한다. 만약 이러한 규칙을 지키지 않았을 경우에는 실행시 동작을 예측 할수 없게 된다.

정렬된 데이터를 넘겨야 동작하는 알고리즘
















binary_search lower_bound upper_bound equal_range
set_union set_intersection set_difference set_symmetric_difference
merge inplace_merge
include

binary_search, lower_bound, upper_bound, equal_range 등은 내부적으로 이진 탐색을 사용해서 값을 찾기 때문에 반듯이 정렬된 데이터 범위가 필요하다.
일반적으로 위의 알고리즘들은 로그 시간의 성능을 내준다고 하지만 임의 접근 반복자를 사용할때에만 그렇다. 예를 들어 양방향 반복자등을 사용한다면 한곳에서 다른 곳으로 이동시 인접한 데이타만으로 이동이 가능하므로 선형시간 이상이 걸리게 된다.

set_union, set_intersection, set_difference, set_symmetric_difference 은 set 조작 알고리즘이며 선형시간의 효율을 보인다. 만약 정렬되지 않은 데이타 범위로 동작할 때는 동작자체는 하지만 선형시간을 초과하는 동작시간을 보여준다.

merge, inplace_merge 알고리즘은 merge sort 의 일부분으로 생각할수 있다. 즉 두개의 정렬된 데이타 범위를 합쳐 정렬된 하나의 범위로 만드는 동작이다.

include 는 어떤 범위안에 원하는 값이 들어있는지의 여부를 알아볼때 사용한다. 물론 정렬된 것을 가정하고 동작하므로 정렬되어 있지 않다면 수행시간이 느려지게 된다.

unique 류의 알고리즘은 인접한 동일값에 대해 작용하는 알고리즘으로 정렬되어 있을 경우와 정렬되어 있지 않을 경우 결과가 달라질수 있다. 데이터 범위에서 중복된 값을 모두 제거하고 싶다면 정렬을 한뒤 unique 를 호출해야 하는 것이다.


정렬 기준에 대한 첨언

binary_search, lower_bound, upper_bound, equal_range 등의 알고리즘은 당연히 정렬된 기준과 동일한 기준으로 검색기준을 가져야 정상적으로 작동을 할것이다.
vector<int> v;

sort(v.begin(), v.end(), greater<int>());

bool a5Exists = binary_search(v.begin(), v.end(), 5);
위의 코드를 볼때 데이터의 범위는 내림차순으로 정렬을 했지만 기본적으로 binary_search는 오름차순을 기준으로 한다. 위의 코드를 정상적으로 작동하게 하기 위해서는
bool a5Exists = binary_search(v.begin(), v.end(), 5, greater<int>());
으로 수정을 해서 binary_search 알고리즘이 내림차순으로 정렬된 데이터 범위라는 것을 알수 있도록 해야 한다.

또한 unique류 를 제외하고 위에서 언급한 정렬된 범위에 대해 동작하는 알고리즘은 두개의 값이 같은지 판정할때 동등성을 기준으로 삼는다.
이와는 반대로 unique류의 알고리즘은 상등성에 의해 두 객체의 같음을 판별하게 된다.(EffectiveSTL19 참조)

remove와 비슷한 알고리즘을 포인터의 컨테이너에 적용할 때에는 각별히 조심하자.

EffectiveSTL32에서 언급한 것처럼 remove(remove_if, unique)는 지워질 데이터에 대해서 지워 지지 않는 데이타로 덮어써 버리는 동작을 취한다. 이런 동작은 컨테이너의 요소타입이 포인터일 경우 심각한 메모리 누수 현상을 일으킬 가능성이 있다.

이 경우 partition 알고리즘으로 지워질 대상이 되는것과 남아있을 데이타를 분리한뒤 지워질 대상의 메모리를 해제하고 erase 를 호출하면 깔끔하다.

하지만 부득이한 사정으로 remove 류의 함수만을 사용해야 한다면 for_each 등으로 직접 컨테이너의 요소중 삭제될 데이타의 메모리를 해제해 준뒤 remove-erase 합성문을 호출해 주어야 한다.

이런 것을 신경 쓰고 싶지 않다면 스마트 포인터를 사용하도록 하자!

요소를 정말로 제거하고자 한다면 remove 류의 알고리즘에는 꼭 erase를 붙여 사용하자.

remove 알고리즘의 경우 범위를 나타내는 한쌍의 반복자를 인자로 받는다. 여기서 문제가 생기는데 반복자만으로는 어떤 컨테이너 인지 알수 없을 뿐더러 해당 컨테이너를 접근할수도 없다.
그 결과 remove 알고리즘을 호출하더라 하더라도 컨테이너에 들어있는 요소의 개수가 삭제되지 않는 결과가 나타나게 된다. 이유는





romove는 어느것도 진짜로 없애지 않는다. 없앨수 없기 때문이다.

실제로 remove가 하는 일은 지워질 데이터와 남아있을 데이터를 재배열 하는 것이다.
vector<int> v;
v.reserve(10);
for(int i=1; i<=10; ++i)
{
v.push_back(i);
}
v[3] = v[5] = v[9] = 99;
remove(v.begin(), v.end(), 99);
위의 코드는 remove를 실제로 사용하는 코드이다. 위의 코드중 remove를 호출하기 전의 상태는
   v.begin()                     v.end()
↓ ↓
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│1 │2 │3 │99│5 │99│7 │8 │9 │99│
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
같은 상태이다. remove를 호출한 이후는
   v.begin()           v.end()
↓ ↓
┌─┬─┬─┬─┬─┬─┬─┬─┬─┬─┐
│1│2│3│5│7│8│9│?│?│?│
└─┴─┴─┴─┴─┴─┴─┴─┴─┴─┘

remove에서 리턴된 반복자위치
위와 같은 상태를 갖게 된다. 즉 remove는 남아있을 값을 앞쪽으로 모아 놓고 논리적인 마지막 위치의 반복자를 리턴하게 되는 것이다. 이후 remove에서 리턴한 반복자와 원래 컨테이너의 end() 반복자 사이의 요소를 컨테이너의 erase를 호출하여 삭제해줘야 한다.
vector<int> v;

v.erase(remove(v.begin(), v.end(), 99), v.end());
즉 위와 같은 형식의 코드가 구성된다.
remove와 유사한 알고리즘으로 remove_if 가 존재하며 비슷하게 동작하는 unique(인접한 중복갑제거)도 존재한다. 즉 remove_if와 unique등은 remove와 마찬가지로 실제로 데이타를 삭제하기 위해서는 erase를 같이 호출해 주어야 한다.

주의할점 1
list의 경우는 예외적으로 remove 와 unique 맴버함수가 존재하며 위의 설명과는 다르게 바로 데이타를 삭제한다.

주의할점 2
위의 remove, remove_if, unique등은 삭제 대상이 되는 데이타는 신경쓰지 않고 덮어써 버리는 구조를 취하게 되므로 포인터를 컨테이너에 넣는 경우 주의해야 한다.(EffectiveSTL33)

정렬시의 선택 사항들을 제대로 파악해보자



  • vector, string, deque 혹은 C++ 배열에 대해 전체 정렬을 수행할 필요가 있을 때에는 sort 나 stable 소트를 사용한다.
  • vector, string, deque 혹은 C++ 배열에 대해 상위 n개의 요소만 순서에 맞추어 뽑아내고 싶다면 partial_sort를 사용한다.
  • vector, string, deque 혹은 C++ 배열에 대해 상위 n개의 요소를 뽑되 순서를 고려할 필요가 없다면 nth_element가 적합하다.
  • 표준 시퀀스 컨테이너가 있고, 이 컨테이너의 요소들을 어떤 기준에 만족하는 것들과 그렇지 않은 것들을 모아 구분하고 싶다면 partition 혹시 stable_partition 을 사용한다. (등급을 구분하거나 더 나아가 몇 등급이상의 것만 얻거나)
  • list의 경우 partition, stable_partition 은 직접 사용이 가능하며 sort 와 stable_sort 알고리즘 대신 list::sort 멤버 함수를 사용할수 있다. 만일 partial_sort나 nth_element의 기능이 필요하다면 백터등으로 변환하거나 반복자를 백터에 넣어 정렬하는 간접적인 방법을 사용해야 한다.
stable 이 붙은 정렬알고리즘은 정렬이전의 순서를 유지시켜준다.

그리고 간단하게 정렬알고리즘의 성능으로 순위를 내보자면

  1. partition
  2. stable_partition
  3. nth_element
  4. partial_sort
  5. sort
  6. stable_sort
순으로 나타난다.(하지만 성능은 상황에 따라 상대적인것)
nth_element 의 또 다른 사용법과 다른 알고리즘의 사용예는 책을 참고

알고리즘의 데이터 기록범위는 충분히 크게 잡자

컨테이너를 대상으로 알고리즘을 수행할때는 언제나 알고리즘의 결과가 어디에 입력이 되는지를 생각해야 한다. 예를 들어

int transmogrify(int x);                // 정수 x를 받아
// 새로운 값을 생성
vector<int> values;
vector<int> result;
transform(values.begin(), values.end(),
result.end(), // 알고리즘의 결과를
transmogrify); // result 뒤에 추가 시도
의 경우 transform 알고리즘으로 transmogrify의 결과를 result의 뒤에 덧붙이는 결과를 기대 하겠지만 실제로 result.end()는 실제로 할당되어 있는 객체가 아닐뿐더라 result.end() 이후도 마찬가지 이다. 즉 해당코드는 알수없는 장소에 데이터를 기록하려 시도하다 런타임 에러를 유발하게 된다.
이런 경우 사용할수 있는것이 back_inserter, front_inserter, inserter 등이다. 이것들은 내부적으로 push_back, push_front, insert 등을 호출하여 컨테이너의 항목을 늘려주고 그 항목에 해당하는 반복자를 리턴해준다.
int transmogrify(int x);
//…
vector<int> values;
list<int> result;
transform(values.begin(), values.end(),
front_inserter(result),
transmogrify);
주의할 점은 각각의 컨테이너가 구현방식이 다르므로 사용할수 있는 함수의 종류도 다르다며 각 함수의 효율도 차이가 있다. 그래서 예제 코드도 vector에서 list로 바꿨다.
list 같은 의 경우 노드 단위의 컨테이너 이므로 앞,뒤 삽입이 자유로우며 삽입에 따른 오버헤드 역시 별로 없다. 하지만 임의 위치에 삽입은 탐색에 대한 오버헤드가 존재한다.
vector 등의 front_inserter를 지원하지 않고 모든 삽입의 경우 재할당 오버헤드가 발생할 가능성이 있다. 하지만 reserve 나 resize 를 이용해 재할당 오버헤드를 방지할수 있다.(물론 reserve등을 쓴다고 해도 back_inserter 등의 함수를 사용해야 반복자 위치를 정상적으로 증가 시킬수 있다.)
즉 각 컨테이너의 구현 방식에 따라 효율적인 방법을 선택해야 하는것이다.

결론은 목적지 범위를 지정해야 하는 알고리즘을 사용할 때에는 언제나 이 목적지 범위의 크기를 미리 확보해 두든지, 알고리즘이 실행될 때 자동으로 증가하도록 만들어야 한다는 것이다.