템플릿 메타프로그래밍

템플릿 메타프로그래밍(template metaprogramming)은 템플릿을 사용하는 프로그래밍 기법으로, 컴파일러에게 프로그램 코드를 생성하도록 하는 방식이다. 이러한 기법은 컴파일 시점에 많은 것을 결정하도록 하여, 실행 시점의 계산을 줄여준다. 이 기법은 C++ 프로그래밍 언어에서 주로 사용된다.

예제 편집

다음의 예는 C++ 언어로 기술하였다.

메타프로그래밍 기법 없이, 계승 함수는 다음과 같이 재귀적으로 구현할 수 있다.

int factorial(int n) {
  if (n == 0)
    return 1;
  return n * factorial(n - 1);
}

// factorial(4) == (4 * 3 * 2 * 1) == 24
// factorial(0) == 0! == 1

이 경우 factorial(4)의 값은 실행 시점에 계산된다.

반면 템플릿 메타프로그래밍과 재귀의 탈출 조건을 제공하는 템플릿 특수화를 사용하면, 프로그램에서 사용되지 않는 펙토리얼들은 구할 것도 없이, 사용되는 펙토리얼의 값만을 컴파일할 때 구할 수 있다.

template <int N>
struct Factorial {
  enum { value = N * Factorial<N - 1>::value };
};

template <>
struct Factorial<0> {
  enum { value = 1 };
};

// Factorial<4>::value == 24
// Factorial<0>::value == 1

위 두 예제는 기능적 측면에서는 유사해 보이지만, 첫 번째 예제는 프로그램이 실행될 때 계산하고 두 번째 예제는 컴파일할 때 값을 구하여 사용한다. 두 번째 예제와 같이 사용하기 위해서 컴파일러는 컴파일할 때 템플릿의 인자를 정확히 알고 있어야 하므로, Factorial<X>::value가 컴파일 시점에 계산되기 위해서, X는 컴파일 시점에 알 수 있는 상수 값이어야 한다.

템플릿 메타프로그래밍은 알기 힘든 문법 구조를 가지고 있으나 쓸모있는 용도가 있다. 어떤 값 n이 있고 이 값이 컴파일 시간에 전달된다고 하면, n 차원 벡터 클래스를 만들 수 있다. 고전적인 방법으로 n 차원 벡터를 만드는 것보다 반복문이 필요 없고, 상당히 최적화된 코드를 얻을 수 있어 좋다.

아래의 덧셈 연산자 코드를 살펴보자. n 차원 벡터를 추가하기 위해 아래처럼 짜여질 수도 있다.

template<int dimension>
Vector<dimension>& Vector<dimension>::operator+=(const Vector<dimension>& rhs) {
  for (int i = 0; i < dimension; ++i) {
    value[i] += rhs.value[i];
  }
  return *this;
}

컴파일러는 위에서 정의한 템플릿 함수를 초기화할 때, 아래와 같은 코드를 만든다.

template<>
Vector<2>& Vector<2>::operator+=(const Vector<2>& rhs) {
  value[0] += rhs.value[0];
  value[1] += rhs.value[1];
  return *this;
}

컴파일러 최적화 도구가 반복문인 'for'를 제외하는 까닭은, 템플릿 인자인 'dimension'이 컴파일 시간에 전달되는 상수이어서이다. 이 기법의 실제 구현을 보려면 filpcode.com에 올려진 글을 참고하라.

C++에서, 템플릿 메타프로그래밍은 튜링 완전성을 갖고 있는데, 이게 뜻하는 바는 다음과 같다. 컴퓨터는 표현 가능한 어떤 계산도 처리할 수 있는데, 어떤 형태에서는 템플릿 메타프로그램에 의해서도 가능하다는 뜻이다.

템플릿 메타프로그램으로 된 어떠한 형태든지 프로그램된 표현으로 처리하기 때문이다.

이 기법으로 작성된 프로그램은 변수 값을 바꿀 수 없다. 이 의미는 일단 초기화된 값은 바꿀 수 없다는 뜻이다. 이런 결과로, 템플릿 메타프로그래밍은 C++과는 다른 함수형 프로그래밍 형태로 나타난다. 이것은 메타프로그램에서의 순서 제어는 재귀를 통해 이루어지기 때문이다.

장점 편집

  • 빨라지는 실행 시간: 연산을 컴파일 시점에 이미 다 처리해두었기 때문에 실행 코드가 더 효율적이다.
    • 문맥이 상수이면 컴파일러가 이를 즉치 연산으로 바꿔넣을 수 있다. 반복문을 직접 대입으로 대체하거나 함수 호출을 없앨 수 있다.
    • 상수 전파(constant propagation) 최적화가 더 잘 되게 된다.
    • 인라이닝(inline) 처리가 더 많이 되며, 보편 참조를[1] 통해 변수 생성과 소멸 비용이 없어지거나 줄어들 수 있다.
    • 정적 다형성을 구현하면 동적 다형성(가상 함수)에 비해 실행시간 비용이 줄어든다. 대체재로 RTTI(Runtime Type Information)가 있지만 훨씬 느리다.
    • 상수가 아닌 경우 넣을 수 없는 문맥에 템플릿 정보를 활용할 수 있다. 배열의 개수 등이 이에 해당된다. 이를 통해 동적 할당을 하지 않고 스택 메모리에 넣으면 메모리 접근 속도가 더 빨라진다.
  • 일반화 프로그래밍(Generic Programming)
    • 내부 자료형이 확정되지 않았는데도 클래스나 함수를 만들어내는 것이 가능하다. 자료구조 구현할 때 템플릿을 안 쓰고는 제대로 만들 수 없다.
    • 타입에 상관없이 암시적 다형성을 통한 코드 문맥만 맞으면 동작한다. 이를 이용해 정적 다형성 기술을 구현할 수 있다.
    • 심지어 무한한 개수의 템플릿 매개변수를 전달하는 것도 (이론상) 가능하다. 가변 함수 매개변수나 매크로와는 달리 타입 안정성도 갖출 수 있다.
    • 자료형을 유지한 채로 다른 함수에 넘겨줄 수 있다. 보편 참조[1]를 통해 이를 달성할 수 있다.
    • CRTP(Curiously Repeated Template Pattern) 기술을 사용하면 기반 클래스에서 자식 클래스의 멤버에 접근할 수 있다.
    • SFINAE와 같은 기법을 이용하여 타입 연산을 통해 타입 안정성을 유지한 채로 특정 자료형만 사용하도록 강제할 수 있다.
  • 암시적 자료형 변환을 막을 수 있다. 기본 자료형이 함수로 넘어올 때 암시적 변환되지 않도록 막으려면 템플릿 std::enable_if 나 꼬리표 분배 기법 등을 써서 제한해버리면 된다. 대체재인 explicit는 생성자와 operator 함수에만 사용 가능하며 제대로 막히지 않는 문제가 있다.
  • 버그를 조기 발견할 수 있다. TMP 기법을 사용하여 실행시간 처리를 컴파일 시간으로 가져왔을 때의 장점 중 하나.

