STL 자료구조의 구현부를 탐색하던 중, 아래와 같은 코드를 보았다.
_EXPORT_STD template <class _It>
concept bidirectional_iterator = forward_iterator<_It> && derived_from<_Iter_concept<_It>, bidirectional_iterator_tag>
&& requires(_It __i) {
{ --__i } -> same_as<_It&>;
{ __i-- } -> same_as<_It>;
};
다른건 그렇다고 쳐도, concept와 require라는 키워드를 처음보아서 이에 대해 찾아보았다.
먼저, 이를 이해하기 위해선 템플릿에 대해 알아야 한다.
템플릿이란, 하나의 기능을 다양한 자료형에 대응할 수 있도록 만들어주는 매우 편리한 기능이다. 하지만, 템플릿에는 한가지 단점이 있는데 잘못된 자료형으로 인한 오류를 쉽게 탐지할 수 없다는 것이다. 상황에 따라 컴파일 타임에 오류가 탐지되지 않기도 하고 오류가 탐지되더라도 그 오류 메세지가 말도안되게 복잡하고 까다로운 상황이 자주 발생한다.
즉, 모든 자료형에 대응할 수 있도록 만들어진 문법이지만 프로그래머 입장에선 그 자율성이 다소 부담이 되기도 한다. 그래서 C++에선 템플릿의 매개변수를 제약하는 방법을 제공해준다. 예를 들어, 특정 클래스를 상속받은 자료형만 템플릿 매개변수에 대입할 수 있다든가 하는 방법 말이다.
아래의 코드를 보자.
class Test
{
public:
Test(int _Value)
{
Value = _Value;
}
void operator+=(const Test& _Other)
{
Value += _Other.Value;
}
private:
int Value = 0;
};
template<typename T1, typename T2>
void Add(T1& _Left, T2& _Right)
{
_Left += _Right;
}
int main()
{
Test Test1(2);
Test Test2(5);
Add(Test1, Test2);
return 0;
}
Add함수는 템플릿 인자로 들어온 두 값중 첫번째 인자에 두번째 인자를 += 으로 연산하고 있다.
그리고 main함수에선 Test의 두 인스턴스를 만든 뒤에 Add 함수의 인자로 대입하여 함수를 호출하였다.
이는 당연히 문제가 없다. 왜냐하면 Test 클래스엔 += 연산자가 오버로딩이 되어있기 때문이다.
하지만, 만약 오버로딩이 되어있지 않다면? 컴파일 에러가 발생하고 만다. 즉, 위의 경우엔 += 연산자가 오버로딩 되어있는 클래스만 템플릿 매개변수로 사용되어야 한다는 것이다.
그렇다면, 우리는 먼저 아래와 같은 방법을 사용할 수 있을 것이다.
class Parent
{
virtual void operator+=(Parent& _Other) = 0;
};
이렇게 += 연산자 오버로딩이 순수 가상함수로 선언된 객체를 만들고 이 객체를 상속받은 객체만 Add 함수의 매개변수로 사용 가능하도록 만드는 것이다. 그렇게 되면 적어도 Add 함수에서 사용되는 객체에 += 연산자가 오버로딩이 되어있지 않을 가능성은 없어지기 때문이다.
C++ 11에선 아래와 같은 방법으로 이를 제약할 수 있다.
template<typename T1, typename T2,
typename = typename std::enable_if<std::is_base_of<Parent, T1>::value && std::is_base_of<Parent, T2>::value>::type>
void Add(T1& _Left, T2& _Right)
{
_Left += _Right;
}
enable_if 안의 식이 false라면 컴파일 오류가 발생하고 true라면 정상적으로 컴파일이 된다.
std::is_base_of는 첫번째 매개변수가 두 번째 매개변수의 상위 클래스인지를 검사하는 식이다.
즉, T1과 T2가 모두 parent를 상속받고 있어야만 정상적으로 컴파일이 되는 것이다.
하지만, 위의 문법은 너무 길다. C++ 14에선 아래와 같이 줄일 수 있다고 한다.
template<typename T1, typename T2,
typename = std::enable_if_t<std::is_base_of_v<Parent, T1> && std::is_base_of_v<Parent, T2>>>
void Add(T1& _Left, T2& _Right)
{
_Left += _Right;
}
std::enable_if 대신 std::enable_if_t를 사용하여 typename을 생략하였고, 끝의 ::type도 생략되었다.
또한, std::is_base_of 대신 std::is_base_of_v를 사용하여 끝의 ::value도 생략해주었다.
하지만, 이 역시나 너무 길고 가독성도 안좋고 불편하다. 그래서 생겨난 것이 concept와 Requires라고 한다.
(concept와 requires는 C++ 20부터 사용 가능하다고 한다.)
template<typename T>
concept ParentDerived = std::is_base_of_v<Parent, T>;
template<typename T1, typename T2>
requires ParentDerived<T1> && ParentDerived<T2>
void Add(T1& _Left, T2& _Right)
{
_Left += _Right;
}
매크로 정의를 하듯이 concept 뒤에 이름과 정의를 대입해주고, 이를 템플릿 함수에서 requires로 사용하면 된다.
ParentDerived는 std::is_base_of_v<Parent, T>로 정의가 되어있기 때문에, ParentDerived<T1> std::is_base_of_v<Parent, T1>이 되는 것이다. 앞에서 보앗던 것과 동일하게 requires뒤의 구문이 true가 되어야만 정상적으로 컴파일이 된다.
concept으로 정의해놓은 대상은 다양한 템플릿 함수에 무한정 사용할 수 있다. (물론 똑같은 이름으로 여러개가 정의되어있으면 안된다.)
아래와 같이 Requres는 함수 이름 뒤에 적어도 된다.
template<typename T1, typename T2>
void Add(T1& _Left, T2& _Right) requires ParentDerived<T1>&& ParentDerived<T2>
{
_Left += _Right;
}
심지어 아래와 같이 사용도 가능하다고 한다.
void Add(ParentDerived auto _Left, ParentDerived auto _Right)
{
_Left += _Right;
}
template<typename T1, typename T2> 를 빼고, concept로만 함수를 정의한 것이다.
이런식으로 템플릿 매개변수를 제약하는 방법을 알아보았다.
이를 전문용어(?)로 SFINAE 라고 부른다고 한다. (Substitution Failure Is Not An Error)
특정 자료형을 템플릿 함수에서 사용할 수 없을 때, 이 것이 컴파일 오류로 발생하도록 냅두는 것이 아니라 템플릿 생성이 무시되도록 한다는 의미인 듯 하다. 실제로 사용해보면 조건에 맞지 않는 자료형을 대입했을 때 템플릿 매개변수 오류가 아니라 오버로딩된 함수를 찾을 수 없다는 오류가 뜬다.
(템플릿 생성이 무시되어버리기 때문에 무시무시한 오류 메세지를 보지 않아도 된다. 조건에 맞게 오버로딩만 잘 만들어 두면 정상작동한다.)
지금은 concept와 requires에 대해서만 알아보았지만, SFINAE에 대해서 조금 더 깊게 알아봐야겠다.
'C++ > C++' 카테고리의 다른 글
C++ - 디버깅을 위한 로그 추적 (TRACE, std::source_location) (3) | 2024.10.05 |
---|---|
C++ - if constexpr (1) | 2024.10.05 |
C++ - 스마트 포인터 (shared_ptr, weak_ptr, unique_ptr) (1) | 2024.06.13 |
C++ - Callable 과 std::function (함수 객체, 람다 함수) (1) | 2024.06.11 |
C++ - Move Semantic (std::move, std::forward) (1) | 2024.06.09 |