hubring

[EC++] 항목 48: 템플릿 메타 프로그래밍, 하지 않겠는가? 본문

C++/Effective C++

[EC++] 항목 48: 템플릿 메타 프로그래밍, 하지 않겠는가?

Hubring 2021. 10. 13. 01:07

항목 48: 템플릿 메타 프로그래밍, 하지 않겠는가?

템플릿 메타프로그래밍(template metaprogramming: TMP)

  • 템플릿 메타프로그래밍 란?
    • 컴파일 도중에 실행되는 템플릿 기반의 프로그램을 작성하는 일.
    • C++ 컴파일러가 실행시키는, C++로 만들어진 프로그램.
    • TMP 프로그램이 실행을 마친 후에 그 결과로 나온 출력물(템플릿으로부터 인스턴스화된 C++ 소스코드)이 다시 보통의 컴파일 과정을 거치는 것.
    • 1990년 초 TMP개념이 발굴된 후 C++ 언어 및 표준라이브러리에 TMP를 용이하게 만드는 확장요소가 추가될 여지까지 생김.
  • TMP의 강점
    • TMP를 쓰면 다른 방법으로 까다롭거나 불가능한 일을 쉽게 할 수 있음
    • C++ 컴파일이 진행되는 동안에 실행되기 때문에, 기존 작업을 런타임 영역에서 컴파일 타임 영역으로 전환할 수 있음.
    • 일반적인 프로그램 실행 도중에 잡혀 왔던 에러들을 컴파일 타임에 찾을 수 있다는 점
    • TMP를 써서 만든 프로그램이 효율적일 여지가 많다는 점
      • 컴파일 타임에 이미 수행하여 실행 코드가 작아지고 실행시간이 짧아지며 메모리도 적게 잡음 (대신 컴파일 타임이 길어짐)

예제

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; } //++ 혹은 -- 연산의 반복 호출을 사용
    }
}
  • 반복자 종류 참고
  • 위는 STL의 advance와 유사한 코드이다.
  • 이 유사코드를 진짜 코드로 만들려면 아래 코드처럼 typeid를 쓸 수 있다. (지극히 밋밋한 c++ 적인 방법)
    • 타입 정보를 꺼내는 작업을 런타임에 하겠다는 의미이다.
template<typename IterT, typename DistT>
void advance(IterT& iter, DistT d){
    if(typeid(typename std::iterator_traits<IterT>::iterator_catetory)==
    typeid(std::random_access_iterator_tag)){ //임의 접근 반복자에 대해서 
        iter += d;                     //반복자 산술 연산을 쓴다.
    }
    else{
        if(d >= 0) { while (d--) ++iter; } // 다른 종류의 반복자에 대해서는 
        else { while (d++) --iter; } //++ 혹은 -- 연산의 반복 호출을 사용
    }
}
  • 항목 47에서 지적했듯이, 특성정보(traits)를 쓰는 방법보다 효율이 떨어진다.
  • 타입 점검 동작이 컴파일 도중이 아니라 런타임에 일어나기 때문이며
  • 런타임 타입 점검을 수행하는 코드는 어쩔 수 없이 실행 파일에 들어가야하기 때문이다.
  • 특성정보 방법이 TMP이기 때문에 타입에 따른 if...else 처리를 컴파일 타임에 할 수 있었던 것이다.
  • typeid 방법은 성능 외에도 컴파일 문제를 일으킬 수 있다.
std::list<int>::iterator iter;
...
advance(iter, 10); // 10개 원소 만큼 앞으로 옮기려 하지만 컴파일이 안됨
  • 위의 코드를 컴파일러가 돌린다고 가정했을 때, 다음과 같은 advance가 생성될 것이다.
void advance(std::list<int>::iterator& iter, int d){
    if(typeid(typename std::iterator_traits<std::list<int>::iterator>::iterator_catetory)==
    typeid(std::random_access_iterator_tag)){ 
        iter += d;     // 에러!              
    }
    else{
        if(d >= 0) { while (d--) ++iter; } 
        else { while (d++) --iter; } 
    }
}
  • std::list::iterator 는 양방향 반복자이기 때문에 += 연산을 지원하지 못한다.
  • 하지만 지금은 typeid 점검이 실패하기 때문에 += 줄까지도 실행될 수가 없다.
  • 모든 코드가 제대로 되어 있는지 확인하는 일은 컴파일러의 책무이고 iter += d 부분은 iter가 임의 접근 반복자가 아닌 한 컴파일되지 않을 것은 자명하다.

  • TMP를 썼다면 주어진 타입에 따른 코드가 별로로 함수로 분리될 것이고, 각각의 함수는 자신이 맡은 타입에 대한 연산만 수행할 것이다.
  • TMP는 그 자체가 튜링 완전성을 갖고 있는 것으로 알려져 왔다.
    • 범용 프로그래밍 언어처럼 어떤 것이든 계산할 수 있는 능력을 갖고 있다는 뜻이다.
    • 변수 선언도 되고, 루프도 실행할 수 있으며, 함수를 작성하고 호출하는 것까지도 된다.
  • 단 이런것들에 필요한 구문요소가 "보통"의 C++에서 쓰이는 구문요소들과 꽤나 다른 모습을 갖고 있다.
    • if...else 조건문을 나타내는 데는 템플릿 및 템플릿 특수화 버전을 사용한다.
    • 프로그래밍 언어 수준으로 보면 이런 방법은 TMP 어셈블리라고 할 수 있다.
  • TMP용 라이브러리도 많이 있다. (부스트의 MPL이 대표적 항목 55 참조)

