들어가기에 앞서, 용어와 관례

class Widget {
public:
    Widget(Widget&& rhs); // 타입은 오른값 참조이지만 rhs 자체는 왼값이다
    ...
};

일반적으로 주소를 취할 수 있으면 왼값, 아니면 오른값이다.

 

대체로 오른값의 복사본은 이동 생성되고 왼값의 복사본은 복사 생성된다.

그래서 단순히 어떤 객체가 다른 객체의 복사본이라는 것만 알고 있다면 생성 비용을 알 수 없다. 왼값, 오른값 여부를 알아야 한다.

 

함수에 전달한 표현식은 인수이고 매개변수를 초기화 하는데 쓰인다.

 

람다 표현식을 통해 만들어진 함수 객체를 클로저라고 부른다. 그런데 딱히 구분짓지 않고 표현식이든 클로저든 둘다 람다라고 부른다.

 


 

 

template<typename T>
void f(ParamType param);

f(expr);

이런 경우 컴파일러는 expr에 해당되는 표현식을 이용해서 두 가지 타입을 추론한다. 하나는 T에 대한 타입, 하나는 ParamType에 대한 타입이다.

 

template<typename T>
void f(const T& param);
...
int x = 0;
f(x); // T는 int, ParamType은 const int&로 추론된다

그런데 보통 ParamType에는 위처럼 const나 참조자가 붙어서 T와 ParamType의 타입이 다른 경우가 매우 흔하다.

T에 대해 추론된 타입은 expr의 타입에 의존할 뿐만 아니라 ParamType의 타입에도 의존하게 되는 것이다.

이런 경우는 아래와 같은 세 가지 경우로 나뉜다.

 

Case1 : ParamType이 포인터 또는 참조 타입이지만 보편 참조는 아닌 경우

template<typename T>
void f(T& param);
...
int x = 27; // x = int
const int cx = x; // cx = const int
const int& rx = x; // rx = const int&

f(x); // T = int, param = int&
f(cx); // T = const int, param = const int&
f(rx); // T = const int, param = const int&

expr의 타입이 참조 타입이라면 참조 부분을 무시한다. 그 뒤 expr의 타입을 ParamType에 대해 패턴 부합(pattern-matching) 방식으로 대응시켜서 T의 타입을 결정한다.

 

여기서 중요한 부분은 cx, rx에 const 값이 배정되었기 때문에 param의 타입 역시 const int& 타입으로 추론된다.

rx는 참조자로 넘겨줬음에도 불구하고 타입 추론 과정에서 rx의 참조성이 무시되어 cx와 동일하게 취급된다.

 

template<typename T>
void f(const T& param);
...
f(x); // T = int, param = const int&
f(cx); // T = int, param = const int&
f(rx); // T = int, param = const int&

만약 param의 타입이 T&가 아닌 const T&라고 해도 크게 달라지는건 없다. 다만 이제는 param이 항상 const에 대한 참조로 간주되기 때문에 굳이 T의 일부로 추론될 필요가 없어진다.

포인터에 대해서도 다를것 없이 동일한 규칙이 적용된다.

 

param의 타입에 의존하여 T의 타입이 추론된다

책에서 언급된 내용은 아니지만 덧붙이자면 param의 타입이 const T&인 경우 T의 타입이 모두 int로 추론되기 때문에 함수의 인스턴스화가 한번만 이루어진다. T&라면 int, const int 두개로 인스턴스화 된다.

 

Case2: ParamType이 보편 참조인 경우

템플릿이 보편 참조 매개변수(&&)를 받는 경우 조금 복잡해진다. 전달된 인수가 왼값인지, 오른값인지에 따라 추론이 달라지기 때문이다.

 

만약 왼값이라면 T와 ParamType 둘 다 왼값 참조로 추론된다. Case1에서는 표현식의 참조 부분을 무시한다고 했지만 이 경우에만 유일하게 T가 참조 타입으로 추론된다. 또한 ParamType의 선언 구문 자체는 오른값 참조이지만 실제로 추론되는 타입은 왼값 참조이다.

말이 길어지면 이해가 어려울테니 코드를 보면서 다시 얘기해보자.

 

template<typename T>
void f(T&& param); // param = 보편 참조
...
int x = 27;
const int cx = x;
const int& rx = x;

f(x); // x = 왼값, T = int&, param = int&
f(cx); // cx = 왼값, T = const int&, param = const int&
f(rx); // rx = 왼값, T = const int&, param = const int&

