본문 바로가기
프로그래밍/C & C++

[C++] Templates in Practice

by 별준 2023. 1. 5.

References

  • Ch9, C++ Templates The Complete Guide

Contents

  • The Inclusion Model
  • Templates and inline
  • Precompiled Headers
  • Decoding the Error

템플릿 코드는 일반 코드와 약간 다릅니다. 어떤 측면에서는 매크로와 일반(non-template) 선언의 중간이라고 볼 수도 있습니다. 이번 포스팅에서는 템플릿의 기술적인 부분은 배제하고 실제 어떻게 사용할 수 있는지에 대해서 살펴보려고 합니다. 특히 전통적인 C++ 컴파일 시스템(compiler + linker) 측면에서 어떻게 템플릿 코드를 작성하고, 작성하면서 마주할 수 있는 에러 등에 대해서 살펴봅니다.

 


The Inclusion Model

템플릿 소스 코드를 구성하는 방법에는 여러 가지가 있는데, 일반적으로 많이 사용되는 inclusion model에 대해 알아보겠습니다. Inclusion model은 번역 단위(TU)에 선언부와 구현부를 모두 포함하는 것을 의미합니다.

 

Linker Errors

대부분의 C/C++ 구현은 non-template 코드를 다음과 같이 구현합니다.

  • 클래스와 다른 타입들은 모두 헤더 파일에 위치하며, 일반적으로 헤더 파일은 파일 확장자가 .hpp(or .H, .h, .hh, .hxx) 입니다.
  • 전역(non-inline) 변수와 함수의 경우, 헤더 파일에서는 선언만 하고 정의는 헤더 파일의 TC로 컴파일되는 파일에서 구현합니다. 일반적으로 이 파일(CPP 파일)은 파일 확장자가 .cpp(or .C, .c, .cc, .cxx) 입니다.

이렇게 분리해도 잘 동작하며, 특히, 이렇게 선언과 정의를 분리해두면 전체 프로그램 내에서 원하는 타입/함수 선언을 쉽게 이용할 수 있고, 링크 과정에서 변수나 함수에 대한 중첩 정의 에러를 피할 수 있습니다.

 

템플릿을 처음 사용하는 경우, 자주 경험할 수 있는 에러가 아래에서 잘 보여주고 있습니다. 아래 코드는 일반적인 코드처럼 템플릿을 헤더 파일에 선언하고 있습니다.

/* myfirst.hpp */
#pragma once

// declaration of template
template<typename T>
void printTypeof(T const&);
/* myfirst.cpp */
#include <iostream>
#include <typeinfo>
#include "myfirst.hpp"

// implementation/definition of template
template<typename T>
void printTypeof(T const& x)
{
    std::cout << typeid(x).name() << "\n";
}

printTypeof()는 특정 타입 정보를 출력하는 간단한 함수이며, 선언은 hpp 파일, 구현은 cpp 파일에 정의되어 있습니다. 코드를 간단하게 설명하면, 전달된 표현식의 타입을 설명하는 문자열을 출력하기 위해 typeid 연산자를 사용합니다 (std::type_info의 멤버 함수인 name() 사용).

 

다음으로 다른 cpp 파일(ex, main.cpp)에서 이 템플릿을 사용하는데, 이때 템플릿 선언은 #include로 불러옵니다.

#include "myfirst.hpp"

// use of the template
int main()
{
    double ice = 3.0;
    printTypeof(ice); // call function template for type double
}

C++ 컴파일러에서는 아무런 문제가 발생하지 않지만, 링커에서는 아마 printTypeof() 함수의 정의가 없다고 에러를 발생시킵니다.

이는 printTypeof()라는 함수 템플릿의 정의가 인스턴스화되지 않았기 때문에 발생합니다. 템플릿이 인스턴스화되려면 정의가 어떤 템플릿 인자로 인스턴스화되어야 하는지 컴파일러가 알고 있어야 합니다. 하지만, 위 예제 코드에서 하나의 템플릿에 대한 정보가 다른 파일에 떨어져 있고, 각각 따로 컴파일됩니다. 따라서 컴파일러는 printTypeof()에 대한 호출을 보고 이 함수를 double 타입으로 인스턴스화하려고 하는 순간에 템플릿 정의를 알고 있지 않기 때문에, 이 함수에 대한 정의(구현)이 다른 곳에 있다고 생각하고 해당 정의에 대한 참조만 생성합니다. 반면, 컴파일러가 myfirst.cpp를 컴파일할 때에는 어떤 인자에 대해 템플릿 정의를 인스턴스화해야 하는지 모릅니다.

 

