48: 템플릿 메타프로그래밍, 하지 않겠는가?
템플릿 메타프로그래밍(template metaprogramming: TMP)은 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일을 말한다. 템플릿 메타프로그램은 C++ 컴파일러가 실행시키는, C++로 만들어진 프로그램이다.
TMP에는 엄청난 강점이 두 개나 있다.
- TMP를 쓰면 다른 방법으로는 까다롭거나 불가능한 일을 굉장히 쉽게 할 수 있다.
- 템플릿 메타프로그램은 C++ 컴파일이 진행되는 동안에 실행되기 때문에, 기존 작업을 런타임 영역에서 컴파일 타임 영역으로 전환할 수 있다. 몇몇 에러들을 컴파일 도중에 찾을 수 있다.
- 추가적으로, 모든 면에서 효율적일 여지가 많다. 실행 코드가 작아지고, 실행 시간도 짧아지며, 메모리도 적게 잡아먹는다.
항목 47의 advance 함수를 다시 보자.
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++)
--tier;
}
}
}
위의 의사 코드를 진짜 코드로 만들려면 typeid를 쓸 수 있다. 타입 정보를 꺼내는 작업을 런타임에 하겠다는 것이다.
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d)
{
if(typeid(typename std::iterator_traits<IterT>::iterator_category)
== typeid(std::random_access_iterator_tag))
iter += d;
}
else {
if (d >= 0) {
while (d--)
++iter;
}
else {
while (d++)
--tier;
}
}
}
이렇게 typeid 연산자를 쓰는 방법은 특성정보(traits)를 쓰는 방법보다 효율이 떨어진다. 타입 점검 동작이 컴파일 도중이 아니라 런타임에 일어나며, 런타임 타입 점검을 수행하는 코드는 어쩔 수 없이 실행 파일에 들어가야 하기 때문이다. 특성정보 방법이 바로 TMP이기 때문에, if...else를 컴파일 타임에 처리할 수 있다.
typeid 방법은 성능 외에도 컴파일 문제를 일으킬 수 있다는 말을 항목 47에서 했었다.
std::list<int>::iterator iter;
...
advance(iter, 10);
이렇게 하면 다음과 같은 advance 인스턴스가 생길 것이다.
void advance(std::list<int>& iter, int d)
{
if(typeid(typename std::iterator_traits<std::list<int>>::iterator_category)
== typeid(std::random_access_iterator_tag))
iter += d; // 에러!
}
else {
if (d >= 0) {
while (d--)
++iter;
}
else {
while (d++)
--tier;
}
}
}
list<int>::iterator는 양방향 반복자이기 때문에 += 연산을 지원하지 못한다. 하지만 지금은 += 줄까지 실행될 수 없다. if문에서 실패하기 때문이다. 하지만, 그렇다 해도 모든 소스 코드가 제대로 되어 있는지 확인하는 일은 컴파일러의 책무이므로 컴파일 에러이다. 만약 특성정보 기반의 TMP를 썼다면 별도의 함수로 분리되어 에러가 발생하지 않았을 것이다.
TMP는 그 자체가 튜링 완정성을 갖고 있는 것으로 알려져 있다. 변수 선언도 되고, 루프도 실행시킬 수 있으며, 함수를 작성하고 호출하는 것까지도 된다. 단, 이런 것들에 필요한 구문요소가 "보통"의 C++에서 쓰이는 구문요소들과 꽤나 다른 모습을 갖고 있다.
루프를 보자. TMP에는 반복 의미의 진정한 루프는 없기 때문에, 재귀를 사용해서 루프의 효과를 낸다. 그런데 이 재귀조차도 우리가 알고 있는 종류가 아니다. TMP의 루프는 재귀 함수 호출을 만들지 않고 재귀식 템플릿 인스턴스화(recursive template instantiation)를 하기 때문이다.
template<unsigned n>
struct Factorial {
enum { value = n * Factorial<n - 1>::value};
};
template<>
struct Factorial<0>
{
enum { value = 1 };
};
항목 2에서 본 나열자 둔갑술이 쓰였다. Factorial 템플릿은 구조체 타입이 인스턴스화되도록 만들어져 있다. Factorial 템플릿은 다음과 같이 쓰면 된다.
int main()
{
std::cout << Factorial<5>::value; // 120을 런타임 계산 없이 출력
std::cout << Factorial<10>::value; // 3628800을 런타임 계산 없이 출력
}
C++ 프로그래밍에서 TMP가 실력 발휘하는 예를 들어 보면 세 군데이다.
- 치수 단위(dimensional unit)의 정확성 확인
- 속도를 나타내는 변수에 질량을 나타내는 변수를 대입하면 에러다. 하지만 거리 변수를 시간 변수로 나누고 그 결과를 속도 변수에 대입하는 것은 맞다. TMP를 사용하면 프로그램 안에서 쓰이는 모든 치수 단위의 조합이 제대로 됐는지를 맞춰(컴파일 동안에) 볼 수 있다. 그것도 계산 시간 없이!
typedef SquareMatrix<double, 10000> BigMatrix;
BigMatrix m1, m2, m3, m4, m5;
BigMatrix result = m1 * m2 * m3 * m4 * m5;
- 행렬 연산의 최적화
- 위의 계산을 보면 네 개의 임시 행렬이 생겨야 한다. 그리고 원소들 사이에 곱셈을 해야 하므로 네 개의 루프가 순차적으로 만들어질 수 밖에 없다. 이런 비싼 연산에 TMP를 사용할 수 있다. TMP를 응용한 고급 프로그래밍 기술인 표현식 템플릿(expression template)을 사용하면 덩치 큰 임시 객체를 없애는 것은 물론이고 루프까지 합쳐 버릴 수 있다.
- 맞춤식 디자인 패턴 구현의 생성
- 디자인 패턴은 그 구현 방법이 여러 가지일 수 있다. TMP를 사용한 프로그래밍 기술인 정책 기반 설계(policy-based design)라는 것을 사용하면, 따로따로 마련된 설계상의 선택을 나타내는 템플릿을 만들어낼 수 있게 된다.
이것만은 잊지 말자!
- 템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 낸다. 따라서 TMP를 쓰면 선행 에러 탐지와 높은 런타임 효율을 손에 거머쥘 수 있다.
- TMP는 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있다.
'C++ > Effective C++' 카테고리의 다른 글
[Effective C++] 50: new 및 delete를 언제 바꿔야 좋은 소리를 들을지를 파악해 두자 (1) | 2023.11.21 |
---|---|
[Effective C++] 49: new 처리자의 동작 원리를 제대로 이해하자 (1) | 2023.11.21 |
[Effective C++] 47: 타입에 대한 정보가 필요하다면 특성정보 클래스를 사용하자 (0) | 2023.11.18 |
[Effective C++] 46: 타입 변환이 바람직할 경우에는 비멤버 함수를 클래스 템플릿 안에 정의해 두자 (0) | 2023.11.16 |
[Effective C++] 45: "호환되는 모든 타입"을 받아들이는 데는 멤버 함수 템플릿이 직방! (0) | 2023.11.15 |