매개변수가 오른값 참조로 작성되어있다면 T의 타입은 왼값 참조자로 추론되는것을 확인할 수 있다.

 

그렇다면 오른값이라면 어떨까? 단순하다. Case1과 완전 동일하게 처리된다.

 

f(27); // 27 = 오른값, T = int, param = int&&

 

Case3: ParamType이 포인터도 아니고 참조도 아닌 경우

template<typename T>
void f(T param);

말을 길게 써놔서 그렇지 값에 의한 전달이 이루어지는 상황이다.

param은 인수의 복사본이므로 완전히 새로운 객체이다. 새로운 객체이기 때문에 expr에서 T가 추론되는 과정에서 Case1,2와 다른 규칙들이 적용되게 된다.

 

우선 expr의 참조 타입을 무시하는것 까지는 Case1과 동일하다. 그 이후 expr이 const이면 const 역시 무시하고 volatile라면 그것까지 전부 무시한다.

 

int x = 27;
const int cx = x;
const int& rx = x;

f(x); // T = int, param = int
f(cx); // T = int, param = int
f(rx); // T = int, param = int

앞선 상황들과 다르게 참조, const를 모두 무시한다. 값에 의한 전달로 인해 복사가 이루어지고 완벽히 독립적인 객체가 됐기 때문에 expr의 제약을 따를 필요가 전혀 없기 때문이다.

 

다만 값 전달 매개변수에 대해서만 무시될 뿐이지 const 참조나 포인터인 매개변수에 대해서는 조금 더 생각해봐야한다.

 

template<typename T>
void f(T param); // 값에 의한 전달

const char* const ptr = "Fun with pointers"; // const 객체를 가리키는 const 포인터
f(ptr); // const char* const 타입의 인수 전달

포인터와 포인터가 가리키는것까지 모두 const가 된 상태이다. 즉, ptr은 다른 주소를 가리키지도 못하고 역참조를 통해 다른 값으로 변경하지도 못한다.

 

이때 ptr이 param에 전달되면 위의 규칙처럼 const가 무시되어서 ptr이 값으로 복사된다. 하지만 const가 무시되는것은 ptr에 대해서만이지 char*에 대한 const는 무시되지 않는다.

그래서 결과적으로 param에 대해 추론되는 타입은 const char*이 된다. const 문자열을 가리키는 수정 가능한 포인터가 된다.

 

 

 

위의 세 가지 경우가 템플릿의 타입 추론이 관여하는 대부분의 상황이고 조금 특수한 상황이 또 존재한다.

 

Case4 : 배열 인수

배열과 포인터를 구분하지 않고도 사용할 수 있지만 실제로 배열의 타입과 포인터의 타입은 다르다. 그럼에도 서로 맞바꿔가며 쓸 수 있는것은 많은 문맥에서 배열이 배열의 첫번째 원소를 가리키는 포인터로 붕괴(decay)되기 때문이다.

 

붕괴(decay)?

배열이 크기 정보를 잃고 포인터로 암시적 변환이 이루어 지는 것

 

const char name[] = "J. P. Briggs"; // const char[13]
const char* ptrToName = name; // 배열이 포인터로 붕괴된다

const char*와 const char[13]의 타입은 서로 같지 않지만 배열에서 포인터로의 붕괴 규칙덕분에 타입 불일치 오류 없이 잘 컴파일 된다.

 

template<typename T>
void f(T param);
...
f(name); // 어떤 타입으로 추론?

그런데 배열을 값 전달 매개변수를 받는 템플릿에 전달하면 어떻게 될까?

우선, 배열 타입의 함수 매개변수라는 개념은 없다는 것부터 짚고 넘어가야 한다.

 

void myFunc(int param[]);
void myFunc(int* param);
/* 동일한 함수 */

개념이 없다 뿐이지 구문 자체는 적법하다. 배열 매개변수는 포인터 매개변수처럼 취급된다.

 

f(name); // T = const char*

즉, name은 포인터 타입으로 추론되어서 const char*가 된다.

 

그런데 함수의 매개변수로 배열을 선언할수는 없지만 배열의 대한 참조 선언은 가능하다. 이게 또 무슨말인가?

 

template<typename T>
void f(T& param); // Case1
...
f(name); // T = const char[13], param = const char(&)[13] (const char[13]&)

