본문 바로가기

진리는어디에/C++

[C++20] Concepts 완벽 가이드

이번 포스트에서는 C++20에 추가된 아주 강력한 기능 중 하나인 concept이라는 것에 대해 알아 보도록 한다. 

https://www.modernescpp.com/index.php/c-20-concepts-the-details

들어가며

이번 포스트에서는 C++20에 추가된 Concepts에 대해 살펴 보도록 하겠다. 이 글을 읽는 여러분은 아래 세 가지를 배우게 될 것이다.

  • Concepts를 만드는 방법
  • C++에서 제공하는 표준 Concept
  • 'Requires 절' 문법

C++20의 Concepts 란?

C++20에 추가된 4가지 주요 기능 주에 하나로써, 공식 문서에 따르면 concept 이란, "타입이 가져야 하는 요구 조건을 정의하는 문법"이라고 소개하고 있다. 영어로는 "Named sets of requirements", 즉 "이름을 가진 요구조건의 집합"이다.

"타입이 가져야 하는 요구 조건"을 정의한다는 것은 무슨 뜻인가?

Concept은 "템플릿 클래스", "템플릿 함수", "(일반적으로 템플릿 클래스의) 논-템플릿 함수"가 사용하는 "템플릿 인자들에 대한 제약 조건을 지정 할 수 있다". 이 제약 조건들은 컴파일 타임에 예측 되고, 평가 되며, 제약 조건으로 사용되는 템플릿 인터페이스의 일부가 된다. 또한, 함수 오버로딩이나 템플릿 특수화에서 가장 적절한 함수를 선택하는데 사용 된다.

왜 Concept이 중요한가?

필자는 단지 템플릿 인자에 제약 조건을 걸어 줄 뿐인 '컨셉(Concept)'이라는 것이 C++20에 추가된 강력한 기능 중 하나라고 추천되고 있는지 이해가 가지 않았다.

사실 템플릿 인자는 타입으로써 제약 조건들을 이미 가지고 있다. 예를 들어 아래 예제에서 처럼 템플릿 인자가 처리 할 수 없는 오퍼레이션을 시도 한다면 컴파일러가 에러를 발생시키는 것은 기존 C++에서 이미 지원 하고 있다.

/*********************************************************************************
  C++20 이전에 사용 되던 템플릿 함수가 템플릿 인자에 따라 에러를 발생 시키는 코드
*********************************************************************************/
struct A
{
};

template <class T>
void foo(const T& t)
{
    std::cout << t + t << std::endl;
}

int main()
{
    foo<int>(1);
    foo<A>(A{}); // ERROR!! 구조체 A는 '+' 연산을 지원하지 않는다!
}

이 글을 읽고 계시는 여러분들 중에도 분명히 나와 같은 불만을 가진 분이 있을것이다. 기존에도 템플릿은 인자로 넘겨진 타입에 맞지 않는 오퍼레이션을 시도하면 컴파일 타임에 오류를 발생 시켰다.

컨셉을 사용하면 에러의 가독성이 좋아진다?

https://www.slideshare.net/utilforever/c20-key-features-summary

물론, 기존 템플릿 관련 에러들이 사람이 알아 보기 힘들었던 것은 사실이다. 하지만 자세히 살펴 보면 원인을 찾지 못할 것도 없었다. 그렇다. 우리는 지금 까지 잘 해왔었다. 그리고 앞으로도 잘 해낼 것이다!! 응??

대신, 필자가 생각하는 컨셉을 사용해야하는 가장 큰 이유는,

  • 기존 C++은 템플릿 인자의 '타입'에만 의존하여 함수 오버로딩을 할 수 있었다면
  • 컨셉을 이용하면 템플릿 인자 타입을 넘어 해당 타입이 가지는 '속성' 또는 '오퍼레이션'에 따라 함수 오버로딩을 할 수 있다

는 것이다.

Concept를 이용하면 '타입'이 아니라 '조건'에 따라 오버로딩을 할 수 있다.

예를 들어 아래와 같은 템플릿 함수가 있다고 가정하자.

