들어가기에 앞서, 용어와 관례

class Widget {
public:
    Widget(Widget&& rhs); // 타입은 오른값 참조이지만 rhs 자체는 왼값이다
    ...
};

일반적으로 주소를 취할 수 있으면 왼값, 아니면 오른값이다.

 

대체로 오른값의 복사본은 이동 생성되고 왼값의 복사본은 복사 생성된다.

그래서 단순히 어떤 객체가 다른 객체의 복사본이라는 것만 알고 있다면 생성 비용을 알 수 없다. 왼값, 오른값 여부를 알아야 한다.

 

함수에 전달한 표현식은 인수이고 매개변수를 초기화 하는데 쓰인다.

 

람다 표현식을 통해 만들어진 함수 객체를 클로저라고 부른다. 그런데 딱히 구분짓지 않고 표현식이든 클로저든 둘다 람다라고 부른다.

 


 

 

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

f(expr);

이런 경우 컴파일러는 expr에 해당되는 표현식을 이용해서 두 가지 타입을 추론한다. 하나는 T에 대한 타입, 하나는 ParamType에 대한 타입이다.

 

template<typename T>
void f(const T& param);
...
int x = 0;
f(x); // T는 int, ParamType은 const int&로 추론된다

그런데 보통 ParamType에는 위처럼 const나 참조자가 붙어서 T와 ParamType의 타입이 다른 경우가 매우 흔하다.

T에 대해 추론된 타입은 expr의 타입에 의존할 뿐만 아니라 ParamType의 타입에도 의존하게 되는 것이다.

이런 경우는 아래와 같은 세 가지 경우로 나뉜다.

 

Case1 : ParamType이 포인터 또는 참조 타입이지만 보편 참조는 아닌 경우

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&

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

expr의 타입이 참조 타입이라면 참조 부분을 무시한다. 그 뒤 expr의 타입을 ParamType에 대해 패턴 부합(pattern-matching) 방식으로 대응시켜서 T의 타입을 결정한다.

 

여기서 중요한 부분은 cx, rx에 const 값이 배정되었기 때문에 param의 타입 역시 const int& 타입으로 추론된다.

rx는 참조자로 넘겨줬음에도 불구하고 타입 추론 과정에서 rx의 참조성이 무시되어 cx와 동일하게 취급된다.

 

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&

만약 param의 타입이 T&가 아닌 const T&라고 해도 크게 달라지는건 없다. 다만 이제는 param이 항상 const에 대한 참조로 간주되기 때문에 굳이 T의 일부로 추론될 필요가 없어진다.

포인터에 대해서도 다를것 없이 동일한 규칙이 적용된다.

 

param의 타입에 의존하여 T의 타입이 추론된다

책에서 언급된 내용은 아니지만 덧붙이자면 param의 타입이 const T&인 경우 T의 타입이 모두 int로 추론되기 때문에 함수의 인스턴스화가 한번만 이루어진다. T&라면 int, const int 두개로 인스턴스화 된다.

 

Case2: ParamType이 보편 참조인 경우

템플릿이 보편 참조 매개변수(&&)를 받는 경우 조금 복잡해진다. 전달된 인수가 왼값인지, 오른값인지에 따라 추론이 달라지기 때문이다.

 

만약 왼값이라면 T와 ParamType 둘 다 왼값 참조로 추론된다. Case1에서는 표현식의 참조 부분을 무시한다고 했지만 이 경우에만 유일하게 T가 참조 타입으로 추론된다. 또한 ParamType의 선언 구문 자체는 오른값 참조이지만 실제로 추론되는 타입은 왼값 참조이다.

말이 길어지면 이해가 어려울테니 코드를 보면서 다시 얘기해보자.

 

template<typename T>
void f(T&& param); // param = 보편 참조
...
int x = 27;
const int cx = x;
const int& rx = x;

f(x); // x = 왼값, T = int&, param = int&
f(cx); // cx = 왼값, T = const int&, param = const int&
f(rx); // rx = 왼값, T = const int&, param = const int&

매개변수가 오른값 참조로 작성되어있다면 T의 타입은 왼값 참조자로 추론되는것을 확인할 수 있다.

 

그렇다면 오른값이라면 어떨까? 단순하다. Case1과 완전 동일하게 처리된다.

 

f(27); // 27 = 오른값, T = int, param = int&&

 

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

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

말을 길게 써놔서 그렇지 값에 의한 전달이 이루어지는 상황이다.

param은 인수의 복사본이므로 완전히 새로운 객체이다. 새로운 객체이기 때문에 expr에서 T가 추론되는 과정에서 Case1,2와 다른 규칙들이 적용되게 된다.

 

우선 expr의 참조 타입을 무시하는것 까지는 Case1과 동일하다. 그 이후 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

앞선 상황들과 다르게 참조, const를 모두 무시한다. 값에 의한 전달로 인해 복사가 이루어지고 완벽히 독립적인 객체가 됐기 때문에 expr의 제약을 따를 필요가 전혀 없기 때문이다.

 

