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에 대해서 조금 더 깊게 알아봐야겠다.

+ Recent posts