단점 편집

  • 더 오래 걸리는 컴파일 시간
    • 연산을 처리하기 위해 분석해야 하는 계산량이 훨씬 늘어난다. 컴파일 속도는 보통 크게 문제가 되지는 않지만, 대형 프로젝트나 템플릿에 많이 의존하는 프로젝트는 이 점이 중요할 수 있다.
  • 템플릿 작성 자체의 어려움
    • 템플릿은 거의 항상 헤더 파일에 있어야 한다. 특정 cpp 파일 내부에서만 사용할 것이 아니라면 항시 헤더에 작성해야 한다.
    • 템플릿을 소스 코드에 분리할 수 있는 방법을 제공하는 몇몇 특수한 컴파일러가 아니라면 선언(declaration)과 정의(definition)을 분리할 수가 없다. 만약 두 파일로 쪼갠다면 매우 높은 확률로 서로간의 #include 순환 참조 구조가 발생하며, 특수한 상황에서 이 문제를 해결했다고 하더라도 사용자는 정의 파일을 #include 해야 하는데 이러면 구현 파일 이름을 일반형으로, 선언 파일 이름을 다른 방식으로 쓰는 익숙하지 않은 방식이 되기 쉽다. 보통 구현에 impl 접미어를 붙이는 점을 생각해보자. 이게 반대로 된다.
    • 암시적 자료형 변환과 명시적 변환을 섞어 쓸 수가 없다. 템플릿은 무조건 명시적 자료형 변환만 취급하기 때문. 이 문제를 해결하려면 다수의 오버로딩이나 외부 암시적 변환을 받아들이는 함수를 써야 한다.
    • TMP에서는 통상적인 반복문을 쓸 수 없다. 무조건 재귀만 사용 가능. 단 템플릿 내부의 일반 코드에서는 상관없다. 타입에 대한 재귀 코드를 작성하는 일은 매우 고되고 상당한 숙련도를 요구한다.
    • 함수 템플릿과 클래스 템플릿의 동작은 미묘하게 다르다. 함수 템플릿을 특수화하지 말아야 하는 이유[2]를 찾아보자.
  • 가독성
    • 템플릿 메타프로그래밍의 문법과 형태는 일반적인 C++ 프로그래밍에 비해 훨씬 난해하다. 아예 다른 메타언어 또는 스크립트 언어로 취급하는 것이 좋다.
    • 타입 제한을 위해 작성한 SFINAE 문장이 함수 본문보다 몇 배 더 길어지는 배와 배꼽이 뒤바뀐 상황도 볼 수 있다.
    • 디버깅 시에 나오는 오류 메시지가 장황하고 복잡하다. 타입 연산이 들어가기 때문에 실행시간 오류가 한 줄로 끝날 부분이 한 '화면'을 넘어가는 경우도 생긴다. 수십, 수백 줄에 걸쳐 변수 하나를 표현한 오류를 보고 있자면 분석할 방법부터 찾고 봐야 한다. 이는 정의 없는 템플릿 클래스에 타입을 집어넣어서 확인하는 기법이나 템플릿 오류 분석기 등을 동원해야 수월하게 넘길 수 있다.
  • 컴파일러마다 템플릿을 사용하는 방법이 조금씩 다르다. 표준을 완벽히 준수하는 컴파일러가 드물기 때문에 이식성 문제를 겪고 있다.
  • 라이브러리 보안 문제
    • 배포할 때 다른 개발자들에게 내부 코드를 강제로 공개하게 된다.
    • static이나 이름 없는 namespace를 통해 다른 파일에서 실행되지 못하게 막을 수가 없게 된다.
  • extern "C" 가 동작하지 않게 된다. 이는 외부 C 함수에서 템플릿 코드를 호출하는 식으로 우회해야 한다.
  • DLL과의 호환이 쉽지 않다. DLL에서 템플릿을 쓰면 인스턴스가 없기 때문에 링크 오류가 나기 쉬우며, 모든 필요한 자료형에 대한 강제 호출을 통해 코드를 만들어두어야 동작한다. 이렇게 쓸 바에는 차라리 헤더 파일을 직접 제공하도록 하자.

각주 편집

  1. Scott Meyers. 《Effective Modern C++》. 
  2. 《Exceptional C++ item 7》. 

외부 링크 편집