TMP 동작 원리 예

  • TMP의 동작 원리를 엿볼 수 있는 부분으로 루프가 있다.
  • 반복(iteration)의미의 진정한 루프는 없기 때문에 재귀(recursion)를 사용해서 루프의 효과를 낸다.
  • 단 이 재귀조차도 우리가 알고 있는 종류가 아니다.
  • 실제론 재귀 함수 호출을 만들지 않고 재귀식 템플릿 인스턴스화(recursive template instantiation)를 하기 때문이다.
  • 아래는 재귀 호출을 하는 기본적인 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 변수가 선언이 되어 있다.
    • 이것은 항목 2에서 말한 나열자 둔갑술(enum hack)이 쓰인 것이다.
    • 재귀적으로 돌면서 템플릿 인스턴스화 버전마다 자체적으로 value의 사본을 갖게 되고 각각 value에는 만들어진 값이 담기게 된다.
  • Factorial 템플릿은 다음과 같이 사용하면 된다.
int main(){
    std::cout << Factorial<5>::value; // 런타임 계산 없이 출력
    std::cout << Factorial<10>::value;
}

TMP 적용이 적합한 예

  • 치수 단위(dimensional unit)의 정확성 확인
    • 과학 기술 분야의 응용프로그램에서 치수 단위(질량, 거리, 시간 등)가 똑바로 조합되어야하는 것이 최우선
    • 예를 들면 속도를 나타내는 변수에 질량을 나타내는 변수를 대입하면 에러
    • TMP를 사용하면 프로그램 안에서 쓰이는 모든 치수 단위의 조합이 제대로 됐는지를 맞춰(컴파일 동안에) 볼 수 있다.
    • 선행 에러 탐지(early error detection)에 TMP를 써먹을 수 있는 사례이다.
    • 분수식 지수 표현이 지원이 된다. 이런 표현이 가능하려면 컴파일러가 확인할 수 있도록 컴파일 도중에 분수의 약분이 되어야한다. time^(1/2) 는 time^(4/8)과 똑같이 받아들여져야 한다는 점이다.
  • 행렬 연산의 최적화
    • operator* 등의 어떤 연산자 함수는 연산 결과를 새로운 객체에 담당 반환해야한다고 항목 21에서 이야기 했으며
    • 항목 44를 보신 분은 squareMatrix클래스를 기억할 것이다.
     typedef squareMatrix<double, 10000> BigMatrix;
     BigMatrix m1, m2, m3, m4, m5;
     ...
     BigMatrix result = m1 * m2 * m3 * m4 * m5; //행렬의 곱을 계산
    • 곱셈 결과를 보통 방법으로 계산하려면 네 개의 임시 행렬이 생겨야 한다. (operator* 한번씩 호출할 때 반환 결과로 생성됨)
    • 행렬 원소들 사이에 곱셈을 해야하므로 네 개의 루프가 순차적으로 만들어질 수 밖에 없다.
    • 이런 비싼 연산에 TMP를 사용할 수 있다.
    • TMP를 응용한 고급 프로그래밍 기술인 표현식 템플릿(expression template)을 사용하면 덩치 큰 임시 객체를 없애는 것은 물론이고 루프까지 합쳐 버릴 수 있다.
    • 게다가 위에 써 놓은 사용자 코드에서 문법 하나 바꾸지 않고 적용할 수 있다.
  • 맞춤식 디자인 패턴 구현의 생성
    • 전략 패턴, 감시자 패턴, 방문자 패턴 등의 디자인 패턴은 그 구현 방법이 여러가지일 수 있다.
    • TMP를 사용한 프로그래밍 기술인 정책 기반 설계(policy-based design)라는 것을 사용하면, 따로따로 마련된 설계상의 선택(정책)을 나타내는 템플릿을 만들어 낼 수 있게 된다.
    • 이렇게 만들어진 정책 템플릿은 임의대로 조합되어 사용자의 취향에 맞는 동작을 갖는 패턴으로 구현되는 데 쓰인다.
    • 예로 몇개의 스마트 포인터 동작 정책을 하나씩 구현한 각각의 템플릿을 만들어 두고, 이들을 사용자가 마음대로 조합하여 수백 가지의 스마트 포인터 타입을 생성할 수 있게 하는 것이다.
    • 생성식 프로그래밍(generative programming)의 기초가 바로 이 기술이다. (자동화된 코드 생성을 사용하는 프로그래밍 방식)

TMP 단점

  • 문법이 비 직관적이고 개발도구의 지원도 아주 미약하다.(디버깅 불가능)
  • 비교적 최근 발견된 것으로 TMP의 프로그래밍 관례들도 아직 미약하다.
  • 그럼에도 불구하고 컴파일 타임에 수행하는 장점으로 매력이 있으며 점점 내외적 지원과 관련 서적들이 늘어가고 있다.
  • 특히 라이브러리 개발자라면 알아야할 프로그래밍이다.

이것만은 잊지 말자

  • 템플릿 메타프로그래밍은 기존 작업을 런타임에서 컴파일 타임으로 전환하는 효과를 낸다.
  • 따라서 선행 에러 탐지와 높은 런타임 효과를 줄 수 있다.
  • 정책 선택의 조합에 기반하여 사용자 정의 코드를 생성하는 데 쓸 수 있으며, 또한 특정 타입에 대해 부적절한 코드가 만들어지는 것을 막는 데도 쓸 수 있다.