한가지 더 흥미로운 점은 3이라는 값이 프로그램이 실행되면서 계산된는 것이 아닌, 컴파일 시에 컴파일러가 three::num 을 3으로 치환해버린다.
-> 다시 말해서 아래 덧셈이 수행되는 시키는 컴파일 타임이고, 런타임에는 단순히 그 결과를 보여주는 것이다.
std::cout << "Addtion result : " << three::num << std::endl;
템플릿 메타 프로그래밍 (Template Meta Programming - TMP)
지금까지 타입은 어떤한 개체에 무엇을 저장하느냐를 지정하는데 사용해 왔지, 타입 자체가 어떠한 값을 가지지는 않았다. (초기화 전까지는 어떠한 값을 가지지는 않는다)
하지만 위 예제처럼 템플릿을 사용하면 개체를 생성하지 않더라도, 타입에 어떤한 값을 부여할 수 있고, 또 그 타입들을 가지고 연산을 할 수 있다는 점이다.
또한 타입은 컴파일 타임에 확정되어야 하기 때문에, 컴파일 타임에 모든 연산이 끝나게 된다.
이렇게 컴파일 타임에 생성되는 코드로 프로그래밍 하는 것을 메타 프로그래밍(meta programming) 이라 하고, C++ 의 경우 템플릿을 가지고 이러한 작업을 하기 때문에, 템플릿 메타 프로그래밍, 줄여서 TMP 라고 한다.
/* 컴파일 타임 팩토리얼 계산 */
#include <iostream>
template <int N>
struct Factorial {
static const int result = N * Factorial<N - 1>::result;
};
template <>
struct Factorial<1> {
static const int result = 1;
};
int main() {
std::cout << "6! = 1*2*3*4*5*6 = " << Factorial<6>::result << std::endl;
}
만약 템플릿 메타 프로그래밍을 사용하지 않는다면, 다음과 같이 재귀 함수를 통해 구현할 수 있다.
따라서, 우리는 아래 처럼 재귀 함수 호출이 끝나게 하기 위해서는, n 이 1 일 때 따로 처리를 해주어야 한다.
int factorial(int n) {
if(n == 1) return 1; // 재귀함수 종료 조건
return n * factorial(n-1);
}
템플릿 역시 n=1 일때 템플릿 특수화를 통해 아래와 같이 처리할 수 있다.
template<>
struct Factorial<1> {
static const int result = 1;
};
위 예제에서 볼 수 있듯이, 저기서 실질적으로 값을 가지는 개체는 아무 것도 없다.
-> 즉, 720 이라는 값을 가지고 있는 변수는 메모리 상에 없다는 것이다.
-> 출력 결과로 나타내는 720 이라는 값은, 단순히 컴파일러가 만들어낸 Factorial<6> 이라는 타입을 나타내고 있을 뿐이다.
사실 보통 factorial 함수를 만든다면, 십중팔구 그냥 단순하게 for 문으로 구현을 하였을 것이다.
하지만 안타깝게도 템플릿으로는 for 문을 사용할 수 없기 떄문에, 위와 같은 재귀적 구조를 사용할 수 밖에 없다.
한가지 다행인 소식은 for 문으로 구현할 수 있는 모든 코드는 똑같이 템플릿 메타 프로그래밍을 통해서 구현할 수 있다.
TMP 를 왜 쓰는가?
한가지 재밌는 사실은 어떠한 C++ 코드도 템플릿 메타 프로그래밍 코드로 변환할 수 있다는 점이다.
(물론 코드량은 엄청나게 길어진다..)
게다가 템플릿 메타 프로그래밍으로 작성된 코드는 모두 컴파일 타임에 모든 연산이 끝나기 때문에, 프로그램 실행 속도를 향상 시킬 수 있다는 장점이 있다.
(당연하게 컴파일 시간은 엄청 늘어나게 된다..)
하지만 그렇다고 해서 템플릿 메타 프로그래밍으로 프로그램 전체를 구현하는 일은 없다. 일단 템플릿 메타 프로그래밍은 매우 복잡하다.
-> 프로그래밍은 트레이드오프의 연속이다..
그 뿐만 아니라, 템플릿 메타 프로그래밍으로 작성된 코드는 버그를 찾는 것이 매우 힘들다.-> 일단 기본적으로 컴파일 타임에 연산하는 것이기 때문에, 디버깅이 불가하고,
(디버깅은 컴파일 이후에 이루어진다)
-> C++ 컴파일러에 특성 상 템플릿 오류 시에 엄청난 길이의 오류를 내뿜게 된다.
때문에, TMP 를 이용하는 경우는 꽤나 제한적이지만, 아래와 같은 장점 때문에 많은 C++ 라이브러리 들이 TMP 를 이용해서 구현되었다.
TMP 를 통해서 컴파일 타임에 여러 오류들을 잡아낼 수 있고(타입 체크),
속도가 매우 중요한 프로그램의 경우 TMP 를 통해서 런타임 속도도 향샹시킬 수 있다.
조금 더 복잡한 예제를 가지고 TMP 를 어떻게 사용할 지에 대해서 생각해보자.
보통 컴퓨터 상에서 두 수의 최대공약수를 구하기 위해서는 보톤 유클리드 호제법을 사용한다.
이를 일반적인 함수로 구현하면 다음과 같다.
int gcd(int a, int b) {
if (b == 0) {
return a;
}
return gcd(b, a % b);
}
이를 TMP 로 표현하면 다음과 같다.
#include <iostream>
template <int X, int Y>
struct GCD {
static const int value = GCD<Y, X % Y>::value;
};
template <int X>
struct GCD<X, 0> {
static const int value = X;
};
int main() {
std::cout << "gcd (36, 24) :: " << GCD<36, 24>::value << std::endl;
}
이 최대공약수 계산 클래스를 만든 이유는, 바로 Ratio 클래스를 만들기 위함인데, Ratio 클래스는 유리수를 오차 없이 표현해 주는 클래스이다.
Raito 클래스를 TMP 방식으로 아래와 같이 만들 수 있다.
분모와 분자를 템플릿 인자로 받고, 타입을 나타내게 된다.
template <int N, int D = 1>
struct Ratio {
typedef Ratio<N, D> type; // 편의를 위해 typedef 로 선언
static const int num = N;
static const int den = D;
};
이 Ratio 로 덧셈을 수행하는 템플릿을 만들어보자, 상당히 직관적이다.
-> 두 분수를 더한 결과를 Ratio 에 분자 분모로 전달하면 알아서 기약분수로 만들어준다.