template <class T>
void foo(T t) 
{
    // ...
}

C++은 템플릿 인자의 타입에 따라 각 인자의 타입에 대응하는 버전의 함수들을 생성한다. 템플릿 인자가 int인지, char인지에 따라 함수 오버로딩을 통해 각 인자 타입에 맞는 다양한 버전의 함수를 만들어 준다. 심지어 템플릿 특화(Template specialize)를 통해 특정 타입에는 특정 버전의 함수를 적용하도록 할 수도 있다. 

template <>
void foo<std::sring>(std::stirng t) // std::string 템플릿 특화
{
    // std::string 에 특화된 로직
}

int main()
{
    int i = 0;
    double d = 0.0;
    std::string str = "";
    
    foo(i); // int 타입에 대해 생성된 foo 함수 호출
    foo(d); // double 타입에 대해 생성된 foo 함수 호출
    fool(str); // 특화된 템플릿 함수 호출
}

C++20에서 추가된 Concept을 이용하면 템플릿 인자 타입에 대한 판단을 넘어 "사이즈가 4바이트 이상인지 그렇지 않은지", 해당 타입이 "가상 함수를 가지고 있는지 그렇지 않은지"와 같은 논리적 판단을 컴파일 타임에 할 수 있다.

여기까지 컨셉을 왜 알아야 하는지에 대한 설명은 충분히 되었다고 생각한다. 이제 부터 본격적으로 컨셉의 사용법에 대해 알아 보도록 하자.

Concept 정의와 사용

앞서 Concept이 무엇인지, 왜 써야 하는지를 살펴 보았다. 이번 섹션에서는 Concept의 사용 방법에 대해 살펴 보도록하자.

template < 템플릿-파라메터-리스트 >
concept 컨셉-이름 = 제약-사항-표현식(requries or type-traits);

먼저 concept을 정의하는 문법은 위와 같다. 여기에서 우리는 concept, requires 라는 두 개의 키워드와 type traits가 무엇인지에 대해 주목할 필요가 있다.

  • concept : concept을 선언하는데 사용되는 키워드. concept의 이름을 정의할 수 있다.
  • requires : requires 키워드는 사용되는 위치에 따라 타입의 유효성을 평가하기 위한 '표현식(expression)'과 concept을 template 타입에 적용하는 '절(clause)'. 이렇게 두 가지 의미로써 사용된다. 자세한 설명은 다음장에 설명 된다.
  • type traits : C++11 부터 제공 되는 컴파일 타임에 타입에 대한 정보를 얻거나 변형된 타입을 얻을 때 사용하는 메타함수.

위 설명이 당장은 이해하지 못해도 상관 없다. 지금은 concept, requires, type traits 세 개의 단어만 기억하고 넘어가자. 자세한 설명은 차차 진행해 나가면서 보충 할 것이다.

※ type traits는 이번 포스트의 직접적인 주제에 벗어 나므로 본 포스팅에서는 사용법만을 간단히 언급하고 넘어 갈 것이다. type traits의 보다 자세한 정보는 [여기]를 참조 하도록 한다.

concept 절(clause)

concept을 정의하기 위해서는 concept 키워드를 이용해 이름을 선언하고 그에 따른 제약 사항들을 기술해 주면 된다. 아래는 템플릿 타입이 4바이트 이상인 경우에만 적용 되는 concept을 정의했다.

#include <concepts> // 컨셉 사용을 위한 헤더
 
template <class T>
concept GreaterThan4 = sizeof(T) >= 4; // 단순히 타입 T의 사이즈가 4이상인지 판단 하는 컨셉
  • 컨셉은 "Named set of requirements", 즉, 이름을 가진 요구 조건들의 집합이라고 했다. 위 예제에서 '컨셉-이름'은 "GreaterThan4"이다. 이 이름은 뒤에 템플릿 함수에 concept을 적용할 때 사용 된다.
  • '제약 사항 표현식' 에 타입 T의 사이즈가 4 바이트 이상인지 여부를 판단하는 조건식을 정의했다. 이 조건식이 true인 경우에만 템플릿 함수가 적용 될 것이다.
  • '제약 사항 표현식' 에는 단순 표현식이 들어갈 수도 있고, type traits등의 표현식을 사용할 수도 있다.

