이펙티브 C++
https://www.yes24.com/Product/Goods/17525589
페이지 340p ~ 350p
이펙티브 C++(340p ~ 350p)
요약
항목 48: 템플릿 메타프로그래밍, 하지 않겠는가?
- 템플릿 메타프로그래밍은 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다.
- 템플릿 메타프로그램은 C++ 컴파일러가 실행시키는 C++로 만들어진 프로그램이다.
- TMP 프로그램이 실행을 마친 후에 결과로 나온 출력물이 일반 컴파일 과정을 거친다.
- TMP는 다음과 같은 강점을 가진다.
- TMP를 쓰면 다른 방법으로는 까다롭거나 불가능한 일을 쉽게 할 수 있다.
- 템플릿 메타프로그램은 컴파일이 진행되는 동안 실행되기 때문에, 기존 작업을 런타임 영역에서 컴파일 영역으로 전환할 수 있다.
// iter를 d 단위만큼 이동
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
if (iter가 임의 접근 반복자이다)
{
iter += d;
}
else {
if (d >= 0) { while (d--) ++iter; }
else { while (d--) --iter; }
}
}
- 이 유사코드를 진짜 코드로 만들려면 typeid를 사용할 수 있다. typeid를 사용한다는 것은 타입 정보를 꺼내는 작업을 런타임에 하겠다는 것과 같다.
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
if (typeid(typename std::iterator_traits<IterT>::iterator_category)
== typeid(random_access_iterator_tag))
{
iter += d;
}
else {
// ...
}
}
- typeid 연산자를 쓰는 방법은 특성정보를 쓰는 방법보다 효율이 떨어진다.
- 타입 점검 동작이 컴파일 도중이 아니라 런타임에 일어난다.
- 런타임 타입 점검을 수행하는 코드는 어쩔 수 없이 실행 파일에 들어가야 한다.
- 따라서 위의 코드는 TMP가 보통의 C++ 프로그램보다 효율이 좋은지 보여주는 단적인 예라고 할 수 있다.
- TMP를 사용해서 if .. else 구문의 처리를 컴파일 타임에 할 수 있게 된다.
- typeid 방법은 성능 외에도 컴파일 문제를 일으킬 수 있다.
std::list<int>::iterator iter;
// iter를 10개만큼 앞으로 옮기고 싶었으나, 컴파일 에러가 발생
advance(iter, 10);
- 위의 코드를 컴파일러가 돌린다고 가정했을 때, 어떤 advance가 만들어질지 생각해보자. 템플릿 매개변수인 IterT와 DistT에 대해 iter의 타입과 10의 타입을 넣으면 다음과 같은 advance가 생길 것이다.
void advance(std::list<int>::iterator& iter, int d)
{
if (typeid(typename std::iterator_traits<IterT>::iterator_category)
== typeid(random_access_iterator_tag))
{
iter += d; // 에러
}
else {
if (d >= 0) { while (d--) ++iter; }
else { while (d--) --iter; }
}
}
- 에러가 발생한 원인은
+=
연산자를 사용한 부분 때문이다. list의 iterator는 양방향 반복자이기 때문에+=
연산자를 지원하지 않는다.+=
연산은 임의 접근 반복자만 가능하다. - 실제로는 if 문에서 typeid 점검이 실패하기 때문에
+=
연산자가 호출되는 라인까지 실행되지도 않지만, 컴파일러는 모든 소스 코드가 제대로 되어 있는지 확인하는 책임이 있기 때문에 에러를 발생시킨다. - TMP에는 반복 의미의 진정한 루프는 없기 때문에 재귀를 사용해서 루프의 효과를 낸다.
- 그런데 이 재귀도 우리가 알고 있는 종류가 아니라 재귀식 템플릿 인스턴스화를 통해 구현한다.
- TMP에서 처음 접하는 프로그램은 컴파일을 통해 팩토리얼을 계산하는 템플릿이다.
- TMP의 팩토리얼 계산에서 재귀식 템플릿 인스턴스화를 통한 루프 효과를 확인할 수 있다.
- TMP에서 변수를 만들어서 사용하는 방법도 엿볼 수 있다.
// 일반적인 경우
template<unsigned n>
struct Factorial {
enum { value = n * Factorial<n-1>::value };
};
// 특수한 경우
template<>
struct Factorial<0> {
enum { value = 1 };
};
- 이렇게 만들어진 템플릿 메타프로그램이 있으면
Factorial<n>::value
를 참조함으로써 n의 계승을 바로 얻을 수 있다. - 이 코드에서 루프를 도는 위치는 템플릿 인스턴스인
Factorial<n>
의 내부에서 또 다른 템플릿 인스턴스인Factorial<n-1>
을 참조하는 곳이다. - 제대로 만들어진 재귀 코드가 그러하듯, 재귀를 끝내는 특수 조건이 있어야 한다.
Factorial<0>
- Factorial 템플릿은 구조체 타입이 인스턴스화되도록 만들어져 있다. 이렇게 만들어진 구조체 안에는
value
라는 이름의 TMP 변수가 선언되어 있는데, enum hack을 사용했다. - TMP는 루프 대신에 재귀식 템플릿 인스턴스화를 사용하기 때문에, 꼬리에 꼬리를 물고 만들어지는 템플릿 인스턴스화 버전마다 자체적으로
value
의 사본을 가지게 되고, 각각의value
는 루// 프를 한 번 돌 때마다 만들어지는 값이 저장된다.
// 120을 런타임 계산 없이 출력
std::cout << Factorial<5>::value;
// 3628800을 런타임 계산 없이 출력
std::cout << Factorial<10>::value;
- C++ 프로그래밍에서 TMP가 사용될 수 있을 부분은 다음과 같다.
치수 단위의 정확성 확인
- 과학 기술 분야의 응용프로그램을 만들 때는 무엇보다도 치수 단위가 똑바로 조합되어야 한다.
- TMP를 사용하면 프로그램 안에서 쓰이는 모든 치수 단위의 조합이 제대로 됐는지를 맞춰 컴파일 동안에 볼 수 있다.
- TMP를 사용하면 단위 조합이 제대로 맞춰줬는지 컴파일 시간에 확인이 가능하기 때문에 런타임에 이러한 조합을 검증하는 로직이 필요하지 않다.
- TMP는 분수식 지수 표현이 지원된다. 이를 위해 컴파일 도중 분수의 약분이 되어야 한다.
행렬 연산의 최적화
operator*
등의 어떤 연산자 함수는 연산 결과를 새로운 객체에 담아 반환해야 한다.- 항목 44에서 만든
SquareMatrix
클래스를 이용해서 다음과 같은 행렬 연산을 하는 코드를 작성했다.
typedef SquareMatrix<double, 10000> BigMatrix;
BigMatrix m1, m2, m3, m4, m5;
BigMatrix result = m1 * m2 * m3 * m4 * m5;
- 곱셈 결과를 보통 방법으로 계산하려면 네 개의 임시 행렬이 생겨야 한다.
operator*
를 한 번씩 호출할 때마다 반환되는 결과로 생기는 것이다.- 행렬 원소들 사이의 곱셈을 해야 하므로 네 개의 루프가 순차적으로 만들어질 수 밖에 없다.
- 이런 비싼 연산에 TMP를 사용할 수 있다. TMP를 응용한 표현식 템플릿을 사용하면 덩치 큰 임시 객체를 없애는 것은 물론이고 루프까지 합쳐 버릴 수 있다.
맞춤식 디자인 패턴 구현의 생성
전략 패턴, 감시자 패턴, 방문자 패턴 등의 디자인 패턴은 그 구현 방법이 여러 가지일 수 있다.
TMP를 사용한 프로그래밍 기술인 정책 기반 설계라는 것을 사용하면 따로따로 마련된 설계상의 선택을 나타내는 템플릿을 만들어낼 수 있다.
이렇게 되면 만들어진 정책 템플릿은 서로 임의대로 조합되어 사용자의 취향에 맞는 동작을 갖는 패턴으로 구현되는 데 쓰인다.
- 몇 개의 스마트 포인터 동작 정책을 하나씩 구현한 각각의 템플릿을 만들어 놓고, 이들의 사용자가 마음대로 조합하여 수백 가지의 스마트 포인터 타입을 생성할 수 있게(컴파일 도중에) 하는 것이다.
생성싱 프로그래밍의 기초가 바로 이 기술이다.
TMP는 누구나 쉽고 재미있게 할 수 있는 프로그래밍은 아니다. 문법은 비직관적이고, 개발도구의 지원도 미약하다.
그러나 기존 작업을 런타임에서 컴파일 타임으로 전환함으로써 얻을 수 있는 효율 향상은 포기하기 쉽지 않다.
8. new와 delete를 내 맘대로
- C++은 수동으로 메모리를 관리하는데, 자신들이 만들 소프트웨어의 메모리 사용 성향을 연구한 후에, 그 연구 결과에 맞추어 메모리 할당 루틴과 해제 루틴을 다듬음으로써 가능한 최대의 수행 성능을 제공할 수 있다.
- 개발자로서 이런 작업이 가능하려면 일단 C++의 메모리 관리 루틴이 어떻게 동작하는지를 면밀히 파악해야 한다.
- 다중 스레드 환경에서의 메모리 관리는 단일 스레드 시스템에서는 경험할 수 없는 화끈한 맛을 느끼게 하는 여러 가지 문젯거리를 안고 있다.
- 힙은 수정 가능한 전역 자원으로 분류된다.
- 다중 스레드 시스템에서는 스레드들이 이런 전역 자원에 미친 듯이 접근하면서 경쟁 상태가 생길 소지가 득실득실해진다고 생각하면 된다.
- 이 부분에 적절한 동기화를 걸지 않으면 아무런 소용이 없다.
항목 49: new 처리자의 동작 원리를 제대로 이해하자
- 사용자가 보낸 메모리 할당 요청을 operator new 함수가 맞추어 주지 못할 경우에(메모리 할당이 실패하는 경우) operator new 함수는 예외를 던지게 되어 있다.
- 옛날에는 널 포인터를 반환했다.
- 메모리 할당이 제대로 되기 못한 상황에 대한 반응으로 operator new가 예외를 던지기 전에, 이 함수는 사용자 쪽에서 지정할 수 있는 에러 처리 함수를 우선적으로 호출하도록 되어 있다.
- 이 에러 처리 함수를 가리켜
new 처리자(new-handler)
라고 한다. - 이와 같은 메모리 고갈 상황을 처리할 함수를 사용자 쪽에서 지정할 수 있도록, 표준 라이브러리에는
set_new_handler
라는 함수가 준비되어 있다. 이 함수는<new>
에 선언되어 있다.
namespace std {
typedef void (*new_handler) ();
new_handler set_new_handler(new_handler p) throw();
}
new_handler
는 받는 것도 없고 반환하는 것도 없는 함수 포인터에 대해 typedef를 걸어 놓은 타입동의어이다.set_new_handler
는new_handler
를 받고new_handler
를 반환하는 함수이다.throw
는 예외 지정이라고 불리는 부분이다.- 이 함수는 어떤 예외도 던지지 않을 것이라는 뜻이다.
set_new_handler
가 받아들이는new_handler
타입의 매개변수는 요구된 메모리를 operator new가 할당하지 못했을 때 operator new가 호출할 함수의 포인터이다. 반환 값은 지금의set_new_handler
가 호출되기 바로 전까지 new 처리자로 쓰이고 있던 함수의 포인터이다.
// 충분한 메모리를 operator new가 할당하지 못했을 때 호출할 함수
void outOfMem()
{
std::cerr << "Unable to satisfy request for memory\n";
std::abort();
}
int main()
{
std::set_new_handler(outOfMem);
int* pBigDataArray = new int[10000000000L];
}
- 만약 operator new가 1억 개의 정수 할당에 실패하면
outOfMem
함수가 호출될 것이고, 이 함수는 에러 메시지를 출력하면서 프로그램을 강제로 끝내 버릴 것이다. - 사용자가 부탁한 만큼의 메모리를 할당해 주지 못하면, operator new는 충분한 메모리를 찾아낼 때까지 new 처리자를 되풀이해서 호출한다.
- new 처리자를 반복 호출하는 코드는 항목 51에서 자세히 살펴보겠지만, 응용프로그램 개발자 입장에서는 굳이 이 부분까지 내려갈 필요는 없다.
- 어쨌든 이를 통해 호출되는 new 처리자 함수가 프로그램의 동작에 좋은 영향을 미치도록 설계되어 있다면 다음 동작 중 하나를 꼭 해주어야 한다.
사용할 수 있는 메모리를 더 많이 확보한다.
- operator new가 시도하는 이후의 메모리 확보가 성공할 수 있도록 하자는 전략이다.
- 구현 방법은 여러 가지가 있지만, 프로그램이 시작할 때 메모리 블록을 크게 하나 할당해 놓았다가 new 처리자가 가장 처음 호출될 때 그 메모리를 쓸 수 있도록 허용하는 방법이 그 한 가지이다.
다른 new 처리자를 설치한다.
- 현재의 new 처리자가 더 이상 가용 메모리를 확보할 수 없다 해도, 이 경우에 자기 몫까지 해 줄 다른 new 처리자의 존재를 알고 있을 가능성도 있다.
- 만약 그렇다면 현재의 new 처리자는 제자리에서 다른 new 처리자를 설치할 수 있다.
- 현재의 new 처리자 안에서
set_new_handler
를 호출하는 것이다.
- 현재의 new 처리자 안에서
- operator new가 다시 new 처리자를 호출할 때가 되면, 새로 설치된 new 처리자가 호출된다.
- 이렇게 만드는 한 가지 방법은 new 처리자의 동작을 조정하는 데이터를 정적 데이터 혹은 네임스페이스 유효범위 안의 데이터, 아니면 전역 데이터로 마련해 둔 후에 new 처리자가 이 데이터를 수정하게 만드는 것이다.
new 처리자의 설치를 제거한다.
- 다시 말해,
set_new_handler
에 널 포인터를 넘긴다. - new 처리자가 설치된 것이 없으면, operator new는 메모리 할당이 실패했을 때 예외를 던진다.
예외를 던진다.
bac_alloc
혹은bad_alloc
에서 파생된 타입의 예외를 던진다.- operator new에는 이쪽 종류의 에러를 받아서 처리하는 부분이 없기 때문에, 이 예외는 메모리 할당을 요청한 원래의 위치로 전파(propagate)된다.
복귀하지 않는다.
- 대개 abort 혹은 exit을 호출한다.
메모
항목 48: 템플릿 메타프로그래밍, 하지 않겠는가?
- 템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 낸다. 따라서 TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있다.
- TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있다.
'책 > 이펙티브 C++' 카테고리의 다른 글
이펙티브 C++(360p ~ 369p) (0) | 2024.02.13 |
---|---|
이펙티브 C++(350p ~ 361p) (1) | 2024.02.12 |
이펙티브 C++(332p ~ 340p) (1) | 2024.02.09 |
이펙티브 C++(312p ~ 320p) (1) | 2024.02.08 |
이펙티브 C++(320p ~ 331p) (1) | 2024.02.08 |