Templates in Header Files

위에서 발생한 에러를 해결하는 일반적인 방법은 매크로나 인라인 함수에서 사용하는 방법과 동일합니다. 바로 템플릿 정의를 해당 템플릿 선언이 있는 헤더 파일에 포함시키는 것 입니다.

 

즉, myfirst.cpp는 버리고 myfirst.hpp에 선언과 정의(구현)을 모두 포함시키는 것 입니다.

/* myfirst2.hpp */
#pragma once
#include <iostream>
#include <typeinfo>

// declaration of template
template<typename T>
void printTypeof(T const&);

// implementation/definition of template
template<typename T>
void printTypeof(T const& x)
{
    std::cout << typeid(x).name() << "\n";
}

위와 같이 템플릿을 구성하는 것을 Inclusion Model이라고 합니다. 이 방식을 사용하면 올바르게 컴파일/링크/실행이 가능합니다.

 

여기서 몇 가지 포인트를 살펴보겠습니다. 먼저 위의 방식을 사용하게 되면 헤더 파일을 include하는 데 드는 비용이 상당히 증가합니다. 위 예제의 경우에서는 템플릿 정의 뿐만 아니라, 템플릿을 정의하기 위한 사용한 헤더들도 모두 include해야만 합니다. 여기서는 <iostream>과 <typeinfo>가 포함됩니다. <iostream>의 경우, 추가되는 코드의 양만해도 수만 줄이 됩니다. 이렇게 되면 프로그램을 컴파일 시간이 증가하는 것이 문제입니다. 이러한 문제를 해결하기 위한 다른 방법으로는 precompiled headerexplicit template instantiation과 같은 방법이 있습니다.

 

빌드 시간 문제가 있긴 하지만 경험상 그리 큰 프로그램이 아니라면 문제가 될 정도로 빌드 시간이 느려지지는 않는 것 같습니다. 따라서, 가능하면 inclusion model로 템플릿을 작성하는 것을 권장한다고 합니다. C++20에서부터는 모듈(module)이라는 메커니즘이 도입되었는데, 이는 컴파일러가 모든 선언을 따로 컴파일하고 효율적이면서 선택적으로 필요한 선언을 import하는 메커니즘입니다.

 

Inclusion model 방식에서 약간 미묘한 부분은 인라인이 아닌 함수 템플릿은 인라인 함수나 매크로와는 구별된다는 점입니다. 템플릿은 호출되는 영역에서 확장되는 것이 아니라, 인스턴스화될 때 함수의 새로운 복사본이 생성됩니다. 이러한 복사본이 자동으로 만들어지기 때문에 컴파일러는 서로 다른 파일에서 동일한 복사본을 생성할 수도 있고, 링커가 같은 함수에 대한 두 개의 정의를 발견하고 에러를 발생시킬 수도 있습니다. 이론적으로 이는 프로그래머가 고려해야될 사항은 아니며, C++ 컴파일 시스템이 수정해야 할 문제입니다. 사실, 대부분의 템플릿 코드는 잘 동작하며, 이러한 현상에 대해 신경 쓸 필요는 없습니다. 다만, 이러한 템플릿 코드 라이브러리를 사용하는 대형 프로젝트에서는 문제가 발생할 수도 있습니다.

 


Templates and inline

프로그램의 실행 시간을 향상시키고자 할 때 흔히 함수를 인라인(inline)으로 선언하곤 합니다. inline 지정자(specifier)는 일반 함수와 달리 함수 호출 시, 일반적인 함수 호출보다 호출 지점을 함수 바디로 치환하는 것을 더 선호한다고 컴파일러에게 알려주는 힌트입니다.

 

하지만, 컴파일러는 이 힌트를 무시할 수 있습니다. 그래서 inline에서 보장되는 효과는 오직 함수 정의(definition)이 프로그램 내에서 여러 번 등장할 수 있다는 것입니다 (보통 인라인 함수 정의를 포함하는 헤더 파일이 여러 곳에서 include되기 때문).

 