NOTE - 컨셉은 자기 자신을 재귀적으로 참조 할 수 없으며, 자기 자신의 제약 조건이 될 수 없다.

template<typename T>
concept V = V<T*>; // error: 재귀 참조
 
template<class T> concept C1 = true;

template<C1 T>
concept Error1 = true; // Error: C1 T은 컨셉을 컨셉 조건으로 사용하려 함.

template<class T> requires C1<T>
concept Error2 = true; // Error: requires절이 컨셉 조건으로 사용 됨.

requires 절(clause)

  • requires 절은 템플릿 인자가 가져야 하는 제약 조건(concept, traits 등)을 기술하는 문법으로써, 템플릿 함수 또는 클래스 선언과 함께 선언 한다.
  • requires 절은 컴파일 타임에 결정되는 bool 타입의 상수 값만을 사용 할 수 있다. int, char 같이 암묵적으로 bool 타입으로 캐스팅 될수 있는 타입은 사용할 수 없다.

NOTE - requires clause(절)은 뒤에 설명 할 concept을 선언 할 때 사용하는 requires expression(표현식)과 엄연히 다르다. 이름이 같다고 헷깔리지 말자.

앞에서 정의한 concept은 requires 키워드를 이용해 템플릿 함수 또는 클래스에 정용할 수 있다. 아래 예제는 우리가 앞에서 만들었던 foo 함수에 requires를 이용해 concept "GreaterThan4"를 추가한 버전이다.

template <class T> 
requires GreaterThan4<T> // requires 키워드를 이용해 앞에서 정의한 "GreaterThan4" 컨셉 사용
void foo(T arg) 
{ 
    std::cout << "size of " << typeid(T).name() << " is greater than 4" << std::endl;
}

템플릿 인자 선언 뒤에 requires 키워드를 이용해 앞에서 정의한 GreaterThan4 제약 조건을 추가했다. 이제 부터 이 템플릿 함수는 타입 T가 4 바이트 이상일 경우에만 유효하고, 그렇지 않은 경우에는 컴파일 타임에 함수를 아예 만들지 조차 않는다.

int main()
{
    int i = 10;
    short s = 10;
    double d = 3.14;
    
    foo(i); // int의 사이즈는 4와 같으므로 OK
    foo(s); // short의 사이즈는 4보다 작으므로 ERROR
    foo(d); // double의 사이즈는 4보다 크므로 OK
}

위 코드를 컴파일 하면 short 타입은 4 바이트 미만 사이즈이고 해당 조건을 만족하는 foo() 함수는 생성이 되지 않았으므로, 'no matching overloaded function found', 'the accociated constraints are not satisfied' 메시지를 출력하며 컴파일 에러가 발생한다.

그럼 위 예제에 requires를 사용하지 않은 일반 템플릿 함수를 다시 추가 해보자.

template <class T>
void foo(T t) 
{
    std::cout << "size of " << typeid(T).name() << " is less than 4" << std::endl;
}

이제 GreaterThan4 제약 조건에 만족하지 못하는 모든 타입들은 이 일반 템플릿 함수를 이용하게 된다.

OUTPUT :

size of int is greater than 4
size of short is less than 4
size of double is greater than 4

위 결과를 통해 int와 double 타입으로 호출 시 - 4 바이트 이상 제약 조건 걸린 - 같은 함수가 호출 되고, short 타입으로 호출 시 다른 일반 템플릿 함수가 호출되는 것을 확인 할 수 있다. 함수 오버로딩이 인자의 타입이 아니라 타입이 가진 속성, 즉, "타입의 사이즈 속성"이 함수 오버로딩의 기준으로 사용 되었다는 것에 주목 하도록 하자.

requires의 다양한 사용법

아래의 방법들은 requires의 위치가 다르지만 모두 동일한 의미를 가진다

