티스토리 뷰

어떤 복잡한 시스템의 사용자가 그 시스템의 작동 방식을 알지 못해도 잘 사용할 수 있다면 설계가 잘 된 것이라는 관점에서 템플릿 형식 연역은 엄청난 성공작이라 할 수 있습니다.

 

여기에 대해서 템플릿 연역 규칙을 모르는 프로그래머에 대해서는 좋은 소식과 나쁜 소식이 있습니다 :

  • 좋은 소식 : auto가 템플릿에 대한 형식 영역을 기반으로 작동한다는 것입니다
  • 나쁜 소식 : 템플릿 형식의 연역 규칙들이 auto의 문맥에 적용될 때에는 템플릿에 적용될 때에 비해 덜 직관적인 경우가 존재합니다.

 

앞서 언급한 사항들 때문에 auto를 잘 활용하기 위해 auto가 기초하고 있는 템플릿 형식 연역의 면모를 제대로 이해하는 것이 중요합니다. 먼저 함수 템플릿의 선언을 살펴보겠습니다 :

// 함수 템플릿 선언의 일반적 모습
template<typename T>
void f(ParamType param);

// 함수 템플릿 호출
// expr : 어떤 표현식
f(expr);    

 

위의 함수 템플릿 호출에서 컴파일러는 컴파일 도중 expr을 이용하여 T에 대한 형식ParamType 에 대한 형식을 연역하게 됩니다. 이 두 형식은 다른 경우가 많은데, ParamType에 흔히 const나 참조 한정사(& 또는 &&) 같은 수식어들이 붙기 때문입니다. 템플릿이 다음과 같다면,

// 함수 템플릿 선언
template<typename T>
void f(const T& param);

// 함수 템플릿 호출
int x = 0;
f(x);

 

이 경우 TintParamTypeconst int& 로 연역됩니다. 이 때, T가 연역되는 방법이 전달되는 인수의 형식과 같을 것이라고 생각하는 것은 당연하지만, 항상 그런 것은 아닙니다. 정확히 얘기하자면, T의 형식이 연역되는 규칙에는 전달되는 인수의 형식 뿐만 아니라 ParamType 의 형태에도 의존합니다. 이 때 ParamType의 형태에 따라 총 세 가지 경우로 나뉩니다 :

  • ParamType이 포인터(*) 또는 참조(&) 형식이지만, 보편 참조(universal reference, &&)는 아닌 경우
  • ParamType이 보편 참조인 경우
  • ParamType이 포인터도 아니고 참조도 아닌 경우(값일 경우)

 

(보편 참조의 경우 항목 24에서 설명합니다). 아래 세 가지의 경우를 다음의 일반적인 템플릿 함수의 선언 및 호출에 기초하여 살펴보겠습니다 :

// 템플릿 함수 선언
template<typename T>
void f(ParamType param);

// 템플릿 함수 호출
f(expr);

 

 

 

경우 1 : ParamType이 포인터 또는 참조 형식이지만 보편 참조는 아님


이 경우, 형식 연역은 다음과 같이 진행됩니다.

  1. 만일 expr이 참조 형식이면 참조 부분(&)을 무시한다.
  2. 그런 다음 expr의 형식을 ParamType에 대해 패턴 부합(pattern-matching) 방식으로 대응시켜서 T의 형식을 결정한다.

 

예를 들어 함수 템플릿과 변수들이 다음과 같이 선언되어 있을 때,

// 함수 템플릿 선언
template<typename T>
void f(T& param);        // param이 참조 형식

int x = 27        // x는 int;
const int cx = x        // x는 const int;
const int& rx = x        // x는 const int인 x에 대한 참조(reference) ;

 

각각 템플릿 함수를 호출하면 다음과 같이 param과 T의 형식이 연역됩니다 :

f(x);            // T는 int, param의 형식은 int&
f(cx);            // T는 const int, param의 형식은 const int&
f(rx);            // T는 const int, param의 형식은 const int&

 

여기서 2번째와 3번쨰의 함수 호출에 대해 T가 const int로 연역된 것을 통해, 객체의 const성(const-ness; 상수성)은 T에 대해 연역된 형식의 일부가 됨을 알 수 있습니다. 또한 3번째 호출에서 T가 const int로 연역되는 것은 첫 번째 규칙에 의해 참조 부분이 무시되었기 때문임을 알 수 있습니다. 여기서 매개변수 형식이 T&에서 const T&로 바뀐다고 놀랄만한 변화가 생기는 것은 아닙니다.

// 함수 템플릿 선언
template<typename T>
void f(const T& param);

int x = 27
const int cx = x;
const int& rx = x;

f(x);            // T는 int, param은 const int&
f(cx);            // T는 int, param은 const int&
f(rx);            // T는 int, param은 const int&

 

param이 참조 형식이 아니라 포인터라도 본질적으로 같은 방식으로 연역됩니다.

// 함수 템플릿 선언
template<typename T>
void f(T* param);

int x = 27;
const int* px = &x;

