프로그래밍 일반/C++ 프로그래밍

[Effective Modern C++] 항목 1: 템플릿 형식 연역 규칙을 숙지하라

지노윈 2020. 8. 18. 17:28
반응형

항목 1: 템플릿 형식 연역 규칙을 숙지하라

템플릿 선언은 다음과 같은 모습입니다.

  template<type T> 
  void f(ParamType param);

 

호출하는 코드는 다음과 같은 모습니다.

  f(expr);

 

예를 들어 템플릿의 선언이 다음과 같습니다.

  template<type T> 
  void f(const T& param);

 

그리고 이를 다음과 같이 호출한다고 합시다.

  int x = 0; 
  f(x);

 

T가 expr의 형식일 것이라고 기대합니다. 실제로 위의 예제에서 x는 int이고 T는 int로 연역됩니다. 그러나 항상 그런 것이 아닙니다. T에 대해 연역된 형식은 expr의 형식과 Paramtype의 형태에 의존합니다.

그 형태에 따라 총 세가지 경우로 나뉩니다.

  • ParamType이 포인터 또는 레퍼런스이지만 Universal reference가 아닌 경우.
  • ParamType이 Universal reference인 경우
  • ParamType이 포인터도 아니고 참조도 아닌 경우

경우1: ParamType이 포인터 또는 레퍼런스이지만 Universal Reference가 아님

이 경우가 가장 간단한 경우이며 다음과 같이 진행됩니다.

  • 만일 expr이 참조 형식이면 참조 부분을 무시한다.
  • 그런 다음 expr의 형식을 ParamType에 대해 패턴 매칭 방식으로 대응시켜서 T의 형식을 결정한다.

예를 들어 함수 템플릿이 다음과 같다고 하자

  template<typename T>

  void f(T& param);

그리고 다음과 같은 변수 선언과 호출 코드가 있습니다.

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

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

f(x)는 설명할 필요 없이 단순하며 T가 그대로 int로 결정되어 T는 int, param은 int&입니다.

f(cx) 또한 T가 그대로 const int로 결정되어 T는 const int, param은 const int&입니다.

f(rx)는 (1)단계의 설명 처럼 참조부분을 무시하여 T는 const int, param은 const int&입니다.

 

f의 매개변수 형식을 T&에서 const &T로 바꾸어도 별반 다를 것이 없습니다.

  template<typename T>

  void f(const T& param);

f(x) : T는 int, param의 형식은 const int &

f(cx) : T는 int, param의 형식은 const int &

f(rx) : T는 int, param의 형식은 const int &, 이번에도 참조성이 무시 되었습니다.

 

f의 매개변수 형식이 T*일때도 동일한 방식입니다.

  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*

마찬가지로 f(px)에서 포인터성이 무시 되었습니다.(T는 const int)


경우 2: ParamType이 Universal Reference인 경우

템플릿이 Universal reference 매개 변수를 받는 경우에는 상황이 조금 불투명해집니다.

유니버셜 레퍼런스(Universal Reference)
type&&는 구문의 생김이 rvalue reference이지만 실제 의미는 lvalue reference일 수 있습니다. 이렇게 유연성을 가진 reference를 universal reference라고 합니다.
변수가 auto, template, decltype에 의해 type deduction(형식 연역)이 필요하며 T&&로 선언되면, 해당 변수는 universal reference입니다.
  • 만일 expr이 lvalue이면, T와 ParamType 둘 다 lvalue reference로 연역됩니다. 이는 이중으로 비정상적인 상홥입니다. 첫째로, 템플릿 형식 연역에서 T가 참조 형식으로 연역되는 경우는 이것이 유일합니다. 둘째로, ParamType의 선언 구문은 rvalue reference와 같은 모습이지만, 연역된 형식은 lvalue reference입니다.
  • 만일 expr이 rvalue이면, '정상적인'(즉, 경우 1의) 규칙들이 적용됩니다.

다음은 예입니다.

  template<typename T>

  void f(const T&& param);

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

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

이렇게 연역되는 규칙에 대해서는 항목 24에서 설명할 것이며, expr이 lvalue reference 또는 rvalue reference인지에 따라 각각 다른 연역 규칙이 적용된다는 것만 알아 두자.


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