// requires를 사용하지 않고 템플릿 타입으로 정의된 concept를 사용 할 수 있다
template <GreaterThan4 T>
void foo(T arg) 
{ 
}

// 템플릿 선언이 아닌 함수 선언 다음에 requires 절을 사용해도 된다
template <class T>
void foo(T arg) requires GreaterThan4<T> 
{ 
}

아래는 requires 절의 다양한 사용법들이다. requires 절에는 concept, type traits 뿐만아니라 컴파일 타임에 bool 값으로 결정 되는 모든 표현식들이 올 수 있다.

template <class T> requires true  // 의미는 없지만 이렇게 적어도 된다!!
void foo(T a) {}

template <class T> requires false // 무조건 fasle를 리턴하기 때문에 이 함수는 사용 할 수 없다
void foo(T t) {}

template <class T> requires std::is_pointer_v<T> // type trait를 바로 사용 할 수 있다
void foo(T t) {}

template <class T> requires 1 // ERROR!!. int를 사용 할 수는 없다
void foo(T t) {}

bool check_error() 
{
    return true; 
}

template <class T> requires (check_error()) // ERROR!!. 상수식 함수가 아니라 아니라 에러
void foo(T t) {}

constexpr bool check_ok() 
{ 
    return true; 
}

template <class T> requires (check_ok()) // constexpr로 컴파일 타임에 평가 되기 때문에 OK
void foo(T t) {}

template <class T> requires (sizeof(T) > 4) // 상수표현식을 바로 사용하는 것도 OK
void foo(T t) {}

template <class T>
concept GreaterThan4 = sizeof(T) >= 4;

template <class T> requires GreaterThan4<T> // concept를 사용하는 것도 OK
void foo(T t) {}

requires 표현식(expression)

지금까지 살펴본 예제에서는 concept 절이든 requires 절이든 매우 단순한 표현식만을 사용했으므로 C++11에서 지원 되던type traits를 사용하는 것과 별반 다르지 않았다. 사실 이렇게 쓸거면 concept이 C++에 도입되었다고 호들갑 떨 필요도 없었다.

하지만 본 포스트에서 제일 먼저 강조 했듯이, 컨셉은 "Named set of requirements", 즉, 이름을 가진 제약 조건들의 집합이다. 여러 개의 제약 조건들을 묶어 하나의 이름으로 사용 할 수 있으며, 지금 부터 살펴 볼 "requires expression"를 사용해 보다 한 차원 더 복잡한 제약 조건을 보다 가독성이 좋은 상태로 만들어 낼 수 있다.

requires { requirement-seq; }; // requires expression에서 인자 없이 제약 조건만을 적을 수 있다.
requires ( prameter-list ) { requirement-seq; }; // 인자를 이용한 제약 조건도 가능하다.

NOTE - 컨셉에서 제약 조건을 정의 할 때 사용하는 requires 표현식(expression)과 템플릿 함수에서 제약 조건을 참조 할 때 사용하는 requires 절(clauses)를 헷깔리지 말자.

Simple requires 표현식

Simple requires 표현식은 이름 그대로 단순 표현식(expression) requires를 의미한다. 이 표현식은 실제 연산 되는 것은 아니고 문법적으로 가능/불가능 여부만 판단한다. 

template <class T>
concept Comparable1 = requires(T a, T b)
{
    a < b; // a가 b보다 작은지는 평가 되지 않는다. 다만 a < b 비교가 가능한지만 체크한다.
};

위 예제는 실제 a와 b의 값을 비교 평가하여 true/false를 리턴하는 것이 아니고, a 와 b 사이에서 < 연산이 가능한가/불가능한가를 평하가는 것이다. 

Type requires 표현식

지정된 타입 이름이 유효한지, 즉 타입의 존재 여부를 체크하는 표현식이다. 문법은 typename 키워드 뒤에 체크하고자 하는 타입을 기술하며, 한정자(qualifier)를 가진 타입도 사용할 수 있다.

template <class T>
concept HasValueType = requires
{
    typename T::value_type;
};

