본문 바로가기

진리는어디에/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 절' 문법

Concept란?

먼저 많은 분께 생소한 개념인 Concept에 대해 알아 보자. C++20 공식 문서에 따르면 C++의 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를 이용하면 '타입'이 아니라 '조건'에 따라 오버로딩을 할 수 있다.

예를 들어, 기존 C++은 템플릿 인자가 int 형인지, char 형인지에 따라 함수 오버로딩을 통해 다른 로직을 실행 할 수 있었다면, C++20에서는 템플릿 인자의 타입을 넘어 "사이즈가 4바이트 이상인지 그렇지 않은지", 해당 타입이 "가상 함수를 가지고 있는지 그렇지 않은지"와 같은 논리적 판단을 컴파일 타임에 할 수 있다.

이것으로 기존에 런타임에 수행 되던 논리 연산들을 컴파일 타임에 결정하여 보다 빠른 실행 속도를 제공 할 수 있으며 런타임에 발생할 수 있는 오류들을 컴파일 타임에 검출 해낼 수 있다.

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

Concept은 어떻게 사용하는가?

컨셉을 사용하기 위해서는 우선 concept, requires 라는 C++20에 추가된 두개의 키워드와 type traits라는한가지 개념을 알아야 한다.

  • concept : 템플릿 인자의 제약 조건을 기술하는데 사용되는 키워드.
  • requires :
    템플릿 인자가 가져야 하는 조건을 표기 하는 키워드.
    조건을 만족하는 경우에만 템플릿을 사용하여 함수 생성.
    requires 절에 표기 할 수 있는 것은 concept 뿐만 아니라 type_traits등도 사용 가능.
  • type traits : C++11 부터 <type_traits> 헤더로 제공 되는 컴파일 타임에 타입에 대한 정보를 얻거나 변형된 타입을 얻을 때 사용하는 메타함수.

물론 이 위 설명만으로는 한참 설명이 부족다는 것을 안다. 지금은 저런 개념이 있구나 정도로만 머리에 담아 두고, concept, requires, type traits 세 개의 단어만 기억하고 넘어가자. 자세한 설명은 차차 진행해 나가면서 보충 할 것이다.

※ type traits까지 자세하기 다루기에는 분량이 너무 방대해지므로 본 포스팅에서는 사용법만을 간단히 언급하고 넘어 갈 것이다. type traits의 보다 자세한 정보는 [여기]를 참조 하도록 한다.

concept 절(clause)

template < template-parameter-list >
concept concept-name = constaint-expression;

컨셉은 위와 같은 문법을 가진다. 워밍업으로 아주 간단한 컨셉을 이용한 제약 조건을 하나 만들어 보자.

#include <concepts> // 컨셉 사용을 위한 헤더
 
template <class T>
concept GreaterThan4 = sizeof(T) >= 4; // 단순히 타입 T의 사이즈가 4이상인지 판단 하는 컨셉

위 예제는 템플릿 인자 타입 'T'의 사이즈가 4 바이트 이상인 경우에만 true를 리턴한다고 정의하고 있다.
컨셉은 "Named set of requirements", 즉, 이름을 가진 요구 조건들의 집합이라고 했으니, 이름을 "GreaterThan4"라고 정해 주었다.

그럼 정의된 컨셉은 어떻게 사용 할까? 이 때 requires 키워드가 사용 된다.

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

