momodudu.zip
Generics - template instantiation, specialization 본문
Template Instantiation
템플릿은 실제로 컴파일 타임에 특정 타입의 템플릿이 하나의 타입으로 구체화된 클래스 정의를 생성한다. 이 과정을 instantiation(구체화)라고 한다.
1) implicit instantiation
구체적인 자료형을 명세하지 않고 컴파일러에서 타입을 추론해주는 경우. 이 객체가 실제로 사용되기 전까지는 만들지 않는다. 포인터의 경우에는 선언하고 객체를 생성할때 구체화되며 포인터가 아닌 경우에는 선언만으로도 instantiation이 이루어진다.
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b;
}
int main()
{
sum(1, 2); // 컴파일할 때 int sum(int a, int b) 함수 생성
sum(1.0, 2.0); // 컴파일 할 때 double sum(double a, double b) 함수 생성
return 0;
}
2) explicit instantiation
구체적인 자료형을 명시해주는 경우. 이 구체화는 실제로 사용하려고 할 때 선언해줄수도 있고, 미리 함수 선언부를 빼놓을수도 있다.
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b;
}
template double sum<double>(double a, double b); // double 타입에 대한 explicit instantiation ----> (a)
int main()
{
sum<int>(1, 2); // int type에 대한 explicit instantiation ----> (b)
return 0;
}
여기서부터 살짝 헷갈리기 시작하는데, 천천히 살펴보면 원리를 이해할 수 있다.
일단 (a)는 double 타입에 대해서 explicit instantiation을 해준것이고, (b)는 int 타입에 대해서 explicit instantiation을 해준것이다. 즉, 둘다 똑같이 컴파일러에게 "이런 타입의 함수 청사진을 사용할테니, 만들어주세요"라고 하는것이다.
단 여기서 주의해야할점은 제일 위에 선언해놓은 템플릿 함수 원형, "즉 함수원형은 유지하되 그 타입에 대해서 여러가지 만들어서 넣어주세요" => 가 바로 instantiation이라는 점이다.
(b)의 경우에는 직관적으로 Int type으로 함수 sum을 실행하겠다로 바로 이해가 가지만, (a)의 경우는 사실 언뜻 봤을때는 밑에서 설명할 템플릿 특수화와 조금 헷갈릴수도 있다. (a)는 template에서 typename을 생략하고, double로만 받겠다고 미리 선언해두는것으로 함수의 원형은 바꿀 수 없다. 즉, 원래 선언해두었던 함수의 원형 a+b를 그대로 쓰되, 선언만 해놓고 요대로 쓸거라고 컴파일러에게 알려주는것이다. 실제로 (a)에다가 함수 내용을 작성하려고 하면 컴파일 에러가 뜬다.
3) 왜 두가지 instantiation이 존재하는걸까?
그냥 암시적으로 1)처럼 쓰면 되지, 왜 굳이 헷갈리게 명시적 & 암시적 구체화가 존재하는걸까?
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b;
}
int main()
{
double a = 1.0;
int b = 2;
sum(a, b);
return 0;
}
이 예제를 보면 바로 알 수 있다. 암시적으로 sum(a, b)를 구체화를 시키려고 하면서, 컴파일러는 sum template의 원형을 따라간다. 어? a의 타입이 double이네? 그럼 T는 double이구나! 하면서 double sum(double a, double b) 함수를 만들어낸다. 그렇지만 구체화시킨 코드를 보면 두번째 인자 b는 Int타입으로 전달해줬으므로, double != int type불일치로 타입 추론을 할 수 없어 컴파일 에러가 발생한다.
실제로 컴파일 에러도 error: no matching function for call to 'sum'. 맞는 인자의 function을 찾을 수 없다고 한다. 이 경우 사용하는게 바로 명시적 instantiation이다.
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b;
}
int main()
{
double a = 1.0;
int b = 2;
sum<double>(a, b); // 명시적 구체화. double 타입의 함수로 쓰겠다고 알려줌
return 0;
}
sum을 double type으로 명시적으로 확실하게 구체화 했으므로, 이제 sum을 호출하면 b는 double로 타입캐스팅을 해주면서, 컴파일 에러가 발생하지 않는다.
Template Specialization
특정 매개변수 타입에만 다른 동작을 명시하고 싶다면, 특수화를 사용한다. explicit specialization(명시적 특수화), partial specialization(부분적 특수화) 두가지가 있으며 함수 템플릿에서는 명시적 특수화만 가능하다.즉, 부분특수화는 클래스 템플릿에서 사용한다.
위에 구체화에서 설명한 코드처럼..
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b;
}
이 템플릿 자체가 sum이라는 함수에 대해서 온갖 타입의 오버로딩을 모두 허용하게되는 셈이다. 기본 primitive type이나 std::string 같이 + Operator가 미리 정의되어있는 애들은 이 함수 템플릿을 사용해도 컴파일 에러가 나지 않지만,
class MyData
{
public:
int val = 0;
};
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b;
}
MyData에 대해서 이 함수 템플릿을 사용하겠다고 구체화 시켜달라고 컴파일러에게 말해주면 어떨까?
int main()
{
MyData d1, d2;
sum<MyData>(d1, d2);
return 0;
}
당연히 빌드에러가 난다. 이때 빌드에러 메세지는 error: invalid operands to binary expression ('MyData' and 'MyData')
return a+b; 라고 뜬다. 즉, + operator가 정의되어 있지 않으므로 컴파일러에서는 이 클래스 타입에 대해서는 instantiation을 할 수 없다고 에러를 뱉는다. 이와 유사하게 실제 개발상황에서는 모든 타입에 대해서 통일된 템플릿을 사용할수 있는 경우는 많지 않다.
이때 방법은, 1) class MyData에 대해서 + operator를 추가해서 오버로딩 할 수 있도록해주거나 2) MyData에 대해서만 sum을 다른 동작을 정의하게 하는것이다.
여기서는 템플릿을 다루고 있으므로, 2)인 특수화에 대해서 알아보자~
1) explicit specilation
explicit specialization은 템플릿 함수를 실행하되, 특정 타입에 대해서만 다르게 동작시키고 싶을 때 사용한다. 즉, "특정 타입"을 명시하고, "특정 동작"을 명시한다. 실제 템플릿 원형과 매개변수, 반환형이 동일해야한다.
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
return a+b; // (a)
}
template double sum<double>(double a, double b);
// Explicit Specailization
template <>
MyData sum(MyData a, MyData b)
{
MyData ret;
ret.val = a.val + b.val;
return ret; // (b)
}
int main()
{
auto ret1 = sum<int>(1, 2); // int type에 대한 explicit instantiation ----> (a) 실행
auto ret2 = sum<double>(1.0, 2.0); // double 타입에 대한 explicit instantiation ----> (a) 실행
MyData d1, d2;
sum<MyData>(d1, d2); // MyData에 대해서 explicit instantiation ---> (b) 실행
return 0;
}
보면 호출하는 함수는 (a), (b) 두부분으로 나뉜다. 단, MyData type에 대해서만 다른 sum을 호출할 수 있도록 특수화를 시켰다. int나 double에 대한 sum 호출은 위에서 설명한대로 구체화되어서 (a)가 실행된다.
단, MyData의 경우에는 조금 더 부가설명이 필요한데, MyData type에 대한 구체화 자체는 함수 템플릿의 원형을 따른다. 즉, 명시적으로 sum<MyData>으로 구체화를 시켰으므로, 컴파일러는 이 함수를 오버로딩 시킬 템플릿을 찾아간다. 즉, MyData의 리턴타입을 가지고, MyData타입의 인자를 2개를 가져야만한다는걸 컴파일러가 인지한다. 즉!! 구체화라는건, 일종의 인터페이스를 참조할 수 있는 청사진이라고 보면 된다. 거기에 더해서 컴파일러는 MyData는 거기에 더해서 특수화가 선언되어 있는것을 보고, 내부 함수 동작을 특수화를 시킨것으로 만들어낸다. 이러면서 함수의 원형, 즉 동작 자체를 이 청사진과 다르게 지정할 수 있다는 말이다.
이처럼 구체화와 특수화는 헷갈리면 안되는 완전히 별개의 개념이다.
1-1) 템플릿 오버로딩
함수 템플릿은 "완전 특수화" 즉, 모든 정의된 template type마다 특수화가 다 된 경우만 특수화가 된다.
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
std::cout << "함수 템플릿" << std::endl;
return a+b;
}
// 부분적 특수화와 유사하게 동작하도록 템플릿 오버로딩
template <>
T sum(T a, char b)
{
std::cout << "템플릿 오버로딩" << std::endl;
return a;
}
첫번째가 템플릿 원형이고, 두번째가 나름 부분 특수화?를 흉내내본건데, 당연히 컴파일 에러다. 먼저 함수 템플릿에서 "특수화 하겟다"라고 하는 키워드 자체가 template<> 인데, 이렇게 되버리면 typename T가 정의되지 않으므로 문법 자체가 성립이 되지 않는다.
그럼 정녕 부분 특수화를 할 수 없는걸까? 이는 템플릿 오버로딩으로 유사하게 구현을 할 수는 있다.
// 함수 템플릿
template <typename T>
T sum(T a, T b)
{
std::cout << "함수 템플릿" << std::endl;
return a+b;
}
// 부분적 특수화와 유사하게 동작하도록 템플릿 오버로딩
template <typename T>
T sum(T a, char b)
{
std::cout << "템플릿 오버로딩" << std::endl;
return a;
}
// 템플릿 특수화
template<>
int sum(int a, char b)
{
std::cout << "템플릿 특수화" << std::endl;
return a;
}
차례대로 함수 템플릿, 템플릿 오버로딩, 템플릿 특수화이다. 두번째가 템플릿 오버로딩으로 부분 특수화처럼 구현을 한건데, 두번째 인자만 int로 특수화를 했다. 마지막은 함수 템플릿을 int로 모두 특수화 한것이다. 이때, sum(1, 2)를 했을때 어떤게 호출될 지 바로 답이 나오는 사람이 과연 있을까? 템플릿 오버로딩까지 추가되면서 모호성이 엄청나게 커진다.
sum(1, 'a')를 호출하면 실제로는 함수의 우선순위에 따라서 "명시적으로 특수화된 템플릿 함수" 즉, 3이 호출된다.
함수의 우선순위는, 모호성이 없는 명확한것이 우선순위가 가장 높은데, 템플릿이 아닌 일반 함수들이 1순위이고, 그 다음으로는 구체성을 따라간다. 즉, fully 특수화된 3번째 함수가 1순위고, 그다음 순위가 오버로딩함수다.
이렇듯 템플릿 오버로딩과 특수화를 함께 쓰면 복잡도가 매우 높아지므로 반드시 지양해야한다.
2) partial specialization
요걸 설명하려면, 클래스 템플릿에 대한 개념이 필요한데.. 함수템플릿과 사용방법은 유사하다.
template <typename T>
class TypeWrapper
{
public:
TypeWrapper(T rhs) { this->val = rhs; }
T val;
};
using IntWrapper = TypeWrapper<int>;
int main()
{
IntWrapper t1(10);
return 0;
}
일반 함수 템플릿과 똑같이, 클래스에서도 명시적으로 특수화를 할 수 있다.
함수 템플릿의 특수화로 다시 돌아가보면.. 특정 타입에 대해서 특정 동작을 하는 것이었는데, 클래스도 마찬가지다. 특정 타입에 대한 클래스는 함수처럼 한정된 범위에서 벗어나서 더 넓게 특정 동작, 특정 멤버 함수, 특정 멤버 변수등을 가질 수 있다.
template <typename T>
class TypeWrapper
{
public:
TypeWrapper(T rhs) { this->val = rhs; }
T val;
};
template <>
class TypeWrapper<double>
{
public:
TypeWrapper<double>(double rhs) { this->val = rhs; }
void roundVal() { this->val = round(this->val); }
double val;
};
using IntWrapper = TypeWrapper<int>;
using DoubleWrapper = TypeWrapper<double>;
int main()
{
IntWrapper t1(10);
DoubleWrapper t2(12.6);
t1.roundVal(); // compile error. undefined func
t2.roundVal(); // works
return 0;
}
double에 대해서 특수화를 시켰고, 이 타입에 대한 클래스에만 roundVal()이라는 함수를 작성해뒀는데, 보다시피 TypeWrapper<int>에 대해서는 undefined function이 된다. 이런 형태로 클래스도 함수처럼 특정 타입에 대해서만 다르게 동작하는 클래스를 작성할 수 있다.
여기서 개념이 조금 더 확장된것이 여기서 설명하는 부분특수화 인데, 템플릿 인자가 두개 이상이고 그 중 하나 인자에 대해서만 특수화를 하고싶을 때 사용한다.
template <typename T1, typename T2>
class MyClass
{
public:
MyClass(T1 t1, T2 t2)
{
this->val1 = t1;
this->val2 = t2;
}
void printSum() { return val1 + (T1)val2; }
T1 val1;
T2 val2;
};
template <typename T1>
class MyClass<T1, std::string>
{
public:
MyClass(T1 t1, std::string t2)
{
this->val1 = t1;
this->val2 = t2;
}
void printSum()
{
auto ret = std::to_string(val1);
return T2.append(ret);
}
T1 val1;
std::string T2;
};
MyClass는 T1, T2 두개의 타입 인자를 통해서 구체화 할 수 있다. 하지만 추가적으로 T2가 std::string일 경우에만 다르게 동작하는 클래스를 작성함으로써 <anyType, std::string>일때만 동작하는 클래스를 만들어서 부분적으로 특수화 시킬 수 있다.
특수화에서 좀 더 들어가보기
지금까지 계속 본 특수화는 "특정 타입에 대해서만 동작하는 별도의 클래스 / 함수"를 정의하는것이었다. 근데 이와는 약간 반대의 개념으로, "특정 타입외에는 이 템플릿을 사용하지 못하도록" 할 수는 없을까?
이런걸 쓰나? 싶은 생각이 들지만, 우리가 매우 자주쓰는 vector의 멤버함수만 봐도 이 기능이 필요한 경우가 바로 나온다.
벡터 생성자는 여러개가 될 수 있다. 두개의 인자를 받는다고 해도, 그게 원소 2개 일수도 있고, 숫자 2개일수도 있고... 그럼 인자 개수는 다 똑같은데, 뭘 보고 실행 동작을 바꾸지? 라는 생각이 든다.
template <class _InputIterator>
vector(_InputIterator __first,_ InputIterator __last);
정의만 봤을땐 두개의 이터레이터를 인자로 받는 벡터 생성자인데, InputIterator가 모두 템플릿으로 정의되어있어서 이 생성자는 템플릿 함수로 정의되어 있다는것을 알 수 있다.
어? 그럼 이건 vector(a.begin(), b.begin())도 허용되고, vector(1, 2)도 허용된다는건가? 라는 생각이 이쯤되면 들어야한다..이걸 stl에서는 어떻게 막아놨을까? 먼저 지금까지 배운 개념으로는 특수화를 사용할수도 있겠지만, 2개의 인자를 받는 모든 가능한 생성자에 대해서 구체화를 과연 할 수 있을까? 구체화를 해놓지 않은 타입의 인자가 들어오면 기본 템플릿에 정의된 함수가 호출될텐데, 이게 동작을 보장할까?
template <class _InputIterator>
vector(_InputIterator __first,
typename enable_if<__is_cpp17_input_iterator <_InputIterator>::value &&
!__is_cpp17_forward_iterator<_InputIterator>::value &&
is_constructible<
value_type,
typename iterator_traits<_InputIterator>::reference>::value,
_InputIterator>::type __last);
이때 사용하는것이 바로 std::enable_if다. enable_if는 컴파일타임에 이 템플릿으로 들어오려고하는 type이 특정 타입인지 검사하고, 그 타입이 맞다면 typename을 리턴해준다. 벡터의 경우에는 위처럼 구현되어있는데, 복잡해보이지만 그렇게 복잡하지 않다.
_InputIterator가 입력 반복자이고, 정방향 반복자가 아닐때 호출된다. (정방향 반복자인경우에는 따로 또 생성자가 존재한다)
이걸 우리 예제에서도 적용해볼 수 있다.
template <typename T>
class MyClass
{
public:
MyClass(T t)
{
this->val = t;
}
T getSquare() { return val * val; }
T val;
};
위와 같은 템플릿이 있다고 햇을 때, 이 클래스 템플릿을 getSqure의 올바른 동작을 보장하기 위해서 "숫자"타입만 동작하도록 클래스를 작성해보자.
물론 getSqure의 동작을 보장하는 int, double, float등에 대해서 모두 특수화 클래스를 작성해서 getSqure의 올바른 동작을 보장할 수 있겠지만, 일단 딱봐도 좋은 설계가 아니고 std::string이 들어왔을때는 어떤 동작을 넣을건지?에 대한 고민도 필요하다. 더욱이 이 클래스를 외부에 제공한다고 가정해보면, 이는 더욱더 좋은 설계가 아니다. 사용자에게 "std::string을 사용하지마세요" 가 아니라, "std::string을 쓰면 어떻게 될지 저도 모릅니다"라고 하는거나 다름없다.
template <typename T>
class MyClass
{
public:
using MyT = std::enable_if_t<std::is_arithmetic_v<T>, T>;
MyClass(MyT t)
{
this->val = t;
}
MyT getSquare() { return val * val; }
MyT val;
};
이때 사용하는게 바로 enable_if다. 클래스 템플릿 타입으로 들어온 T를 검사해서, T가 숫자인 경우에만 typename을 뱉는다.
int main()
{
// works
MyClass<int> t1(1);
MyClass<float> t2(2.0f);
MyClass<double> t3(11.0);
// compile error
MyClass<std::string> t4("hello");
return 0;
}
이 클래스로 프로그램을 돌려보면, int, float, double같은 숫자 타입만이 템플릿 생성이 가능하며 제일 밑에처럼 std::string으로 만드려고하면 컴파일 에러를 발생시킨다.
no type named 'type' in 'std::enable_if<false, std::string>'; 'enable_if' cannot be used to disable this declaration
이렇게 설계하면 누가 될지 모르는 사용자들은 "아, 여기는 숫자밖에 못쓰는구나"라고 직관적으로 알 수 있고, 확실하게 보장되는 동작만 제공할 수 있다!
'C++' 카테고리의 다른 글
#5 constexpr keyword (0) | 2022.08.01 |
---|---|
#4 std::uniform_int_distribution (0) | 2022.08.01 |
#3 Capturing variables in Lambda (0) | 2020.11.25 |
#2 C++ const keyword와 오버로딩에 관하여 (0) | 2019.10.01 |
#1 C++ STL Container (0) | 2019.08.28 |