f(&x);            // T는 int, param은 int*
f(px);            // T는 const int, param은 const int*

 

모든 것이 자명하고, 저희가 원하는 형식 연역 체계는 바로 이렇게 진행되어야 할 것입니다.

 

 

 

경우 2 : ParamType이 보편 참조임


템플릿이 보편 참조 매개변수를 받는 경우에는 상황이 달라집니다. 보편 참조를 받는 매개변수의 선언은 오른값 참조와 같은 모습이지만, 왼값 인수가 전달되면 오른값 참조와는 다른 방식으로 행동합니다 (자세한 이야기는 항목 24에서 언급하겠습니다).

  • expr이 왼값(l-value)이면, T와 ParamType 둘 다 왼 값 참조로 연역됩니다.
  • expr이 오른값(r-value)이면, 정상적인(경우 1) 규칙들이 적용됩니다.

 

왼 값일 때의 규칙은 이중으로 비정상적인 상황을 포함합니다. 먼저, 템플릿 형식 연역에서 T가 참조 형식으로 연역되는 경우는 이것이 유일합니다(경우 1의 T에 대한 형식 연역을 보았을 때, 참조 형식(&)의 경우에도 T는 참조 형식을 띄지 않았다는 것에 주목하시면 되겠습니다). 둘째로, ParamType의 선언 구문은 오른값 참조와 같은 모습이지만, 연역된 형식은 왼값 참조입니다(&&로 선언되었지만, 연역된 형식이 &라는 의미입니다).

이것에 대한 예시는 다음과 같습니다:

// 템플릿 함수 선언
template<type name T>
void f(T&& param);        // param이 보편 참조를 받습니다.

int x = 27;
const int cx = x;
const int& rx = x;

f(x);            // T는 int&, param은 int&
f(cx);            // T는 const int &, param은 const int &
f(rx);            // T는 const int &, param은 const int &
f(27);            // T는 int, param은 int&&

 

이렇게 연역되는 이유에 대해서는 항목 24에서 설명하도록 합니다. 현재로써는 보편 참조 매개변수에 관한 형식 연역 규칙들이 왼값 참조나 오른값 참조 매개변수들에 대한 규칙들과는 다르다는 점만 기억하시기 바랍니다.

 

 

 

경우 3 : ParamType이 포인터도 아니고 참조도 아님


ParamType이 포인터도 아니고 참조도 아니라면, 인수가 함수에 값으로 전달되는 상황(pass-by-value)입니다. 이 경우, param은 인수에 대해 복사된 새로운 객체이게 됩니다. param이 새 객체라는 사실 때문에 expr에서 T가 연역되는 과정에서 다음과 같은 규칙이 적용됩니다:

  1. 이전처럼, 만일 expr의 형식이 참조이면, 참조성(&)은 무시됩니다.
  2. expr의 참조성을 무시한 후, 만일 expr이 const이면 그 const 역시 무시됩니다. 만일 volatile이면 그것도 무시합니다(volatile에 대한 자세한 사항은 항목 40에서 살펴보겠습니다).

 

다음은 이 규칙들이 적용되는 예시입니다:

// 템플릿 함수 선언
template<typename T>
void f(T param);

int x = 27;
const int cx = x;
const int& rx = x;

f(x);            // T는 int, param은 int
f(cx);            // T는 int, param은 int
f(rx);            // T는 int, param은 int

 

cx와 rx가 const성을 띄지만, param이 복사본이기 때문에 const성을 띄지 않음에 주목하시기 바랍니다. 이것은 param이 독립적인 객체이므로 당연한 결과라고 할 수 있습니다. 즉, expr이 const 키워드가 붙음으로써 수정할 수 없다고 하더라도, 그 복사본인 param이 수정할 수 없는 것은 아니라는 점입니다.

여기서 명심할 점은, const 및 volatile 키워드가 값 전달 매개변수에 대해서만 무시된다는 점입니다. expr이 const 객체를 가리키는 const 포인터이고 이것이 param에 값으로 전달되면 어떻게 되는지 살펴보겠습니다:

// 함수 템플릿 선언
template<typename T>
void f(T param);

const char* const ptr = "Fun with pointers";    // ptr의 형식은 const char* const

f(ptr);            // T는 const char*, param은 const char*

 

ptr의 의미는 const 문자열의 주소를 갖고 있으면서, 다른 const char*의 주소로도 참조가 불가능한 포인터임을 말합니다. 이 형식이 인수로 들어갈 때 포인터 자체(ptr)는 값으로 전달됩니다. 포인터 자체는 const성을 띄고 있으므로 언급한 규칙에 의해 이것이 제거됩니다. 하지만, 이 포인터가 참조하고 있는 char*의 const성은 제거되지 않습니다.

따라서 para은 const char*이 되며, const 문자열을 가리키면서 다른 주소를 참조할 수도 있게 됩니다.

배열 인수