// 아래의 방법들도 동일한 의미를 가진다
/**
// requires를 사용하지 않고 템플릿 타입으로 정의된 concept를 사용 할 수 있다
template <GreaterThan4 T>
void foo(T arg) {}

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

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

concept에서 기술한 제약 조건에 따라 함수 오버로딩을 하는 코드를 추가해 보자. 1라인에서 템플릿 인자 선언 옆에 requires 키워드를 이용해 위에서 정의한 GreaterThan4 제약 조건을 불러 왔다. 이제 부터 이 템플릿 함수는 타입 T가 4 바이트 이상일 경우에만 유효하고, 그렇지 않은 경우에는 컴파일 타임에 함수를 아예 만들지 조차 않는다.

위 코드를 컴파일 하면 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 타입으로 호출 시 다른 일반 템플릿 함수가 호출되는 것을 확인 할 수 있다. 함수 오버로딩이 인자의 타입이 아니라 타입이 가진 속성, 즉, "타입의 사이즈 속성"이 함수 오버로딩의 기준으로 사용 되었다는 것에 주목 하도록 하자.

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의 사용법을 자세히 알아 보자.

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

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

아래는 requires 절의 다양한 사용법들이다.

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)

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

사실 위의 requires 절에서 예제로 사용한 컨셉들은 C++11에서 부터 지원 되는 type traits를 사용하는 것과 별반 다르지 않다. 이럴꺼면 그냥 requires절에 type traits를 바로 쓰지 왜 컨셉이라는 것을 만들어 쓰느냐고 또 불만을 가질 수 있을 것이다. 하지만 앞에 언급 햇듯이, 컨셉은 "Named set of requirements", 이름을 가진 제약 조건들의 집합이다. 여러개의 제약 조건을 묶어 하나의 이름으로 사용 할 수 있으며, 지금 부터 살펴 볼 "requires expression"를 사용해 보다 한차원 더 복잡(?)한 제약 조건을 만들어 낼 수 있다.

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

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

requires 표현식에서 아무런 인자 없이 T 라는 타입에 value_type이 있는지만 검사한다. requires 표현식에서 굳이 인자가 필요 없다면 생략 가능하다. 아래는 표현식에서 인자를 사용할 때의 예이다.

template <class T>
concept Comparable1 = requires(T a, T b)
{
    a < b;
};

템플릿 파라메터의 타입이 '<' 연산이 가능 하면 참, 그렇지 않으면 거짓

#include <concepts>

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

템플릿 파라메터의 타입이 '<' 연산이 가능하고, 그 결과 값의 타입이 bool 타입으로 변환이 가능해야 한다는 뜻이다.

여기서 주목 할 것은, bool 타입 호환을 검사하기 위해 직접적인 bool 타입 캐스팅 같은 것은 사용 할 수 없고 꼭 std::convertable_to<> 를 사용해야 한다는 것이다. convertable_to는 <concepts> 헤더에 정의 되어 있다.

#include <concepts>

template <class T>
concept Equality = requires(T a, T b)
{
    { a == b } -> std::convertable_to<bool>;
    { a != b } -> std::convertable_to<bool>;
};

이전에 언급 했듯이 concept는 여러개의 조건을 기술 할 수 있다. 위의 조건이 모두 참이어야 이 컨셉도 참이 된다. 템플릿 타입 a, b는 '==' 오퍼레이션과 '!=' 오퍼레이션을 지원 해야하고, 둘다 bool 타입을 리턴해야만 참이 된다.

template <class T>
concept Container = requires(T t)
{
    t.begin();
    t.end();
};

위는 타입 T 객체의 begin()과 end()함수를 호출 할 수 있어야 한다는 뜻이다.

이상 requires 표현식의 몇가지 예를 살펴 보았다. requires 표현식 코드 작성을 어렵게 느끼지 말았으면 한다.

간단하게 생각했을 때, 표현식의 내용대로 컴파일이 되는 코드면 true, 그렇지 않으면 false다. 만일 맨 마지막 Container 컨셉의 템플릿 인자로 begin()이나 end()가 없는 객체 타입을 넘기게 된다면 컴파일 오류가 발생 할 것이고, 그것은 곧 컨셉에서 false를 리턴한다는 뜻이다. 

NOTE - 사실 위에서 언급한 컨셉들은 이미 C++20 표준에서 제공하고 있다. 상당히 많은 컨셉들이 이미 만들어져 있으니 삽질 보다는 검색을 먼저 하자.
Concepts library(C++20) : https://en.cppreference.com/w/cpp/concepts

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. 같이 읽으면 좋은 글

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