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

반드시 정렬된 컨테이너에만 사용할 수 있는 알고리즘들이 존재한다.

대체로 이진 탐색이나 순차접근으로 동작하는 알고리즘들이 그렇다.

 

binary_search, lower_bound, upper_bound, equal_range

set_union, set_itersection, set_difference, set_symmetric_difference

merge, inplace_merge

includes

 

unique, unique_copy는 정렬된 범위에 사용하는 편이지만 꼭 그렇지 않아도 된다.

또한 알고리즘들은 대체로 오름차순을 가정하고 동작하기 때문에 내림차순이 필요하면 알맞는 비교 함수를 넘겨주어야 한다.

 

그리고 unique, unique_copy를 제외한 정렬된 범위에 동작하는 모든 알고리즘은 두 값이 같은지를 판단할 때 동등성을 기준으로 삼는다. unique, unique_copy는 상등성이다.

 

 

항목 35 : 대소문자를 구분하지 않는 문자열 비교는 mismatch 아니면 lexicographical_compare를 써서 간단히 구현할 수 있다

◾ mismatch (최초로 달라지는 지점)

int ciCharCompare(char c1, char c2) // 문자를 소문자로 변환해서 비교. strcmp 형식
{
    int lc1 = tolower(static_cast<unsigned char>(c1));
    int lc2 = tolower(static_cast<unsigned char>(c2));
    
    if (lc1 < lc2) return -1;
    if (lc1 > lc2) return 1;
    return 0;
}

대소문자를 구별하지 않고 비교하는 쉬운 방법은 문자열을 문자단위로 모두 소문자나 대문자로 바꾸고 하나씩 비교하면 된다.

 

int ciStringCompareImpl(const std::string& s1, const std::string& s2) // mismatch
{
    typedef std::pair<std::string::const_iterator, std::string::const_iterator> PSCI;
    PSCI p = std::mismatch(s1.begin(), s1.end(), s2.begin(),
        std::not2(std::ptr_fun(ciCharCompare)));
    
    if (p.first == s1.end()) {
        if (p.second == s2.end()) return 0;
        else return -1;
        return ciCharCompare(*p.first, *p.second);
    }
}

int ciStringCompare(const std::string& s1, const std::string& s2) // 문자열 길이 판별
{
    if (s1.size() <= s2.size()) return ciStringCompareImpl(s1, s2);
    else return -ciStringCompareImpl(s2, s1);
}

위의 비교함수를 토대로 문자열로 확장시켜서 사용한다.

mismatch는 두 문자열중 하나가 다른것보다 길이가 짧을 때, 짧은 문자열 쪽을 첫째 매개변수로 넘겨주어야 한다.

반환값으로 두 범위 안의 문자가 처음으로 맞지 않기 시작하는 위치를 pair로 반환해준다.

 

...
std::not2(std::ptr_fun(ciCharCompare)));

// std::not2 -> 이항 조건자 함수의 결과를 뒤집는 함수 객체 생성
// std::ptr_fun -> 함수 래퍼 객체 생성. 함수를 함수 객체로 만들어준다. std::function과 비슷한 듯

 

mismatch의 마지막 인자로 넘어가는 함수가 특이하게 생겼는데, mismatch는 넘겨준 함수가 false를 반환하면 즉시 중단한다. 근데 만들어둔 함수는 문자가 같으면 0을 반환하기 때문에 암시적으로 false로 변환되므로 결과를 반대로 해줄 필요가 있다.

 

 