특별한건 아니고 Case1의 경우로 추론된다.

 

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept { return N; }
// constexpr : 일반화된 상수 표현식. 컴파일 타임에 평가된다
// noexcept : 예외가 발생하지 않을것임을 명시
...
int keyVals[] = {1, 3, 7, 9, 11, 22, 35};
int mappedVals[arraySize(keyVals)];
// 또는 std::array<int, arraySize(keyVals)> mappedVals;

여담으로 배열에 대한 참조를 선언하는걸 활용하면 배열에 담긴 원소의 개수를 컴파일 타임에 추론하는 템플릿을 만들 수 있다.

 

Case5: 함수 인수

배열이 포인터로 붕괴하는 것처럼 함수도 함수 포인터로 붕괴할 수 있다.

 

void someFunc(int, double);

template<typename T>
void f1(T param); // Case3

template<typename T>
void f2(T& param); // Case1

f1(someFunc); // 함수 포인터로 추론. param = void(*)(int, double)
f2(someFunc); // 함수 참조로 추론. param = void(&)(int, double)

 

◾ 템플릿 타입 추론 도중에 참조 타입의 인수들은 비참조로 취급된다. 즉, 참조성이 무시된다.

◾ 보편 참조 매개변수에 대한 타입 추론 과정에서 왼값 인수들은 특별하게 취급된다.

◾ 값 전달 방식의 매개변수에 대한 타입 추론 과정에서 const, volatile 인수는 비 const, 비 volatile 인수로 취급된다.

◾ 템플릿 타입 추론 과정에서 배열이나 함수 이름에 해당하는 인수는 포인터로 붕괴한다. 단, 그런 인수가 참조를 초기화하는 데 쓰이는 경우에는 포인터로 붕괴하지 않는다.

항목 47 : 쓰기 전용(write-only) 코드는 만들지 말자

std::vector<int> v;
int x, y;
...
v.erase(
    std::remove_if(std::find_if(v.rbegin(), v.rend(),
                   std::bind2nd(std::greater_equal<int>(), y)).base(),
            v.end(),
        std::bind2nd(std::less<int>(), x)),
    v.end());

보기만 해도 현기증이 나는 코드이다.

x보다 작은 값을 모두 지우지만, y보다 크거나 같은 값 중 가장 마지막 것의 앞에 있는 것들은 모두 그대로 두는 코드이다.

 

협업을 한다면 절대적으로 지양해야 하는 코드이다. (작성자가)쓰기에는 쉽지만 (다른 사람들이)읽고 이해하는데에 있어서 너무 짜증이 나는 코드인 것이다.

 

물론 상황에 따라 다르긴 하겠지만 유지보수 측면에서 봤을때는 쓰기 전용 코드를 절대적으로 지양해야 한다.

 

 

항목 48 : 용도에 맞는 헤더를 항상 #include하자

예를 들어 <string>은 <iostream> 헤더만 포함 시켜도 사용할 수 있다. (VS2022 C++14 기준)

string을 사용하게 된다면 확실하게 <string> 헤더를 포함시켜 주어야 한다. 이식성의 문제이다.

헤더 안에 선언된 것을 사용할 때는 목적에 맞는 헤더를 포함시켜 주어야 한다.

 

 

항목 49 : STL에 관련된 컴파일러 진단 메시지를 해석하는 능력을 가지자

◾ vector와 string의 경우 반복자는 포인터와 똑같다. 그래서 반복자를 가지고 실수를 했다면 컴파일러 오류 메시지는 포인터 타입을 언급할 확률이 100%에 근접한다.

 

◾ 삽입 반복자에 오류 대한 메시지는 삽입 반복자를 호출할 때 오류가 발생했다는 뜻이다.

 

◾ 출력 반복자, 삽입 반복자는 대입 연산자의 내부에서 동작이 이뤄지기 때문에 오류 발생시 대입 연산자를 언급하게 된다.

 

◾ 알고리즘의 내부가 잘못 되었다는 오류가 발생되었다면 함께 사용한 타입에 문제가 있는것이다.

 

덧붙여서 할당자를 받는 생성자는 웬만하면 쓰지 말자.

const 멤버 함수 안에서는 모든 비정적 데이터 멤버는 무조건 상수 멤버가 된다.

 

 

항목 50 : STL 관련 웹 사이트와 친구하자

(참고 : 너무 오래된 정보라서 넘어간다)

◾ STL?

컨테이너, 반복자, 알고리즘, 함수 객체로 구성된 라이브러리

 

 