함수 템플릿과 인라인 함수 모두 여러 번역 단위에서 정의될 수 있습니다. 정의를 포함하는 헤더 파일을 여러 cpp 파일에 include하면 해당 번역 단위마다 정의될 것 입니다. 이 때문에 함수 템플릿이 기본적으로는 인라인이라고 생각할 수 있지만, 템플릿 함수는 인라인이 아닙니다.

 

호출 지점에서 일반적인 함수 호출 방식 사용 또는 함수 템플릿 바디를 치환은 오로지 컴파일러의 결정에 따라 정해집니다. 놀랍게도 인라인 호출로 얻을 수 있는 성능 개선 효과는 프로그래머보다 컴파일러가 더 잘 평가합니다. 따라서 inline 지정자를 따를 지에 대한 것은 컴파일러마다 다르며, 컴파일 옵션에 따라서도 다릅니다. 그럼에도 적절한 성능 프로파일링 도구를 사용하여 프로그래머가 컴파일러보다 더 정확하게 정보를 얻을 수도 있고, 이에 따라서 컴파일러의 결정을 그대로 따르지 않고 싶을 수도 있습니다. 이런 경우에는 noinline이나 always_inline 같은 컴파일러별 속성을 사용할 수도 있습니다.

 

함수 템플릿의 full specialization(특수화)는 일반 함수와 동일합니다. inline으로 정의되지 않는 한 full specialization의 정의는 단 한 번만 나타날 수 있습니다. 이 주제는 나중에 특수화에 대해서 다룰 때 살펴보도록 하겠습니다 (ch16.3 참조).

 


Precompiled Headers

템플릿이 아니라더라도 C++ 헤더 파일은 매우 커질 수 있고, 이에 따라 컴파일 시간이 길어질 수 있습니다. 템플릿을 사용하면 이러한 경향이 더 커지기 때문에 빌드 시간으로 고통받는 프로그래머를 위해 PCH(precompiled header)라고 불리는 기법이 구현되어 있습니다. 이는 표준 범위를 벗어나기 때문에 벤더사에 따라서 다릅니다. 여기서는 이 기법의 일반적인 동작에 대해서만 간단히 살펴보도록 하겠습니다.

 