◾ lexicographical_compare (

bool ciCharLess(char c1, char c2)
{
    return tolower(static_cast<unsigned char>(c1)) < tolower(static_cast<unsigned char>(c2));
}

bool ciStringCompare(const std::string& s1, const std::string& s2)
{
    return std::lexicographical_compare(s1.begin(), s1.end(), s2.begin(), s2.end(), ciCharLess);
}

mismatch보다 훨씬 간결하다. 마치 strcmp를 일반화한 알고리즘이다.

두 요소를 비교해서 true가 반환되면 서로 다른값으로 판단하고 본인 역시 true를 반환한다.

 

 

◾ mismatch

문자열 길이가 짧은것을 첫 번째 인자로 넘겨주어야 한다.

인자로 넘겨준 비교 함수가 true를 반환하면 최초로 값이 다른 지점의 반복자를 pair로 반환해준다.

 

◾ lexicographical_compare

인자로 넘겨준 비교 함수가 최초로 값이 다른 지점을 찾으면 true를 반환하고 스스로도 true를 반환한다.

결과적으로 mismatch와 lexicographical_compare는 최초로 값이 다른 부분을 만나면 실행이 중단되는 것이다.

 

만약 오직 문자열 비교만을 할거라면 stricmp를 사용하는게 성능상 더 좋긴 하다. (속도가 중요한 PS에 유용할 것)

 

 

항목 36 : copy_if를 적절히 구현해 사용하자

(참고: copy_if도 이제는 표준 알고리즘이다.)

 

template<typename InputIterator, typename OutputIterator, typename Predicate>
OutputIterator copy_if(InputIterator begin, OutputIterator end,
    OutputIterator destBegin, Predicate p)
{
    while (begin != end) {
        if (p(*begin)) *destBegin++ = *begin; // 아마도 destBegin은 공간을 미리 확보해야 할 것이다
        ++begin;
    }
    return destBegin;
}

 

 

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

◾ accumulate (범위 내 원소들의 총합, 누적합)

algorithm 헤더가 아닌 numeric 헤더에 들어있다. 추가로 inner_product(내적), adjacent_difference(인접 원소의 차), partial_sum(부분합)이 있다.

초기 값을 받아들이는 알고리즘 사용에 대해 주의할점이 있다. 받아들인 값의 타입 그대로 반환되기 때문에 int로 넘겨주고 double로 반환받길 기대한다던지, 혹은 long long같은 큰 수를 반환받길 기대하는것은 계산 오류나 오버플로우가 발생할 수 있다.

 

/* accumulate */
std::vector<int> v = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::accumulate(v.begin(), v.end(), 0);
// 55

/* inner_product */
std::vector<int> v1 = {2, 3, 4};
std::vector<int> v2 = {5, 6, 7};
std::inner_product(v1.begin(), v1.end(), v2.begin(), 0);
// 2*5 + 3*6 + 4*7 = 56

/* adjacent_difference */
std::vector<int> v1 = {1, 2, 5, 10, 15, 12, 20};
std::vector<int> v2;
std::adjacent_difference(v1.begin(), v1.end(), std::back_inserter(v2));
// v2: 1 1 3 5 5 -3 8

/* partial_sum */
std::vector<int> v1 = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
std::vector<int> v2;
std::partial_sum(v1.begin(), v1.end(), std::back_inserter(v2));
// v2: 1 3 6 10 15 21 28 36 45 55

만약 계산하는데 직접 만든 함수를 사용한다면 계산 결과를 타입에 맞게 반환해주어야 한다.

기본적으로 사칙연산에 대한 표준 함수자 클래스가 만들어져있다.

 

여담으로 C++ 표준안에 의하면 accumulate에 넘겨지는 함수의 side effect는 없어야 한다고 한다.

 

◾ for_each

범위 내의 요소에 대해 함수 객체를 호출한다. 여기서는 side effect를 가져도 된다.

accumulate와 다르게 매개 변수로 받은 함수 객체를 반환한다.

만약 실행 결과값이 필요하다면 함수 객체에서 요약 정보를 뽑아낼 방법을 정의해두어야 한다.

 

 

accumulate는 함수 객체의 반환값이 존재해야 하고 연산 결과를 값으로 반환하지만, for_each는 함수 객체의 반환값이 없고 연산 결과가 필요하다면 별도의 함수를 정의해야한다.

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

int transmogrify(int x);

std::vector<int> values;
...
std::vector<int> results;
std::transform(values.begin(), values.end(), results.end(), transmogrify);

위의 코드의 의도는 함수의 반환값을 results.end부터 삽입하라는 의미이다.

하지만 동작하지 않는다. 왜냐면 results.end()는 객체가 존재하지 않기 때문이다.

 

std::transform(values.begin(), values.end(), std::back_inserter(results), transmogrify);

대신 삽입 반복자를 사용한다. back_inserter는 push_back을 호출하도록 되어있기 때문에 push_back을 지원하는 컨테이너에만 사용할 수 있다.

back_inserter 말고도 front_inserter(앞에서 추가), inserter(임의 위치부터 추가)도 존재한다.

 

if (results.size() < values.size()) results.resize(values.size());

std::transform(values.begin(), values.end(), results.begin(), transmogrify);

만약 결과값을 기존 컨테이너에 덮어씌우고 싶다면 resize를 통해서 최소한의 크기는 확보하는게 안전하다.

혹은 results를 clear한 이후 삽입 반복자를 호출하면 된다.

 

결론적으로 목적지 범위를 지정하여 수행하는 알고리즘은 범위의 크기를 미리 확보하거나 자동으로 증가되도록 만들어야 한다.

 

 

항목 31 : 정렬시의 선택 사항들을 제대로 파악해 놓자

전체 정렬이 필요한 경우 sort, 전체 정렬을 하되 순서 유지성이 필요하면 stable_sort, 부분 정렬이 필요한 경우 partial_sort, 상위 값이 필요하되 정렬여부는 관계 없다면 nth_element를 사용하면 된다.

 

// 전체 정렬
std::sort(widget.begin(), widget.end(), qualityCompare);

// 전체 정렬 (순서 유지)
std::stable_sort(widget.begin(), widget.end(), qualityCompare);

// 부분 정렬 (상위 20개)
std::partial_sort(widget.begin(), widget.begin() + 20, widget.end(), qualityCompare);

// 상위 값 추출 (단, 지정된 반복자만 정렬 순서 보장. 이경우 widget.begin() + 20)
std::nth_element(widget.begin(), widget.begin() + 20, widget.end(), qualityCompare);

 

partial_sort, nth_element는 값이 동등관계인 경우 아무거나 골라낸다.

nth_element는 상위 n개의 요소를 찾아내는 것 외에 범위 내의 중앙값을 찾거나 특정한 백분위 위치에 있는 값을 찾아내는 데에도 사용할 수 있다.

 

다만 위의 알고리즘들은 임의 접근 반복자를 지원해주는 컨테이너에만(vector, deque) 사용이 가능하다. (list는 사용불가)

 

한가지 알고리즘이 더 있다. 컨테이너의 요소를 특정 조건을 기준으로 좌우로 분할한다.

 

bool hasAcceptableQuality(const Widget& w)
{
    // Widget 객체의 등급이 2등급 이상인지 판별
}

auto goodEnd = std::partition(widget.begin(), widget.end(), hasAcceptableQuality);

어떤 조건을 기준으로 조건을 만족한 값은 begin~goodEnd 사이에, 만족하지 못한 값은 goodEnd~end 사이에 위치하게 된다.

partition은 위의 알고리즘과 다르게 양방향 반복자 이상이면 사용할 수 있으므로 표준 시퀀스 컨테이너 모두에 대해 동작한다.

 

◾ 전체 정렬 (sort, stable_sort) : vector, string, deque, 배열

◾ 부분 정렬 (partial_sort) : vector, string, deque, 배열

◾ 상위 N개 요소 (nth_element) : vector, string, deque, 배열

◾ 기준에 따른 요소 구분 (partition, stable_partition) : 표준 시퀀스 컨테이너

 

partition -> stable_partition -> nth_element -> partial_sort -> sort -> stable_sort 순으로 성능이 점점 나빠진다.

 

 

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

remove는 이름과 달리 범위 내의 요소를 재배치할 뿐이다. 다만 삭제하기 원치 않은 요소들을 앞으로 당겨오며 덮어쓸 뿐이다.

대신 remove가 이루어지고 나면 새로운 end 반복자를 반환해주기 때문에 여기서부터 본래 반복자의 end까지를 erase 시켜주면 실제 의도한대로 삭제가 이루어진다.

 

v.erase(std::remove(v.begin(), v.end(), 99), v.end());

remove_if나 unique 역시 동일하게 동작하기 때문에 erase를 같이 써주어야한다.

 

예외적으로 list의 멤버 함수로 제공되는 remove, unique는 실제로 삭제가 이루어진다.

 

 

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

동적 할당된 포인터를 별도의 해제 없이 erase-remove하게 된다면 remove에서 요소가 앞으로 당겨지며 덮어씌워질 때, 메모리 누수가 발생한다. 덮어 씌워진 포인터는 찾을 방법이 아예 없어진다.

대신 partition으로 나누고 뒷부분에 대해서 delete를 실행해준 뒤 erase를 하면 문제가 없을 것이다.

 

더 좋은 방법으로는 스마트 포인터를 사용하는 것이다. 딱히 고려할것도 없이 바로 erase-remove를 수행해도 된다.

'도서 > Effective STL' 카테고리의 다른 글

함수자, 함수 객체, 함수, 기타 등등 (1)  (0) 2022.12.20
알고리즘(Algorithms) (2)  (0) 2022.12.20
반복자(Iterators)  (0) 2022.12.20
STL 연관 컨테이너 (2)  (0) 2022.12.20
STL 연관 컨테이너 (1)  (0) 2022.12.20

항목 26 : const_iterator나 reverse_iterator, const_reverse_iterator도 좋지만 역시 쓸만한 것은 iterator이다

iterator->const_iterator의 변환은 가능하지만 역은 성립이 되지 않기 때문에 요소의 삽입이나 삭제 위치를 지정하는데 있어서 상수 반복자는 대개 쓸모가 없다.

'대개' 쓸모가 없다는 것이지 아예 쓸모가 없는건 아니다. 알고리즘은 반복자의 종류에 상관없이 동작하기 때문이다.

다만 어떤 형태의 insert와 erase는 꼭 iterator만을 넘겨야만 한다.

 

typedef std::deque<int> IntDeque;
IntDeque::iterator i;
IntDeque::const_iterator ci;
...

if (i == ci)

위의 코드는 iterator를 const_iterator로 암시적인 변환을 해주지 않는다면 컴파일러에 따라 오류를 발생시킬 수도 있다.

 

웬만하면 반복자의 타입을 이것저것 사용하지 않고 iterator만 사용하는 것이 낫다.

 

 

항목 27 : const_iterator를 iterator로 바꾸는 데에는 distance와 advance를 사용하자

const_iterator에 const_cast를 한다고 상수성이 제거되지는 않는다. (vector, string은 예외)

하지만 생각보다 쉽게 안전하고 이식성 있게 변환시킬 수 있다.

const_iterator의 거리를 구해서 iterator를 그만큼 이동시키면 끝이다.

 

std::deque<int> d;
typedef std::deque<int>::iterator Iter;
std::deque<int>::const_iterator CIter; // d의 요소를 가리킨다
...

Iter i(d.begin();
CIter ci = d.find(3);

std::advance(i, std::distance(i, ci));
// std::advance(i, std::distance<CIter>(i, ci));
// const_iterator->iterator 변환이 안된다면 템플릿 인자로 명시한다

또 하나 언급하자면 vector같은 임의 접근 반복자는 상수시간, 양방향 반복자는 선형시간에 특정 반복자에 접근할 수 있기 때문에 정말 필요한게 아니라면 const_iterator를 iterator로 변환하지 않는것이 좋다.

 

 

항목 28 : reverse_iterator에 대응되는 기점 반복자를 사용하는 방법을 정확하게 이해하자

 

std::vector<int> v;

for(int i = 1; i <= 5; ++i) {
    v.push_back(i);
}
std::vector<int>::reverse_iterator ri = std::find(v.rbegin(), v.rend(), 3);
std::vector<int>::iterator i(ri.base());

 

코드를 실행하면 iterator와 reverse_iterator는 그림과 같이 배치된다. ri.base()는 순향 반복자 기준 다음 위치를 반환해준다.

 

◾ 만약 요소의 삽입이 이뤄진다면?

insert는 iterator가 가리키는 위치 바로 앞에서 이뤄진다. 그래서 base로 반환받은 순향 반복자에 별다른 연산을 할 필요없이 바로 삽입을 수행하면 된다.

 

◾ 만약 요소의 삭제가 이뤄진다면?

삽입과 다르게 가리키고 있는 요소를 삭제하기 때문에 대응되지 않는다. 그래서 base로 반환받은 반복자의 한칸 앞의 요소를 삭제해주어야 한다.

그런데 vector나 string의 경우, 반복자가 const T*로 되어있기 때문에 아예 역방향 반복자를 한칸 더 이동시키고 base를 반환받아서 사용해야한다.

 

v.erase((++ri).base());
// --ri.base() 가 아닌 이유 : 
// 벡터는 반복자가 const T*로 이뤄져있기 때문에 반환받은 반복자에 연산을 할 수 없다

 

 

항목 29 : 문자 단위의 입력에는 istreambuf_iterator의 사용도 적절하다

std::ifstream inputFile("interestingData.txt");
std::string fileData((std::istream_iterator<char>(inputFile)), std::istream_iterator<char>());

아쉽게도 위의 코드는 공백 문자를 복사하지 못한다. istream_iterator는 operator<<를 사용하고 공백 문자를 건너뛰기 때문이다.

 

inputFile.unsetf(std::ios::skipws);

입력 스트림의 skipws 플래그를 해제하면 공백 문자를 건너뛰지 않겠지만 생각보다 복사 시간이 빠르지 않다.

 

std::ifstream inputFile("interestingData.txt");

std::string fileData((std::istreambuf_iterator<char>(inputFile)), std::istreambuf_iterator<char>());

대신 istreambuf_iterator를 사용하면 스트림 자체의 버퍼를 직접 건드려서 다음 문자들을 바로 읽어들인다.

skipws의 플래그도 해제할 필요가 없다.

'도서 > Effective STL' 카테고리의 다른 글

알고리즘(Algorithms) (2)  (0) 2022.12.20
알고리즘(Algorithms) (1)  (0) 2022.12.20
STL 연관 컨테이너 (2)  (0) 2022.12.20
STL 연관 컨테이너 (1)  (0) 2022.12.20
vector와 string  (0) 2022.12.18

항목 22 : set과 multiset에 저장된 데이터 요소에 대해 키를 바꾸는 일은 피하자

잠깐만 생각해봐도 당연한 얘기다. 데이터가 삽입될 때 정렬되어서 관리되는데, 이미 삽입된 요소의 값을 바꾸면 정렬 상태가 무너진다.

다만 map과 다르게 const T가 아닌 그냥 T가 저장되기 때문에 정렬 기준과 상관없는 값을 바꿀수는 있다.

 

class Employee {
public:
    ...
    void setTitle(const std::string& title);
    int idNumber() const; // 정렬 기준
};

auto i = se.find(selectedID);
...
i->setTitle("Corporate Deity"); // 변경 가능

객체에 데이터가 여러개있고 일부 데이터만 정렬 기준(key)이 된다면 그 외의 데이터는 수정되어도 정렬 상태가 무너지지 않는다.

만약 set의 반복자 역참조가 T&가 아닌 const T&로 반환한다면, 캐스팅을 통해 참조자를 반환받은뒤에 값을 수정하면 된다.

 

map의 경우는 키값을 저장할때부터 상수화시키기 때문에 함부로 캐스팅을 통해 상수 성질을 없애지 않는것이 좋다

 

만약 저장된 요소를 바꾸고 싶다면 아래 단계를 순서대로 거치면 호환성과 안전성을 챙길 수 있다.

 

  1. 변경하고자 하는 컨테이너 요소의 위치를 찾는다.
  2. 해당 요소의 복사본을 만든다.
  3. 원래 컨테이너에서 해당 요소를 삭제한다.
  4. 복사본의 데이터를 수정한다.
  5. 복사본을 컨테이너에 새로 삽입한다. 만약 새로 삽입되는 요소가 기존 요소와 위치가 같거나 바로 옆이라면 3번에서 삭제될 때의 반복자를 넘겨서 삽입 시간을 상수 시간으로 단축시킬 수 있다.

 

 

항목 23 : 연관 컨테이너 대신에 정렬된 vector를 쓰는 것이 좋을 때가 있다

데이터 셋업, 데이터 탐색, 데이터 재구성 순으로 자료 구조를 사용한다면 연관 컨테이너보다 벡터가 훨씬 나을 수 있다.

 

연관 컨테이너는 균형 이진 탐색(AVL) 트리를 사용하는데, 연속 메모리 구조가 아니라 트리 노드로 구현되어 있기 때문에 요소 하나 저장하는데 최소 세 개의 포인터를 사용하므로 오버헤드가 발생한다.

근데 벡터는 재할당이 이루어지지 않는 한 오버헤드가 없다.

 

만약 저장해야 하는 데이터의 수가 매우 많다고 할 때, 연관 컨테이너는 벡터와 같은 데이터의 양을 저장하더라도 오버헤드로 인해 사용 메모리가 많아지고, 페이지 하나당 저장되는 데이터 개수의 더 적기 때문에 캐시 적중률이 떨어지고, 페이지 부재도 더 빈번하게 발생할 수 있다.

 

그래서 정렬된 벡터에 한해서는 연관 컨테이너보다 더 나은 성능을 보여줄 가능성이 높다.

제약사항이 한가지 더 있긴 하다. 삽입, 삭제, 탐색이 빈번하지 않은 경우여야 한다는 점이다.

 

typedef std::pair<std::string, int> Data;

class DataCompare {
public:
    bool operator()(const Data& lhs, const Data& rhs) const { // 정렬용 비교 함수
        return keyLess(lhs.first, rhs.first);
    }
    bool operator()(const Data& lhs, const Data::first_type& k) const { // 탐색용 비교 함수1
        return keyLess(lhs.first, k);
    }
    bool operator()(const Data::first_type& k, const Data& rhs) const { // 탐색용 비교 함수2
        return keyLess(k, rhs.first);
    }
private:
    bool keyLess(const Data::first_type& k1, const Data::first_type& k2) const { // 실제 비교 함수
        return k1 < k2;
    }
};

덧붙여서 벡터를 써서 맵을 대신하려면 키값이 const여서는 안된다. 정렬될 때 각 요소의 값이 대입 연산을 통해 이동되기 때문이다. 만약 구현하게 된다면 정렬용 비교 함수 외에도 탐색용 비교 함수를 따로 만들어 두어야 한다.

 

 

항목 24 : map::operator[]나 map::insert는 효율 문제에 주의하여 선택하자

class Widget {
public:
    Widget();
    Widget(double weight);
    Widget& operator=(double weight);
    ...
};

std::map<int, Widget> m;
m[1] = 1.50; // == m.operator[](1)
m[2] = 3.67; // 만약 key에 2가 없다면?
...

맵은 key-value 페어로 이루어져 있다. 이미 존재하는 key라면 value의 참조자를 반환하여 value를 수정하고 없는 key라면 새로운 key-value 페어를 생성한다.

새로운 페어를 생성할 때, value_type의 기본 생성자를 이용해서 새로운 객체를 만들어낸다.

 

m[1] = 1.50;

/* 위의 코드는 아래로 풀이된다 */

std::pair<std::map<int, Widget>::iterator, bool> result = 
    m.insert(std::map<int, Widget>::value_type(1, Widget()));
    
result.first->second = 1.50;

키 검사->페어 생성->기본 생성자로 임시객체 생성->value에 복사->임시객체 소멸->value.operator=호출 순으로 이루어진다.

만약 기본 생성자 대신 원하는 값을 직접 매개변수에 넘겨주면 호출 횟수가 많이 줄어들 것이다.

 

m.insert(std::map<int, Widget>::value_type(1, 1.50));

 

만약 추가가 아닌 갱신이라면 오히려 insert보다 operator[]가 더 효율적이다.

 

template<typename MapType, typename KeyArgType, typename ValueArgType>
typename MapType::iterator efficientAddOrUpdate(MapType& m, const KeyArgType& k, const ValueArgType& v) {
    typename MapType::iterator lb = m.lower_bound(k); // 이진 탐색

    // 값 갱신
    if (lb != m.end() && !(m.key_comp()(k, lb->first))) { // 탐색 성공 && 동등성 검사
    // 탐색이 성공했다고 해서 값이 같은것은 아니기 때문에 동등성 검사가 필요하다
        lb->second = v;
        return lb;
	}
    // 값 추가
    else {
        typedef typename MapType::value_type MVT;
        return m.insert(lb, MVT(k, v));
    }
}

하나의 함수로 추가와 갱신을 판단해서 각자 효율적인 동작을 선택하도록 구현해보면 대략적으로 위와 같다.

 

 

항목 25 : 현재는 표준이 아니지만, 해쉬 컨테이너에 대해 충분히 대비해 두자

(참고: 지금은 표준이다. unordered_map, unordered_set)

해쉬 컨테이너는 다른 연관 컨테이너와 다르게 비교 함수를 동등성 검사가 아닌 상등성 검사로 한다.

정렬 순서가 무의미하기 때문이다.

'도서 > Effective STL' 카테고리의 다른 글

알고리즘(Algorithms) (1)  (0) 2022.12.20
반복자(Iterators)  (0) 2022.12.20
STL 연관 컨테이너 (1)  (0) 2022.12.20
vector와 string  (0) 2022.12.18
효과적인 컨테이너 요리법 (2)  (0) 2022.12.16

연관 컨테이너는 시퀀스 컨테이너와 다르게 상등성이 아닌 동등성의 관점으로 요소를 처리한다.

또한 자동으로 요소가 정리된다.

 

항목 19 : 상등 관계(equality)와 동등 관계(equivalence)의 차이를 파악하자

find 알고리즘과 set의 insert는 기본적으로 "두 값이 같은가?"를 알아내는것은 같지만 동작 방식은 다르다.

"같다"의 정의는 find는 상등성, insert는 동등성에 두고있다.

조금 더 쉽게 얘기하면 상등성은 operator==(같다, 틀리다), 동등성은 operator<(크다, 작다)로 판별한다.

 

◾ 상등성

개념적 뿌리는 operator==에 두고있다.

x==y가 참이면 x, y는 같다. 하지만 x, y가 같다고 해서 모든 데이터 멤버가 같다는 보장은 없다.

 

class Widget {
...
private:
    TimeStamp lastAccessed;
};

bool operator==(const Widget& lhs, const Widget& rhs)
{
    // lastAccessed 필드 고려 X
}

lastAccessed 필드를 고려하지 않았기 때문에 lastAccessed 값이 다르더라도 다른 데이터 멤버가 동일하면 두 객체는 같다는 판정을 내린다.

 

◾ 동등성

정렬된 범위 안에 있는 개체들의 상대적인 순서로 판단한다.

객체 x, y가 있을 때, x, y 모두가 서로의 앞에 오지만 않으면 동등하다고 본다.

 

!(w1 < w2) && !(w2 < w1)

이 식이 성립하면 동등하다고 본다. 서로의 값이 서로의 앞에 오지 않으면 두 값은 특정한 정렬 기준에 대해서 동등한 것이다.

 

실제로 연관 컨테이너에 사용되는 비교 함수는 operator<가 아니라 기본적으로 less 이다.

 

!c.key_comp()(x,y) && !c.key_comp()(y,x)

c.key_comp는 비교용 함수를 반환해준다.

 

대소문자를 구분하지 않는 set에서 문자열 "STL"과 "stL"은 상등 관계가 아니지만 동등 관계이다.

표준 연관 컨테이너가 상등성이 아닌 동등성에 기반을 두고 만들어진 이유는 비교 함수를 딱 하나만 지정해주기 위해서이다. 만약 상등성에 기반을 두었다면 정렬용 비교 함수 말고도 값이 같은지를 알아내는 함수가 하나 더 필요하다.

 

연관 컨테이너에 값을 넣을때는 동등 관계로 넣고 값을 찾을때는 상등 관계로 찾는다.

 

 

항목 20 : 포인터를 저장하는 연관 컨테이너에 대해서는 적합한 비교(비교 함수자) 타입을 정해주자

연관 컨테이너는 요소를 삽입할 때 자동으로 정렬이 된다.

만약 타입이 포인터라면 포인터 값을 기준으로 정렬을 하기 때문에 별도의 비교 함수를 정해주어야 한다.

 

std::set<string*> ssp;
ssp.insert(new std::string("Anteater"));
...

auto i = ssp.begin();
*i; // 포인터 값
**i; // 데이터

역참조를 두번 하면 데이터에 접근할 수 있지만 애초에 정렬 기준이 데이터가 아니라서 저장 순서도 뒤죽박죽이다.

 

struct StringPtrLess : public std::binary_function<const std::string*, const std::string*, bool> {
    bool operator()(const std::string* ps1, const std::string* ps2) const {
        return *ps1 < *ps2;
    }
};

기본으로 지정되는 비교 함수자 대신에 포인터가 가리키는 요소를 비교하는 임의의 비교 함수 객체를 만들어서 선언시 지정해주면 된다.

 

typedef std::set<std::string*, StringPtrLess> StringPtrSet;

StringPtrSet ssp;
ssp.insert(new std::string("Anteater"));
...

string의 포인터를 저장하기 때문에 여전히 데이터에 접근하려면 두 번의 역참조가 이뤄져야 하지만 정렬 기준은 의도한대로 데이터를 기준으로 정렬된다.

 

struct DereferenceLess {
    template<typename PtrType>
    bool operator()(PtrType pT1, PtrType pT2) const {
        return *pT1 < *pT2;
    }
};

std::set<std::string*, DereferenceLess> ssp;

멤버 함수를 템플릿으로 만들면 각 포인터의 타입별로 함수 객체를 만들 필요 없이 범용적으로 사용이 가능하다.

 

꼭 원시 포인터가 아니더라도 포인터처럼 동작하는 스마트 포인터나 반복자 역시 동일하게 적용해야한다.

 

 

항목 21 : 연관 컨테이너용 비교 함수는 같은 값에 대해 false를 반환해야 한다

항목 19 내용의 연장선이다.

set의 비교 함수는 기본적으로 less이다. 만약 less_equal일때의 상황을 가정해보자.

 

std::set<int, std::less_equal<int>> s;
s.insert(10);
s.insert(10); // 중복 값 삽입 시도

 

!(10A <= 10B) && !(10B <= 10A)
// !true && !true => false

중복된 값의 삽입 시도를 했음에도 둘의 값은 동등하지 않다는 결론을 내리고 삽입을 하게된다.

 

연관 컨테이너의 비교 함수는 같은 값에 대해 항상 false를 반환해야 구조가 무너지지 않는다.

관련 정보(strict weak ordering) : https://panty.run/strict-weak-ordering/

'도서 > Effective STL' 카테고리의 다른 글

반복자(Iterators)  (0) 2022.12.20
STL 연관 컨테이너 (2)  (0) 2022.12.20
vector와 string  (0) 2022.12.18
효과적인 컨테이너 요리법 (2)  (0) 2022.12.16
효과적인 컨테이너 요리법 (1)  (0) 2022.12.16

+ Recent posts