항목 43 : 어설프게 손으로 작성한 루프보다는 알고리즘이 더 낫다

알고리즘은 대체로 반복자 2개를 매개변수로 받아서 범위로 작동한다.

범위 사이의 모든 객체를 훑는다는 점에서 루프와 일맥상통한다. 아니, 직접 루프를 사용해서 작성할 일들을 알고리즘이라는 이름 하에 정리해놓은 것들이다.

 

std::list<Widget> lw;
...
for (auto i = lw.begin(); i != lw.end(); ++i) i->redraw();
std::for_each(lw.begin(), lw.end(), std::mem_fun_ref(&Widget::redraw));
// 둘의 동작은 동일하다

직접 작성한 루프보다도 한번의 알고리즘 호출이 대체적으로 더 괜찮다.

그 이유는 효율, 정확성, 유지보수성의 측면에 있다.

 

◾ 효율

STL 제작자가 컨테이너의 구현 방식을 충분히 파악하고 있기 때문에 최적화가 잘 이루어져 있고, 컴퓨터 공학적인 알고리즘을 사용하기 때문에 웬만하면 직접 만드는것보다 훨씬 효율적이다. 또한 부가적으로 쓸데없는 계산량을 줄인다.

 

◾ 정확성

루프 안에서는 반복자를 계속 관리해주어야 한다. 증감, 무효화 여부 모두 신경써야 한다.

 

대체로 알고리즘이 간결하기 때문에 코드 가독성 측면에서 바라보면 알고리즘을 선택할 수밖에 없다.

또한 for, while, do를 보면 루프가 나온다는건 알지만 무엇을 하는지 대강 파악하려 해도 처음부터 살펴보아야 한다.

하지만 알고리즘은 이름에서부터 어떤일을 하는지 명시되기 때문에 대강 파악할 수 있다.

 

(이후 나오는 내용은 함수자를 만드는 과정이 나오는데 요즘은 람다식을 넘겨주면 된다.)

 

알고리즘이 이미 제공하고 있거나 제공하는 동작과 비슷한 일을 해야한다면 알고리즘을 쓰자.

 

 

항목 44 : 같은 이름을 가진 것이 있다면 일반 알고리즘 함수보다 멤버 함수가 더 낫다

알고리즘 함수와 이름이 같은 컨테이너 멤버 함수들이 몇개 있다.

이런 것들은 멤버 함수를 써주는게 낫다. 더 빠르고 해당 컨테이너에 더 잘 맞물려 있기 때문이다.

 

std::set<int> s; // 값이 100만개가 있다면?
...
auto i = s.find(727); // 로그 탐색시간. 최악40회, 평균20회
auto i = std::find(s.begin(), s.end(), 727); // 선형 탐색시간. 최악100만회, 평균50만회

예를들어 연관 컨테이너는 이진 탐색 트리로 구성되어 있어서 멤버 함수를 사용하면 빠른 탐색이 가능하다. 하지만 일반 알고리즘 함수를 사용하면 정렬 여부를 따지지 않기 때문에 선형 탐색을 시도하므로 더 오래걸린다.

 

또한 맵의 경우 멤버 함수는 key만 고려하여 동작할 뿐, value는 신경쓰지 않는다. 하지만 일반 알고리즘 함수는 동등성을 기준으로 페어에 들어있는 두 멤버를 모두 고려하여 동작한다.

 

list같은 경우는 remove처럼 아예 동작이 다르거나 sort처럼 아예 사용이 불가능할수 있다.

 

 

항목45 : count, find, binary_search, lower_bound, upper_bound, 그리고 equal_range를 제대로 파악해 두자

 

◾ 컨테이너가 정렬되지 않은 경우 : count, find

탐색에 선형 시간이 걸린다. 상등성으로 판단한다.

count : 찾는 값이 몇개나 있는가

find : 찾는 값이 어디에 있는가

 

◾ 컨테이너가 정렬된 경우 : binary_search, lower_bound, upper_bound, equal_range

탐색에 로그 시간이 걸린다. 동등성으로 판단한다.

binary_search : 찾는 값의 존재 여부. (위치 반환X)

lower_bound : 찾는 값이 존재하면 첫번째 원소의 위치, 존재하지 않으면 삽입될 적절한 위치 반환.

{1,3,5,7,9}에서 5를 탐색하면 5의 위치, 8을 탐색하면 9의 위치가 반환된다.