다만 값 전달 매개변수에 대해서만 무시될 뿐이지 const 참조나 포인터인 매개변수에 대해서는 조금 더 생각해봐야한다.

 

template<typename T>
void f(T param); // 값에 의한 전달

const char* const ptr = "Fun with pointers"; // const 객체를 가리키는 const 포인터
f(ptr); // const char* const 타입의 인수 전달

포인터와 포인터가 가리키는것까지 모두 const가 된 상태이다. 즉, ptr은 다른 주소를 가리키지도 못하고 역참조를 통해 다른 값으로 변경하지도 못한다.

 

이때 ptr이 param에 전달되면 위의 규칙처럼 const가 무시되어서 ptr이 값으로 복사된다. 하지만 const가 무시되는것은 ptr에 대해서만이지 char*에 대한 const는 무시되지 않는다.

그래서 결과적으로 param에 대해 추론되는 타입은 const char*이 된다. const 문자열을 가리키는 수정 가능한 포인터가 된다.

 

 

 

위의 세 가지 경우가 템플릿의 타입 추론이 관여하는 대부분의 상황이고 조금 특수한 상황이 또 존재한다.

 

Case4 : 배열 인수

배열과 포인터를 구분하지 않고도 사용할 수 있지만 실제로 배열의 타입과 포인터의 타입은 다르다. 그럼에도 서로 맞바꿔가며 쓸 수 있는것은 많은 문맥에서 배열이 배열의 첫번째 원소를 가리키는 포인터로 붕괴(decay)되기 때문이다.

 

붕괴(decay)?

배열이 크기 정보를 잃고 포인터로 암시적 변환이 이루어 지는 것

 

const char name[] = "J. P. Briggs"; // const char[13]
const char* ptrToName = name; // 배열이 포인터로 붕괴된다

const char*와 const char[13]의 타입은 서로 같지 않지만 배열에서 포인터로의 붕괴 규칙덕분에 타입 불일치 오류 없이 잘 컴파일 된다.

 

template<typename T>
void f(T param);
...
f(name); // 어떤 타입으로 추론?

그런데 배열을 값 전달 매개변수를 받는 템플릿에 전달하면 어떻게 될까?

우선, 배열 타입의 함수 매개변수라는 개념은 없다는 것부터 짚고 넘어가야 한다.

 

void myFunc(int param[]);
void myFunc(int* param);
/* 동일한 함수 */

개념이 없다 뿐이지 구문 자체는 적법하다. 배열 매개변수는 포인터 매개변수처럼 취급된다.

 

f(name); // T = const char*

즉, name은 포인터 타입으로 추론되어서 const char*가 된다.

 

그런데 함수의 매개변수로 배열을 선언할수는 없지만 배열의 대한 참조 선언은 가능하다. 이게 또 무슨말인가?

 

template<typename T>
void f(T& param); // Case1
...
f(name); // T = const char[13], param = const char(&)[13] (const char[13]&)

특별한건 아니고 Case1의 경우로 추론된다.

 

template<typename T, std::size_t N>
constexpr std::size_t arraySize(T (&)[N]) noexcept { return N; }
// constexpr : 일반화된 상수 표현식. 컴파일 타임에 평가된다
// noexcept : 예외가 발생하지 않을것임을 명시
...
int keyVals[] = {1, 3, 7, 9, 11, 22, 35};
int mappedVals[arraySize(keyVals)];
// 또는 std::array<int, arraySize(keyVals)> mappedVals;

여담으로 배열에 대한 참조를 선언하는걸 활용하면 배열에 담긴 원소의 개수를 컴파일 타임에 추론하는 템플릿을 만들 수 있다.

 

Case5: 함수 인수

배열이 포인터로 붕괴하는 것처럼 함수도 함수 포인터로 붕괴할 수 있다.

 

void someFunc(int, double);

template<typename T>
void f1(T param); // Case3

template<typename T>
void f2(T& param); // Case1

f1(someFunc); // 함수 포인터로 추론. param = void(*)(int, double)
f2(someFunc); // 함수 참조로 추론. param = void(&)(int, double)

 

◾ 템플릿 타입 추론 도중에 참조 타입의 인수들은 비참조로 취급된다. 즉, 참조성이 무시된다.

◾ 보편 참조 매개변수에 대한 타입 추론 과정에서 왼값 인수들은 특별하게 취급된다.

◾ 값 전달 방식의 매개변수에 대한 타입 추론 과정에서 const, volatile 인수는 비 const, 비 volatile 인수로 취급된다.

◾ 템플릿 타입 추론 과정에서 배열이나 함수 이름에 해당하는 인수는 포인터로 붕괴한다. 단, 그런 인수가 참조를 초기화하는 데 쓰이는 경우에는 포인터로 붕괴하지 않는다.

+ Recent posts