위 requires 절 예제는 타입 T가 value_type이라는 타입을 가지고 있는지 검사한다. 만일 requires 표현식에서 인스턴스에 대한 접근이 필요 없다면 위 예제 처럼 템플릿 파라메터 리스트 생략도 가능하다.

합성(compound) requires 표현식

합성(compound) 표현식은 앞서 소개 된 simple requires 표현식에 리턴 타입에 대한 제약도 추가 된 버전이다.

{ 표현식 } noexcept(선택사항) -> 타입-제약-사항(선택사항)

간단한 합성 표현식 예제를 하나 살펴 보자.

template <class T>
concept Comparable = requries(T a, T b)
{
    { a < b } -> std::convertible_to<bool>;
};

위 예제는 템플릿 파라메터 a, b는 '<' 연산이 가능할 뿐만 아니라, 그 결과 값의 타입이 bool 타입으로 변환이 가능해야 한다는 뜻을 가지고 있다. 여기서 주목 할 것은, bool 타입 호환을 검사하기 위해 직접적인 bool 타입캐스팅은 사용 할 수 없고 꼭 std::convertible_to<> 를 사용해야 한다는 것이다.

그런데 위 예제가 과연 얼마나 유용한지 의문이 든다. 당연히 비교 연산의 결과값은 bool 타입인데 굳이 리턴 타입에 대한 제약을 걸어주는 것이 무슨 의미가 있나 싶을 것이다. 위 예제는 개념 설명을 위해 너무 간략화된 버전이라 그런것이고, 합성 표현식이 유용하게 활용 될 수 있는 부분은 함수에 대한 제약을 걸어 줄 때다.

아래 예제는 템플릿 인자로 넘어온 객체에 func() 함수 존재 여부를 체크한다.

template <class T>
concept HasFunc = requires (T t)
{
    t.func(); // func 멤버 함수를 가져야 한다.
};

그리고 모두 func 함수를 가지고 있는 아래 두 클래스가 있다고 가정하자. 두 클래스의 유일한 차이는 func() 함수의 리턴 타입이다.

class WithVoidFunc
{
public :
    WithVoidFunc() = default;

    void func()               // 리턴 타입 : void
    {
    }
};

class WithIntFunc
{
public :
    WithIntFunc() = default;

    int func()                // 리턴 타입 : int
    {
        return 0;
    }
};

만일 HasFunc 컨셉으로 템플릿 제약사항을 걸어준다면 위 두 클래스 타입 모두 유효하다고 판단하고 아무런 문제 없이 컴파일 된다.

template <class T>
requires HasFunc<T>
void CallFunc(T t)
{
    t.func();
}

int main()
{
    CallFunc(WithVoidFunc()); // WithVoidFunc는 func 멤버 함수가 있으므로 OK
    CallFunc(WithIntFunc()); // WithIntFunc는 func 멤버 함수가 있으므로 OK
    return 0;
}

하지만 int func() 멤버 함수를 가진 클래스만을 원한다고 가정하면 아래와 같은 concept를 사용할 수 있다.

template <class T>
concept HasIntFunc = requires (T t)
{
    { t.func() } -> std::convertible_to<int>; // 리턴 타입이 int인 func 멤버 함수가 있어야 한다.
};

class WithVoidFunc { ... }
class WithIntFunc { ... }

template <class T>
requires HasIntFunc<T> // int를 리턴하는 func 함수를 체크하는 concept로 교체
void CallFunc(T t)
{
    t.func();
}

int main()
{
    CallFunc(WithVoidFunc()); // ERROR : the concept 'HasIntFunc<WithVoidFunc>' evaluated to false
    CallFunc(WithIntFunc());
    return 0;
}

중첨된(nested) requires 표현식

중첩된 requires 표현식은 concept 내에서 다른 concept를 호출 할 때 사용 될 수 있다. concept 내에 중첩된(nested) 표현식은 중첨된 표현식의 문법은 아래와 같다.

requires { requries 제약사항-표현식; };
requires ( prameter-list ) { requries 제약사항-표현식; };

예를 들어 아래와 같이 Addable, Comparable 두 개의 컨셉이 정의 되어 있다고 가정하자.