upper_bound : 찾는 값이 존재하면 마지막 원소의 다음 위치, 존재하지 않으면 삽입될 적절한 위치 반환.

{1,3,5,7,9}에서 5를 탐색하면 7의 위치, 8을 탐색하면 9의 위치가 반환된다.

equal_range : 찾는 값의 범위를 pair로 반환해준다. 각각 lower_bound, upper_bound의 반환값이다. 두 반복자 사이의 거리를 구하면 중복되는 원소의 개수를 알 수 있다.

 

erase와 lower_bound, upper_bound를 같이 사용하면 특정 값 이상 또는 초과되는 값의 범위를 모두 지울 수 있다.

 

 

연관 컨테이너는 상황이 약간 다르다. binary_search를 제외한 위의 알고리즘들을 멤버 함수로 제공해주고 find나 count의선형 시간을 걱정할 필요도 없다.

 

출처 : https://baboruri.tistory.com

 

 

항목 46 : 알고리즘의 매개 변수로는 함수 대신 함수 객체가 괜찮다

함수 포인터보다 함수 객체가 추상화 손실을 고려하더라도 성능이 더 좋다. 이유는 인라이닝 때문이다.

둘다 인라인으로 선언된 경우에 함수 객체는 인라이닝 되는 반면, 함수 포인터로 호출된 함수는 인라이닝 되지 않는다.

왜냐면 인라이닝은 컴파일 타임에 이루어져야 하는데 함수 포인터는 컴파일 타임에 확정을 지을 수 없기 때문이다.

 

효율과 상관없이 컴파일과 관련해서도 함수 객체를 사용해야 한다.

 

std::set<std::string> s;

std::transform(s.begin(), s.end(), std::ostream_iterator<std::string::size_type>(std::cout, "\n"),
    std::mem_fun_ref(&std::string::size)); // 될 것 같은데 컴파일이 안된다

/*  */

struct StringSize : public std::unary_function<std::string, std::string, std::string::size_type> {
    std::string::size_type operator()(const std::string& s) const { return s.size(); }
};

std::transform(s.begin(), s.end(), std::ostream_iterator<std::string::size_type>(std::cout, "\n"),
    StringSize()); // 컴파일이 잘 된다

 

이것뿐만 아니라 함수 객체는 미묘한 언어 문제도 막아준다.

 

/* 이항 함수 템플릿 average가 있을 때, */
template<typename FPType>
FPType average(FPType val1, FPType val2) { ... }

transform(..., average<typename iterator_traits<InputIter1>::value_type>);

템플릿 매개변수 타입으로 2개의 인자를 받는 이항 함수를 함수 포인터로 넘겨주는 경우 표현식이 모호해질수 있다.

무슨 얘기냐 하면 컴파일러는 단항 함수 템플릿 average가 존재할수도 있다고 판단해서 컴파일 오류를 발생시킨다.

 

template<typename FPType>
FPType average(FPType val) { ... }
// 이게 존재하면 어떤 템플릿을 인스턴스화 시켜야 하는지 모호해진다

함수 객체는 이런 걱정이 없으니 웬만하면 인자로 넘겨줄 때는 함수 객체를 사용하도록 하자.

항목 40 : 함수자 클래스는 어댑터 적용이 가능하게(adaptable) 만들자

◾ 어댑터?

이미 구현된 기능은 그대로 활용하고, 인터페이스만 변형시킨 것.

구현된 기능의 일부를 막거나 고정시켜서 사용할 수 있지만 없는 기능을 만들수는 없다.

 

컴포넌트 어댑터 : 스택, 큐, 우선순위 큐

반복자 어댑터 : 역방향 반복자

함수 객체 어댑터 : 부정자, 바인더, 함수 포인터 어댑터

 

함수 객체 어댑터 적용이 가능하려면 typedef 몇 가지를 가지고 있어야 한다.

앞선 예제로 not2, ptr_fun을 사용했던 적이 있는데, ptr_fun이 바로 단항 함수 포인터를 단항 함수 객체로 바꿔서 반환해주는 역할을 한다. 단항 함수 객체로 바뀔 때, typedef 몇 가지가 추가되어서 함수 객체 어댑터인 not2(부정자)에 사용이 가능했던 것이다.

 

보통 unary_function, binary_function을 상속받아서 operator()를 구현할 때, operator()의 매개 변수는 const T&지만 템플릿 매개 변수는 const와 참조자를 빼는것이 상례이다.