컴파일러는 파일 내 코드를 번역할 때 파일의 처음부터 끝까지 읽어 들입니다. 파일에서 각 토큰을 처리하면서 (#include로 포함된 파일의 토큰도 처리) 나중에 lookup할 수 있도록 심볼 테이블에 요소들을 추가하는 등의 것들을 포함하여 내부 상태를 조정합니다. 이 과정에서 컴파일러는 오브젝트(object) 파일을 생성합니다.

 

PCH 기법은 파일들의 대부분이 동일한 코드로 시작한다는 점에거 착안되었습니다. 모든 코드가 N행의 동일한 코드로 시작한다고 가정해봅시다. PCH 기법에서는 동일한 N행의 코드를 컴파일한 시점에서의 컴파일러 상태를 저장해둡니다. 그런 다음, 프로그램의 모든 파일에 대해 컴파일하기 전에 저장된 PCH를 불러와서 N+1행부터 컴파일을 시작합니다. 이렇게 하면 처음 N행을 컴파일하는 데 드는 시간을 절약할 수 있습니다.

 

단, PCH를 효율적으로 사용하려면 시작하는 부분이 같은 코드가 되도록 해주어야 합니다. 빌드 시간의 상당 부분은 #include가 차지하기 때문에, include 하는 순서가 동일해야 합니다. 예를 들어, 아래의 두 파일은 소스 코드에서 #include 순서가 달라서 동일한 내부 상태가 없기 때문에 PCH를 활용할 수 없습니다.

// 1st file
#include <iostream>
#include <vector>
#include <list>
...
// 2nd file
#include <list>
#include <vector>
...

어떤 프로그래머는 해당 파일에서 불필요한 헤더가 포함되더라도 PCH를 활용하여 파일의 번역 시간을 줄이는 쪽을 택하기도 합니다. 이런 방식을 사용하면 PCH를 쉽게 관리할 수 있는데, 예를 들어, std.hpp라는 헤더 파일에서 모든 표준 헤더를 불러들이도록 해도 됩니다.

// std.hpp
#include <iostream>
#include <string>
#include <vector>
#include <deque>
#include <list>
...

위의 파일은 사전 컴파일되며, 표준 라이브러리를 사용하는 모든 프로그램 파일들은 아래의 코드로 시작하기만 하면 됩니다.

#include "std.hpp"
...

 

 


Decoding the Error

일반적인 컴파일 에러는 대부분 간결하며 그 원인만을 딱 짚어줍니다. 하지만 템플릿에서의 에러는 다릅니다. 예제를 통해서 살펴보도록 하겠습니다.

 

Type Mismatch

C++ 표준 라이브러리를 사용하는 아래의 간단한 예제 코드를 살펴보겠습니다.

#include <string>
#include <map>
#include <algorithm>

int main()
{
    std::map<std::string, double> coll;

    // find the first non-empty string in coll
    auto pos = std::find_if(coll.begin(), coll.end(),
        [] (std::string const& s) {
            return s != "";
        }
    );
}

위 코드에서는 사소한 실수를 저질렀습니다. 콜렉션 내에서 처음으로 일치하는 문자열을 찾기 위해 람다를 사용하는데, 이 람다는 주어진 문자열을 체크합니다. 하지만, 맵의 요소는 key/value 쌍으로 주어지므로 람다 함수는 std::pair<std::string const, double>을 받아야 합니다.

 

위 코드를 컴파일하면 제 PC에서는 아래와 같은 에러가 발생합니다.

간단한 실수이기 때문에 조금만 자세히 살펴보면 원인을 찾을 수는 있겠으나, 일반적인 에러와는 다릅니다.

위의 에러 메세지의 첫 번째 부분을 살펴보면 함수 템플릿 인스턴스의 깊은 곳에서 에러가 발생했다는 것을 알 수 있는데, 그 위치는 main.cpp에서 include한 다양한 헤더로부터 include된 predefined_ops.h 입니다. 이 부분과 그 다음 행에서 컴파일러는 무엇이 어떤 인자로 인스턴스화 했는지 나와있습니다. 이 경우에는 main.cpp의 11번째 행의 시작하는 부분에서 에러가 발생했다고 나와있습니다.

 

문제의 코드는 아래와 같습니다.

auto pos = std::find_if(coll.begin(), coll.end(),
    [] (std::string const& s) {
        return s != "";
    }
);

이 코드는 stl_algo.h 헤더에 있는 find_if 템플릿을 인스턴스화하는데, 그 때의 코드는 아래와 같습니다.

_IIter std::find_if(_IIter, _IIter, _Predicate)

위 코드를 다음과 같이 인스턴스화합니다.

_IIter = std::_Rb_tree_iterator<std::pair<const std::__cxx11::basic_string<char>, double> >
_Predicate = main()::<lambda(const string&)>

컴파일러는 모든 템플릿이 인스턴스화될 것이라고 예상하고 모든 것들을 알려주며, 이를 통해 인스턴스화를 발생시킨 연쇄 이벤트들을 확인할 수 있습니다.

 

위 예제에서는 모든 템플릿들이 인스턴스화되어야 했을 것이라고 생각됩니다. 하지만 에러가 발생했는데, 이에 대한 정보는 메세지의 끝에서 알 수 있습니다. "no match for call"로 시작하는 부분을 통해 인자의 타입과 파라미터 타입이 일치하지 않았고, 따라서 함수 호출이 실패했다는 것을 알 수 있습니다. 여기서 호출된 코드와 이를 호출한 코드가 함께 나열되어 있음을 알 수 있습니다.

뿐만 아니라 "note: candidate: ..."라는 메세지로 후보 타입으로 const string&을 기대한다는 것과 "no known conversion for argument 1 ..."라는 메세지를 통해 왜 이 후보 타입이 맞지 않는지, 여기서의 문제가 무엇인지도 설명해줍니다.

 

에러 메세지를 조금 더 보기 싶도록 만들 수도 있는데, 위의 경우에서는 std::__cxx11::basic_string<char>와 같이 완전히 확장되어 있는 템플릿 인스턴스화 이름을 알려주고 있습니다. 다른 컴파일러에서도 유사한 정보를 제공하긴 하지만, MSVC의 경우에서는 조금 더 친절하게 알려주는 것 같아 보이긴 합니다.

 

댓글