템플릿 형식 연역기 관여하는 대부분의 상황이 지금까지 이야기한 경우이지만, 이외에도 알아둘 필요가 있는 틈새 상황 이 존재합니다. 먼저 설명려는 것은, 배열이 포인터와 구분하지 않고 사용할 수 있지만, 엄밀히 배열과 포인터 형식은 서로 다르다는 사실에서 비롯되는 것입니다. 배열과 포인터를 맞바꿔 쓸 수 있는 것처럼 보이는 환상의 주된 원인은 많은 문맥에서 배열이 배열의 첫 원소를 가리키는 포인터로 붕괴(decay)한다 는 점 떄문입니다. 이러한 붕괴는 다음과 같은 문맥을 오류 없이 컴파일하도록 합니다:

const char name[] = "J. P. Briggs";    // type : const char[13]

const char* ptrToName = name;    // 배열이 포인터로 붕괴됩니다.

 

c++에는 배열 형식의 함수 매개변수가 존재하지 않습니다. 물론 다음과 같은 구문은 문제가 없습니다:

void func(int param[]);

 

이 경우, 사실상 void func(int* param) 로 선언한 것과 같은 의미입니다. 배열 매개 변수 선언이 포인터 매개변수처럼 취급되므로, 템플릿 함수에 값으로 전달되는 배열의 형식은 포인터 형식으로 연역됩니다. 여기서 함수의 매개변수를 진짜 배열로 선언할 수는 없지만, 배열에 대한 참조로 선언할 수 있는 한 가지 교묘한 요령 이 있습니다. 그것은 다음과 같습니다 :

template<typename T>
void f(T& param);

const char name[] = "Hello World" ;

f(name);

 

이 경우, T에 대해 연역되는 형식은 배열의 실제 형식이 됩니다. 그 형식은 배열의 크기를 포함하므로, 이 예에서 T는 const char [12]로 연역되고, para은 const char (&)[12]로 연역됩니다. 이렇게 배열에 대한 참조를 선언하는 능력을 이용하면 배열에 담긴 원소들의 개수를 연역하는 템플릿을 다음과 같이 만들 수 있습니다 :

// 배열의 크기를 컴파일 시점 상수로서 돌려주는 템플릿 함수
// (배열 매개변수에 이름을 붙이지 않은 것은, 이 템플릿에 
// 필요한 것은 배열에 담긴 원소의 개수 뿐이기 때문입니다)
template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept
{
    return N;
}

 

constexpr는 항목 15에서도 설명하겠지만, 함수를 constexpr로 선언하면 함수 호출 결과를 컴파일 도중에 사용할 수 있게 됩니다. 그러면 다음 예처럼 중괄호 초기화 구문으로 정의된, 기존 배열과 같은 크기(원소 개수)의 새 배열을 선언하는 것이 가능해집니다 :

int keyVals[] = { 1, 3, 7, 9, 11, 22 ,35 };        // keyVals의 원소 개수는 7개

int mappedVals[arraySize(keyVals)];        // mappedVals의 원소 개수 역시 7개

std::array<int, arraySize(keyVals)> stlMappedVals        // stlMappedVals의 크기는 7

 

noexpt로 선언한 것은 컴파일러가 더 나은 코드를 산출하는 데 도움을 주려는 키워드로, 항목 14를 참조하시기 바랍니다.

 

 

 

함수 인수


c++에서 포인터로 붕괴하는 것은 배열 뿐만이 아니라 함수 형식 역시 가능합니다. 다음은 붕괴의 예입니다 :

void someFunc(int, double);        // someFunc는 함수이며, 형식은 void(int,double)

template<typename T>
void f1(T param);            // f1은 값 전달 방식

template<typename T>
void f2(T& param);            // f2는 참조 전달 방식

f1(someFunc);            // param은 함수 포인터로 연역되며, 형식은 void (*)(int, double)
f2(someFunc);            // param은 함수 참조로 연역되며, 형식은 void(7)(int, double);

 

실제 응용에서 이것 때문에 뭔가 달라지는 경우는 드물지만, 배열에서 포인터로의 붕괴를 알고 있다면, 함수에서 포인터로의 붕괴 역시 알아 두는 것이 좋다고 생각합니다.

 

 

 

기억해 둘 사항들


  • 템플릿 형식 연역 도중에 참조 형식(&)의 인수들은 비참조로 취급된다. 즉, 참조성이 무시된다.
  • 보편 참조 매개변수에 대한 형식 연역 과정에서 왼값 인수들은 특별하게 취급된다.
  • 값 전달 방식의 매개변수에 대한 형식 연역 과정에서 const 또는 volatile 인수는 무시된다.
  • 템플릿 형식 연역 과정에서 배열이나 함수 이름에 해당하는 인수는 포인터로 붕괴한다. 단, 그런 인수가 참조를 초기화하는 데 쓰이는 경우에는 포인터로 붕괴하지 않는다.

 

 

 

레퍼런스


[Effective Modern C++ - C++11과 C++14를 효과적으로 사용하는 42가지 방법] 항목 1 : 템플릿 형식 연역 규칙을 숙지하라

댓글