그런데 예외적으로 포인터를 매개변수로 받는 경우는 양쪽 다 const T* 타입으로 맞춰준다.

 

혹시라도 두 개의 기능을 합친답시고 operator()를 두개를 만드는 짓은 하지 말아야한다.

 

여담으로 함수자 클래스를 만들 때 클래스, 구조체 둘중 하나를 사용하는데 개인 취향의 영역으로 보인다.

논리적으로는 함수자 클래스가 상태를 가진다면 캡슐화라는 명목으로 클래스를 사용하는게 맞다.

 

 

항목 41 : ptr_fun, mem_fun, mem_fun_ref의 존재에는 분명한 이유가 있다

(참고: mem_fun은 더 이상 사용되지 않고 대신 mem_fn이 사용된다)

 

void test(Widget& w);
std::vector<Widget> vw;

std::for_each(vw.begin(), vw.end(), test);

비멤버 함수를 포인터나 객체로 만들어서 인자로 전달 후 호출하는것은 여태까지 많이 해왔기때문에 무리없이 구현하여 실행시킬 수 있다.

 

class Widget {
public:
    ...
    void test() {}
};

std::for_each(vw.begin(), vw.end(), &Widget::test); // 될까?

그런데 만약 비멤버 함수가 아닌 멤버 함수라면 어떻게 해야될까?

위의 코드는 당연히 실행이 안된다. 인스턴스화 되지도 않은 객체의 멤버 함수를 호출하는것은 말도 안되기 때문이다.

게다가 for_each의 내부 구현은 비멤버 함수에 대해 호출하게끔 되어있다.

 

멤버 함수를 비멤버 함수 객체처럼 만들어주는것이 mem_fun, mem_fun_ref이다. ptr_fun과 같이 인터페이스를 바꿔주는 어댑터라고 볼 수 있다.

 

std::list<Widget*> lpw;
...
std::for_each(lpw.begin(), lpw.end(), std::mem_fun(&Widget::test));

mem_fun으로 만들어진 함수 객체에 의해 객체의 멤버 함수가 정상적으로 호출이 된다.

 

 

항목 42 : less<T>는 operator<의 의미임을 꼭 알아두자

class Widget {
public:
    size_t weight() const;
    size_t maxSpeed() const;
    ...
};

bool operator<(const Widget& lhs, const Widget& rhs)
{
    return lhs.weight() < rhs.weight();
}

Widget 객체를 무게를 기준으로 정렬하는 operator<의 구현은 위와 같다.

그런데 만약 Widget 객체를 multiset에 담을 때, 무게가 아닌 속도를 기준으로 정렬하고 싶다면 어떻게 해야할까?

 

첫 번째로, less<T>를 특수화 시키는 것이다.

 

template<>
struct std::less<Widget> : public std::binary_function<Widget, Widget, bool> {
    bool operator()(const Widget& lhs, const Widget& rhs) const {
        return lhs.maxSpeed() < rhs.maxSpeed();
    }
};

multiset의 기본 비교 함수는 less<T>이고, less<T>는 T객체에 정의되어 있는 operator<를 호출하게 된다.

하지만 템플릿 특수화를 통해 특정 타입에 대해서만 다르게 동작하게 제작할 수 있다.

less<Widget> 으로 Widget 객체에 한해서만 다르게 동작하도록 하면 되는 것이다.

 

 

두 번째로, 함수자 클래스를 만들어서 템플릿 매개변수로 넘겨주면 된다. less<Widget> 대신 다른걸 만들어서 넣어주면 되는 것이다.

 

struct MaxSpeedCompare : public std::binary_function<Widget, Widget, bool> {
    bool operator()(const Widget& lhs, const Widget& rhs) const {
        return lhs.maxSpeed() < rhs.maxSpeed();
    }
};

std::multiset<Widget, MaxSpeedCompare> widgets;
// std::multiset<Widget> widgets; -> less<Widget> 호출

자주 봐왔던거라 딱히 특별하지도 않다.

 

어쨌든 less를 사용하겠다면 반드시 operator<의 의미를 가지게 해야한다는게 결론이다. 다른 기준이 필요하다면 less가 아닌 함수자 클래스를 만들어서 사용해야한다.

항목 38 : 함수자 클래스는 값으로 전달되도록(pass-by-value) 설계하자