template <class T>
concept Addable = requires(T a, T b) // + 연산이 가능한 타입만 valid
{
    a + b;
};

template <class T>
concept Comparable = requires (T a, T b) // 비교 가능한 타입만 valid
{
    a < b;
};

이제 위 두 개의 제약 사항을 동시에 갖는 concept이 필요하다면 각 제약 사항을 새로운 concept에 정의할 필요 없이 아래와 같이 중첩된 requires 표현식을 이용해 손쉽게 정의할 수 있다.

template <class T>
concept Numeric = requires(T a, T b) // + 연산과 비교 연산이 모두 가능한 타입만 valid
{
    requires Addable<T>;
    requires Comparable<T>;
};

struct OnlyAddable // + 연산만 가능한 타입
{
    friend OnlyAddable operator + (const OnlyAddable& lhs, const OnlyAddable& rhs);
};

OnlyAddable operator + (const OnlyAddable& lhs, const OnlyAddable& rhs)
{
    return lhs;
}

void func(Numeric auto a, auto b)
{
    a + b;
}

int main()
{
    int a = 0, int b = 0;

    func(a, b); // 덧셈 연산과 비교 연산이 가능한 타입이기 때문에 컴파일 성공

    func(OnlyAddable{}, OnlyAddable{}); // 비교 연산이 불가능한 타입이기에 에러

    return 0;
}

 

Concepts 사용 예

앞에서 concepts의 기본적인 이론을 살펴 보았으니 실제 어떻게 사용하면 좋을지 예제를 통해 보다 자세히 concepts에 대해 알아 보도록 하자.

아래 예제는 C++의 날짜와 시간에 관련된 clock 의 타입에 따른 처리를 컴파일 타임에 분기하여 처리하고 있다.

template<class>
struct is_clock : std::false_type {};
 
template<class T>
    requires
        requires {
            typename T::rep;
            typename T::period;
            typename T::duration;
            typename T::time_point;
            T::is_steady;
            T::now();
        }
struct is_clock<T> : std::true_type {};

C++에서 clock 타입은 rep, period, duration, time_point, is_steady 스태틱 멤버 변수와 now() 함수를 정의하고 있어야 한다고 정의한다. 세부 내용은 [여기] 참조. 그리고 그 중 is_steady 라는 const static 멤버를 통해 steady clock 여부를 결정하게 되는데 이에 따라 now()의 출력 방식이 달라져야 한다. 이 설명에서 is_steady나 now()는 중요한 것이 아니고 특정 const 변수를 통해 오버라이딩 된 함수를 적용하고 있다는 점에 주목 하도록하자.

#include <iostream>
#include <concepts>
#include <chrono>

// WallClock concept 정의
// 템플릿 인자가 clock 타입인가? is_steady 스태틱 콘스트 멤버가 false 인가
template <class _Clock>
concept WallClock = true == std::chrono::is_clock_v<_Clock> && false == _Clock::is_steady;

// SteadyClock concept 정의
// 템플릿 인자가 clock 타입인가? is_steady 스태틱 콘스트 멤버가 true 인가
template <class _Clock>
concept SteadyClock = true == std::chrono::is_clock_v<_Clock> && true == _Clock::is_steady;

template <class _Clock>
requires WallClock<_Clock>
void print()
{
    std::cout << _Clock::now() << std::endl;
}

template <class _Clock>
requires SteadyClock<_Clock>
void print()
{
    std::cout << _Clock::now().time_since_epoch().count() << std::endl;
}

int main()
{
    print<std::chrono::system_clock>();
    print<std::chrono::steady_clock>();
    return 0;
}

부록 1. 잡다한 지식

requires 절 없이도 컨셉을 사용 할 수 있다. concept은 bool 값을 리턴하는 메타 타입으로 사용가능하다.

#include <concept>
#include <type_traits>

template <class T>
concept Integeral = std::is_integeral_v<T>;

int main()
{    
    bool result = Integeral<int>; // 컴파일 타임에 결정 된다!!
}

부록 2. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!