ParamType이 포인터도 아니고 참조도 아니라면, 인수가 함수에 값으로 전달되는 상항인 것입니다.

  template<typename T>

  void f(T param);

param은 주어진 인수의 복사본, 즉 완전히 새로운 객체입니다. 새로운 객체이기 때문에 expr에서 T가 연역되는 과정에서 다음과 같은 규칙이 적용됩니다.

  • 이전처럼, 만일 expr의 형식이 참조이면, 참조 부분은 무시됩니다.
  • expr의 참조성을 무시한 후, 만일 expr이 const이면 그 const 역시 무시합니다. 만일 volatile이면 그것도 무시합니다.

이 규칙들이 적용되는 예입니다.

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가 무시되는 이유는 expr을 수정할 수 없다고 복사본까지 수정할 수 없는 것은 아닙니다.

 

expr이 const 객체를 가르키는 const 포인터이고 param에 값으로 전달되는 경우는 어떨까? 이런 예를 보자.

  template<typename T>
  void f(T param);	// param에 값 전달
  
  const char* const ptr = "Fun with pointers";	// ptr은 const 객체를 가르키는 const 포인터
  
  f(ptr);	// const char* const 형식의 인수를 전달, const char*로 연역

ptr 선언의 오른쪽에 있는 const 때문에 ptr 자체는 const가 됩니다. 즉, ptr이 다른 장소를 가르키도록 변경할 수 없습니다. ptr을 f에 전달하면 그 포인터를 구성하는 비트들을 param에 복사됩니다. 즉, 포인터 자체(ptr)는 값으로 전달 됩니다. 형식 연역 과정에서 ptr의 오른쪽 const가 무시됩니다.(그대로 복사 되므로)


배열 인수

배열이 배열의 첫 원소를 가르키는 포인터로 decay됩니다. 이러한 변환 덕에 다음과 같은 코드가 오류 없이 컴파일 됩니다.

const char name[] = "J. P. Briggs";
const char* ptrToName = name;	// 배열이 포인터로 decay

배열 형식의 매개 변수라는 것이 없습니다. 다음의 두 선언은 동일합니다.

void myFunc(int param[])	// 이 코드는 적법하지만 int*로 변환됩니다.
void myFunc(int* param)		// 즉, 이 선언과 동일합니다.

이러한 배열 매개변수와 포인터 매개변수의 동치성은 C로 부터 넘겨 받은 것이며, 배열 형식과 포인터 형식이 같다는 환성은 바로 이것으로 부터 비롯되었습니다.

 

배열 매개변수 선언이 포인터 매개 변수처럼 취급되므로, 템플릿 함수에 값으로 전달되는 배열의 형식은 포인터 형식으로 연역됩니다.

template<typename T> 
void f(T param);
  
const char name[] = "J. P. Briggs";
  
f(name);	// name은 배열이지만 T는 const char*로 연역된다.

그런데 한가지 교묘한 요령이 있습니다. 비록 함수의 매개변수를 진짜 배열로 선언 할 수는 없집만, 배열에 대한 참조로 선언할 수는 있습니다. 즉 다음과 같이 f가 인수를 참조로 받도록 수정하고,

  template<typename T>

  void f(T& param);

함수에 배열을 전달하면

  f(name)

T에 대한 연역된 형식은 배열의 실제 형식이 된다! 배열의 크기를 포함하므로 이 예에서 T는 const char [13]으로 연역되고 f의 매개변수의 형식은 const char (&)[13]으로 연역됩니다.

 

흥미롭게도, 배열에 대한 참조를 선언하는 능력을 이용하면 배열에 담긴 원소들의 개수를 연역하는 템플릿을 만들 수 있습니다.

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T(&) [N]) noexcept
{
	return N;
}

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

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

함수 인수

C++에서 포인터로 decay되는 것은 배열만이 아닙니다. 함수 형식도 함수 포인터로 decay할 수 있습니다. 배열 인수의 연역 규칙은 하수 인수도 동일하게 적용됩니다.

void someFunc(int, double); // 형식은 void(int, double)

template<typename T>
void f1(T param)

template<typename T>
void f2(T& param)

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

종종 컴파일러에게 "어떤 형식을 연역했는지 알려줘!"라고 물어 보고 싶을 때가 있을 것이다. 항목 4를 참고하세요.


기억해 둘 사항

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