C/C++의 함수 포인터는 값으로 전달된다. STL의 함수 객체는 함수 포인터를 본따서 만든것이기 때문에 마찬가지로 값으로 전달(복사)된다.

함수 객체가 반드시 값에 의한 전달로만 이뤄져야 하는건 아니지만 대체로 그렇다.

 

또한 함수 객체는 웬만하면 최대한 작게 만들고 단형성을 유지하는게 좋다. 가상함수를 가지지 말라는 것인데 그렇다고 다형성을 가진 함수 객체를 만들지 말라는 것은 아니다.

 

브릿지 패턴(또는 Pimpl idiom, Pimpl 관용구 라고도 함)을 사용하면 함수 객체를 작게 유지하면서 마치 가상 함수를 사용하는 듯한 함수 객체를 만들수도 있다.

 

template<typename T>
class BPFC : public unary_function<T, void> {
public:
    virtual void operator()(const T& val) const; // 가상 함수라서 슬라이스 문제가 생길 수 있음
private:
    Widget w; // 많은 데이터들
    int x;
    ...
};

예를 들어 이런 함수자 클래스가 있다고 하자. 데이터도 많고 가상함수로 인한 슬라이스 문제도 발생할 위험이 있다.

 

template<typename T>
class BPFCImpl {
private:
    virtual ~BPFCImpl();
    virtual void operator()(const T& val) const;
private:
    Widget w;
    int x;
    ...
    friend class BPFC<T>; // 접근 허용
};

template<typename T>
class BPFC : public std::unary_function<T, void> {
public:
    void operator()(const T& val) const { pImpl->operator()(val); } // 가상 함수가 아니다
private:
    BPFCImpl<T> *pImpl; // 크기도 작아졌다
};

인터페이스와 구현부를 분리함으로써 작고, 단형성을 가진 함수자 클래스가 되었다.

 

한가지 고려해야 할 점은 값에 의한 전달로 인해 복사가 일어나므로 복사 생성자에서 구현부 포인터를 적절히 처리해야 한다.

가장 쉬운 방법으로는 스마트 포인터로 만들어서 참조 횟수를 증가시키는 것이다.

 

 

항목 39 : 술어 구문은 순수 함수로 만들자

◾ 술어 구문(술어 함수) : bool을 반환하는 함수. STL에서 폭넓게 사용된다.

◾ 순수 함수 : 함수 내에서 값이 변하지 않는 함수

◾ 술어 구문 클래스 : operator()가 술어 구문인 함수자 클래스

 

술어 구문 함수는 반드시 순수 함수이어야 한다. 이유는 아래 코드를 보면서 설명한다.

 

class BadPredicate : public std::unary_function<Widget, bool> {
public:
    BadPredicate() : timesCalled(0) {}
    bool operator()(const Widget&) { return ++timesCalled == 3; } // 3회 호출시 true 반환
}
private:
    size_t timesCalled;
};

std::vector<Widget> vw;
vw.erase(std::remove_if(vw.begin(), vw.end(), BadPredicate()), vw.end());

3회 호출 시 true를 반환하는 술어 구문 클래스이다. 문제는 true가 반환되어 요소가 삭제(이동)될때이다.

true가 반환되어 복사가 이루어지면 술어 구문 클래스의 기본 복사 생성자에 의해 timeCalled가 다시 0으로 초기화 되어 3회 호출하면 6번째 요소가 삭제가 되며 계속 반복된다.

 

template<typename FwdIterator, typename Predicate>
FwdIterator remove_if(FwdIterator begin, FwdIterator end, Predicate p) // 여기서 복사 1번
{
    begin = std::find_if(begin, end, p); // 여기서 1번
    if (begin == end) return begin;
    else {
        FwdIterator next = begin;
        return remove_copy_if(++next, end, begin, p); // 여기서 1번
    }
}

remove_if의 구현을 보면 술어 구문인 p가 최대 3번까지 값으로 전달(복사) 되는것을 확인할 수 있다.

물론 복사될때마다 timeCalled는 계속 0으로 초기화된다.

 

처음 의도한것은 3회 호출 시 딱 한번만 요소를 삭제하는것인데 결과적으로 순수 함수가 아니기 때문에 복사에 의해 의도치 않은 결과가 발생했다.

위와 같은 자기 모순에 빠지지 않기 위해서는 operator()를 const로 선언하면 된다.

 

다만 mutable같은 예외 경우가 있기 때문에 아예 순수 함수로 만드는것이 안전하다.

+ Recent posts