타입 추론 - template, auto, decltype()
C++에서는 컴파일러가 인자의 타입을 알아서 추론해주는 기능을 제공한다.
기본적으로 template의 typename이 그것이다.
추가로 C++11 부터는 auto와 decltype() 라는 키워드도 type 추론을 가능하게 해준다.
Template Type Deduction(템플릿 타입 추론) 규칙
다음은 일반적인 함수 템플릿의 형태다.
template<typename Type>
void f(ParamType param);
위의 템플릿 함수에서 ParamType부분을 중점적으로 배울 것이다.
참고로 ParamType은 T를 이용하여 만들 수 있는 임의의 형식을 의미하며 실제 코드 사용 예시가 아니다.
ParamType이 실제 코드라면 T, const T, T&, const T&, T&& 같은 형태가 될 것이다.
위의 f 함수를 호출할 때는 아래와 같은 형태일 것이다.
f(expr);
이 때 컴파일러는 expr의 타입에 기초하여 ParamType과 T의 타입추론을 수행한다.
ParamType이 다음의 3가지 경우에 대해서 다르게 동작한다.(중요 ★★★★★)
- ParamType이 Reference인 경우( T& )
- ParamType이 Universial Reference인 경우 ( T&& )
- ParamType이 Reference가 아닌 경우 ( T )
1. ParamType이 Reference인 경우
1) 만약 expr이 참조(&) 타입으로 평가되면 참조를 제거(없으면 그대로 진행)한 후
2) 평가된 타입을 ParamType과 패턴 매칭을 통해 T의 타입을 추론한다.
패턴 매칭이 어떻게 이루어지는지를 알아보자
template<typename T>
void f(T& param); // 여기서 param은 참조형
int x = 27; // x의 타입은 int
const int cx = x; // cx의 타입은 const int
const int& rx = x; // rx의 타입은 const int&
f(x); // int와 T& 매칭, T는 int, param은 int&
f(cx); // const int와 T& 매칭, T는 const int, param은 const int&
f(rx); // const int와 T& 매칭, T는 const int, param은 const int&
// const가 붙은 경우
template<typename T>
void f2(const T& param);
f2(x); // int와 const T& 매칭, T는 int, param은 const int&
f2(cx); // const int와 const T& 매칭, T는 int, param은 const int&
f2(rx); // const int와 const T& 매칭, T는 int, param은 const int&
위 코드에서 타입 추론시 발생하는 패턴 매칭은
expr의 타입을 ParamType과 매칭시켜 매칭이 된 부분을 제외한 나머지를 모두 T에 매칭하는 방식이다.
즉, 템플릿 함수f에 const int&를 전달했을 때 T&와 비교하여 동일하게 매칭되는 부분은 참조(&)이다.
그럼 참조(&)를 제거하고 남은 const int가 T에 매칭이 된다.
이 방식은 ParamType이 Reference일 경우에 해당된다는 것을 명심하자.
2. ParamType이 Universial Reference인 경우( T&& )
- r-value reference 처럼 생겼지만 Universial Reference는 다르게 동작한다.
Universial Reference는 평가된 expr의 타입에 따라 다음과 같은 추론을 한다.
- expr의 타입이 l-value 인경우 T와 ParamType은 l-value reference로 추론
- expr의 타입이 r-value 인경우 1번(Reference)의 경우와 같은 방식으로 추론
template<typename T>
void f(T&& param); // 여기서 param은 Universal 참조형
int x = 27; // x는 int
const int cx = x; // cx는 const int
const int& rx = x; // rx는 const int&
f(x); // x는 lvalue, 따라서 T는 int&, param은 int&
f(cx); // cx는 lvalue, 따라서 T는 const int&, param은 const int&
f(rx); // cx는 lvalue, 따라서 T는 const int&, param은 const int&
f(27); // 27은 rvalue, 따라서 T는 int, param은 int&&
코드를 보면 알겠지만 l-value의 경우 흔히 아는대로 l-value reference로 추론된다.
expr이 r-value인 경우가 문제인데
함수 f에 상수 27이 전달된 경우 '1. ParamType이 Reference인 경우'의 패턴 매칭과 동일하게 적용된다.
즉, 상수 27의 타입인 int는 T에 매칭되고 나머지 &&가 남게 되므로 ParamType은 int&&가 된다.
3. ParamType이 Reference가 아닌 경우
- 이 경우 인자가 Value Copy(Pass by value)방식으로 전달된다.
즉, param은 expr의 평과 결과가 복사되어 전달된다.
template<typename T>
void f(T param); // 여기서 param은 비-참조형
int x = 27; // x는 int
const int cx = x; // cx는 const int
const int& rx = x; // rx는 const int&
const char* const ptr = "Fun with pointers"; // ptr은 const char* const
f(x); // T와 param의 타입은 모두 int
f(cx); // T와 param의 타입은 모두 int
f(rx); // T와 param의 타입은 모두 int
f(ptr); // T와 param의 타입은 모두 const char*
cx는 const int &인데 왜 ParamType이 int로 평가되는지 의문이 생길 것이다.
간단하다 ParamType이 Reference가 아니기 때문에 Pass by value방식을 채택하므로
복사가 진행되며 복사본은 수정되지 말아야 할 이유가 없기 때문에
원래 붙어있던 const가 제거되는 것이다. (volatile같은 키워드도 마찬가지로 제거된다)
ptr는 왜 const char* 로 변경되었는지 의아할 수 있다.
이유는 마찬가지로 복사가 되기 때문인데,
포인터가 복사가 되어도 포인터가 가리키는 대상을 변경하지 못하게 할 이유가 없기 때문에
포인터 자체를 변경하지 못하도록하는 우측의 const 키워드만 남게 되는 것이다.
그리고 예외적인 2가지 경우가 있다.
함수 인자로 배열을 전달할 경우와 함수를 전달하는 경우이다.
* 배열을 인자로 넘겼을 때
template<typename T>
void f(T param); // 여기서 param은 비-참조형
const char arr[] = "Array";
f(arr); // arr은 const char[6]이지만 T는 const char*
ParamType이 Reference가 아닐 경우 const char[6]은 const char*로 타입 추론을 수행한다.
즉, 배열의 길이를 알 수 없는 타입으로 추론한다.
하지만 ParamType이 Reference인 경우 다음과 같이 추론한다.
template<typename T>
void f(T& param); // 여기서 param은 참조형
const char arr[] = "Array";
f(arr); // arr은 const char[6], T는 const char[6], param은 const char(&)[6]
하지만 ParamType이 Reference일 경우
배열을 포인터로 바꾸지 않고 고정 길이 배열 타입 그대로 받아들인다.
그래서 아래와 같이 활용할 수 있다.
template <typename T>
auto GetArrInfo(T& arr)
{
std::cout << typeid(arr).name() << '\n';
std::cout << sizeof(arr) << '\n'; // 40
return sizeof(arr);
}
template <typename T>
auto GetArrInfo2(T arr)
{
std::cout << typeid(arr).name() << '\n';
std::cout << sizeof(arr) << '\n'; // 40
return sizeof(arr);
}
int arr[10] = { 0, };
auto s = GetArrInfo(arr);// int[10], 40
auto s2 = GetArrInfo2(arr);// int * __ptr64, 8
* 함수를 인자로 넘겼을 때
void SomeFunc(int, double); // 함수 포인터 타입은 void(*)(int, double)
template<typename T>
void f1(T param); // 여기서 param은 non-reference
template<typename T>
void f2(T& param); // 여기서 param은 reference
f1(SomeFunc); // T는 void(*)(int, double) param은 void(*)(int, double)
f2(SomeFunc); // T는 void(int, double) param은 void(&)(int, double)
// 참고로 function reference는 다음과 같은 역할을 한다
void SomeFunc2(int, double) {}
void(&funcRef)(int, double) = SomeFunc2;
ParamType이 Reference일 경우 배열에서 처럼 함수 타입이 유지되고,
non-reference인 경우에는 함수 포인터로 바꿔서 타입추론을 수행한다.
auto 키워드의 타입 추론
auto는 대입되는 R-Value expression을 평가하여 Type을 추론하는 키워드이다.
추론 규칙 1. 일반적인 경우 auto는 '템플릿 타입 추론'방식과 같은 동작을 한다.
template<typename T>
void f1(T param);
template<typename T>
void f2(T& param);
template<typename T>
void f3(const T& param);
template<typename T>
void f4(T&& param);
auto a1 = expr; // f1(expr)과 같은 타입 추론 수행
auto& a2 = expr; // f2(expr)과 같은 타입 추론 수행
const auto& a3 = expr; // f3(expr)과 같은 타입 추론 수행
auto&& a4 = expr; // f4(expr)과 같은 타입 추론 수행
코드 내용 그대로라서 설명할 것이 없다.
배열과 함수를 전달했을 때에도 마찬가지이다.
const char name[] = "R. N. Briggs"; // name의 타입은 const char[13]
auto arr1 = name; // arr1의 타입은 const char*
auto& arr2 = name; // arr2의 타입은 const char(&)[13]
void someFunc(int, double); // someFunc는 function
// 타입은 void(int, double)
auto func1 = someFunc; // func1의 타입은 void (*)(int, double)
auto& func2 = someFunc; // func2의 타입은 void (&)(int, double)
auto가 non-reference인 경우 : 함수나 배열을 전달하면 pointer타입으로 바꾸어 추론.
auto가 reference인 경우 : 함수나 배열을 전달하면 템플릿 패턴 매칭 방식 그대로.
int x1 = 27;
int x2(27);
int x3 = { 27 }; // C++11, uniform initialization
int x4{ 27 }; // C++11, uniform initialization
auto a1 = 27; // 타입은 int, 값은 27
auto a2(27); // 타입은 int, 값은 27
auto a3 = { 27 }; // 타입은 std::initializer_list<int>, 값은 {27}
auto a4{ 27 }; // 타입은 std::initializer_list<int>, 값은 {27}
C++11에서 소개된 uniform initialization 덕분에 위의 x1, x2, x3, x4가 모두 가능한 선언이다.
하지만 똑같은 방식으로 auto 선언문으로 바꾸면 결과는 달라진다.
주석에서 볼 수 있듯이 a3, a4는 int형이 아니고 std::initializer_list<int>형으로 추론된다.
braced initializer를 사용하면 컴파일러가 상황에 맞춰서 std::initializer_list 타입으로 추론한다.
auto 선언문에서도 컴파일러는 braced initializer를 std::initializer_list 타입으로 추론한다.
위와 같이 auto는 braced initializer를 std::initializer_list형으로 인식하고 타입 추론을 수행하지만
템플릿에 braced initializer를 넘기면 타입 추론을 하지 못하고 컴파일에 실패한다.
이 점이 두 타입 추론의 차이점이다. 즉 아래 예에서 f()의 호출은 실패한다.
// auto 타입 추론
auto x = { 11, 23, 9 }; // x의 타입은 std::initializer_list형으로 추론됨
// 템플릿 타입 추론
template
void f(T param);
f({ 11, 23, 9 }); // error! 타입 추론을 할 수 없음.
추론 규칙 2. 템플릿에서 std::initializer_list 타입은 추론할 수 없다.
C++14부터 auto를 함수의 return 타입과 람다의 인자에서도 사용이 가능한데
이 때는 braced initializer를 std::initializer_list형으로 인식하지 않고 컴파일을 실패하게 된다.
즉 아래 두 가지 경우에 대해서는 (auto 타입 추론이 아닌) "템플릿 타입 추론"을 수행한다.
auto SomeFunc()
{
return {1,2,3}; // 이런건 안됨
}
auto createInitList() {
return { 1, 2, 3 }; // error: { 1, 2, 3 }을 타입 추론할 수 없음.
}
std::vector<int> v;
...
auto resetV = [&v](const auto& newValue) { v = newValue; }; // C++14
...
resetV({ 1, 2, 3 }); // error! { 1, 2, 3 }을 타입 추론할 수 없음.
이렇게 보면 auto가 타입 추론되는 방식은 변수를 선언할 때만
braced initializer를 인식(std::initializer_list)한다는 점을 제외하면 템플릿 타입 추론과 완전히 동일하다.
즉, auto 타입 추론은 템플릿 타입 추론과 거의 같지만
braced initializer를 std::initializer_list형으로 인식해서 추론을 수행한다는 점이 다르다.
(템플릿 타입 추론 방식으로 동작할 때는 braced initializer를 통한 타입 추론을 실패함)
auto가 함수의 return 타입 또는 람다의 인자에 사용되었을 때는
템플릿 타입 추론과 완전히 동일하게 동작한다.(즉 braced initializer를 통한 타입 추론은 실패한다)
자주 하는 실수들
추론 규칙에 따라서 auto키워드만으로 const와 reference를 추론하지 않기 때문에 직접 붙여주어야 한다.
& 와 const 까지 추론하지는 않기 때문에 잘모르고 사용하면 예상치 못한 결과를 맞이할 수 있다. ( 자주 실수 함)
const int a = 10;
auto b = a;
b = 2;
cout << a << "\t" << b << endl;
변수 선언 시 auto의 타입 추론은 constness와 reference를 추론하지 않는다.
auto에 const int를 대입하면 int만 추론한다.
auto 가 const 까지 추론하지 않기 때문이다.
이번에는 &기호를 무시해버리는 예시이다.
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
for (vector<int>::size_type i = 0; i < v.size(); i++) {
int& data = v[i];
data = 100;
}
위의 코드를 실행하면 v의 원소들은 모두 100으로 변하게 된다.
하지만 int& data 부분을 auto data로 바꾸면 & 무시되게되고 v[i]를 단순 복사하게 된다.
그결과 벡터 v의 원소들은 아무 변화가 생기지 않고, 확인해보면 그대로 1,2,3 으로 남아있다.
이를 해결하려면 아래와 같이 auto 키워드에 & 기호를 붙여야한다.
for (vector<int>::size_type i = 0; i < v.size(); i++) {
auto& data = v[i];
data = 100;
}
혹은 R-Value쪽에 캐스팅을 해주는 식으로 사용해야한다.
auto data = static_cast<int&>(v[i]);
실제로 이렇게 쓰는 사람을 만나면......개인적으로 가독성이 중요한 부분에서는 auto의 남발을 피하는 것이 좋은 것 같다.
Decltype()
decltype specifier는 전달받은 인수의 타입에 근거하여 타입을 추론한다
< 사용 예시 >
#include <iostream>
using namespace std;
int main() {
//C++ 스타일 변수 선언 및 정의
int num(6); // 소괄호
int num2{ 7 }; // 중괄호
int num3 = { 8 };
double num4 = { 3.14 };
cout << "num3 = " << num3 << endl; // 출력 : 7
//num3(5);// 재정의 시 NG
num3 = 5;// 재정의는 원래 방식대로
cout << "num3 = " << num3 << endl; // 출력 : 5
auto integerNum(5);
auto floatNum(3.5f);
auto doubleNum(5.5);
// 타입을 자동으로 추론 함
cout << "integerNum = " << typeid(integerNum).name() << endl;//출력 : int
cout << "floatNum = " << typeid(floatNum).name() << endl; //출력 : float
cout << "doubleNum = " << typeid(doubleNum).name() << endl; //출력 : double
decltype(integerNum) intNum = 3.5f;//float 타입의 상수를 대입했지만 decltype 키워드에 의해 integerNum의 타입인 int로 타입이 정해진다
cout << "intNum = "<< intNum<<", type : " << typeid(intNum).name() << endl;//출력 : intNum = 3 , type : int
return 0;
}
< 사용 예시 2 >
#include <iostream>
#include <vector>
#include <list>
using namespace std;
template <typename T>
void foo(vector<T>& v) {
/* 벡터 라이브러리의 이터레이터 사용 시 컴파일러가 vector<T>::iterator가 typename인지 멤버 변수인지 알 수 없어 오류 발생하므로
* typename이라고 명시해주어야 합니다.
* 예외 상황을 예로 들어보자면 public으로 선언된 static 멤버 변수 등 일수도 있기 때문입니다.
* 출저 : effective c++ 서적
*/
for (typename vector<T>::iterator iter = v.begin(); iter != v.end(); ++iter)
{
cout << *iter;
}
cout << "\t";
// 위의 for문의 typename을 auto 하나로 간단하게 대체할 수 있다
for (auto iter = v.begin(); iter != v.end(); iter++)
{
cout << *iter;
}
cout << endl;
}
int main() {
vector<string> vStrings;
vStrings.push_back("Hell");
vStrings.push_back("o");
vStrings.push_back(" world!");
foo(vStrings);//출력 : Hello world! Hello world!
auto vIntegers = { 1,6,34,4,7,87,2,547,34,3,6,3 };
auto vSomething = { "apple","banana","tomato" };
cout << typeid(vIntegers).name() << endl;// 출력 : class std::initializer_list<int>
cout << typeid(vSomething).name() << endl;// 출력 : class std::initializer_list<char const *>
for (auto l : vSomething)
cout << l << "\t"; // 출력 : apple banana tomato
return 0;
}
위와 같이 auto 키워드를 사용하면 긴 코드가 좀 더 짧아진다.
주의사항!
- auto 는 & 와 const를 무시한다는 사실!!
아래 코드를 보면서 다시 떠올리고 잊어버리지 않기!!!
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
for (auto elem : v)
{
elem = 100; // elem은 v의 원소에 대한 참조가 아닌 for ranged loop의 Scope내에서 생성된 인스턴스
cout << elem << endl; // 출력 : 100
}
//따라서 elem에 다른 값을 대입해도 원본 v에는 아무 영향이 없음에 주의!!
for (auto elem : v)
{
cout << elem << endl; // 출력 결과 : 1 2 3
}
위의 코드에서 elem 은 원본에는 영향을 주지않는 읽기 전용 객체나 마찬가지인 셈이다.
따라서 원본에 영향을 주고 싶다면 아래와 같이 auto& 를 사용하여야한다
vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
for(auto& elem : v)
{
elem=100;
}
// 원본에 영향을 주고 싶지 않다면 그냥 auto
for(auto elem : v)
{
cout<<elem<<endl; // 출력 결과 : 100 100 100
}
PS.
auto가 const 키워드를 무시하는 이유가 무엇일까 생각해 보았다.
아마 기본적으로 & 기호를 무시하기 때문에, 참조자가 아니므로 원본에 영향을 줄 수 없기 때문이 아닐까 생각해본다.