Coding Memo
[C++20] concepts - requires 본문
C++20에서 추가된 Concepts의 기능인 requires에 대해서 간단히 정리해보자.
requires는 C#에 비유하자면 where와 비슷한 키워드라고 생각할 수 있을 것 같다.
where가 C#에서 제레릭 타입에 대한 조건을 지정하듯이,
C++에서도 템플릿 타입에 대해 조건을 지정할 수 있다.
Concepts 도입 전에는 SFINAE(Substitution Failure Is Not An Error)를 이용해, 템플릿 타입을 제약했었다. 그러나 C++20 이후에는, 템플릿에서 requires를 이용하여 타입에 대한 제약 조건을 쉽고 직관적으로 지정할 수 있다.
먼저 일반적인 코드를 확인해보자.
struct A {};
template <typename T>
T add(T a, T b)
{
return a + b;
}
int main()
{
std::cout << add(5, 3) << std::endl; // ok
std::cout << add(5.5, 3.2) << std::endl; // ok
std::cout << add(std::string("Hello, "), std::string("world!")) << std::endl; // ok
auto a = add(A(), A()); // compilation error
return 0;
}
이 코드는 const char*에 대해서 컴파일 에러를 내뱉을 것이다. 이유는 이 타입에 대한 + 연산자를 적용시킬 수 없기 때문이다. 컴파일 진행 시(인스턴스 생성 시)에 에러가 날 것이다.
그렇다면 SFINAE를 사용한 코드를 확인해보자. T 타입을 + operator가 있는 값만 받는다.
struct A {};
template<typename T, typename = void>
struct enable_add_operator : std::false_type {};
template <typename T>
struct enable_add_operator<T, std::void_t<decltype(std::declval<T>() + std::declval<T>())>> : std::true_type {};
template <typename T>
auto add(T a, T b) -> typename std::enable_if<enable_add_operator<T>::value, T>::type
{
return a + b;
}
int main()
{
std::cout << add(5, 3) << std::endl; // ok
std::cout << add(5.5, 3.2) << std::endl; // ok
std::cout << add(std::string("Hello, "), std::string("world!")) << std::endl; // ok
auto a = add(A(), A()); // error
return 0;
}
operator+가 포함된 타입만 허용한다. 먼저 코드와 조금 차이점이 있다면, 인스턴스 생성 전에, IDE에서 알려준다!
만약 A에서 operator+를 다음과 같이 오버라이드하고 있다면, 컴파일은 물론 실행도 잘 된다!
(적당히 구현만 하려고 세부적인 사항은 전부 생략했다.)
struct A
{
A& operator+(const A&) { return *this; }
};
좀 더 좋아졌다! 좀 더 명시적으로 사용해서 보기 편해졌다!
그러나 코드 양도 그렇고, 여전히 복잡해 보인다.
requires를 사용하면 어떨까.
struct A {};
template <typename T>
requires requires(T a, T b) { a + b; }
T add(T a, T b)
{
return a + b;
}
int main()
{
std::cout << add(5, 3) << std::endl; // ok
std::cout << add(5.5, 3.2) << std::endl; // ok
std::cout << add(std::string("Hello, "), std::string("world!")) << std::endl; // ok
auto a = add(A(), A()); // error
return 0;
}
엄청나게 간단해졌다!
requires로 타입 T가 a+b를 수행할 수 있는지를 명시적으로 확인하라고 지시를 해준 것이다.
마찬가지로 A에 operator+를 오버라이드하면 'auto a = add(A(), A());'가 정상적으로 컴파일 될 것이다.
requires는 위와 같이 typename에 대해 특정 연산을 지원하는지 검사할 수 있게 해주는 것 뿐만 아니라, 특정 멤버 함수나 변수의 지원 여부, 특정 타입 검사, 상속 관계 검사 등도 미리 확인할 수 있도록 해준다.
또한, 위 조건들을 '&&'로 묶어 결합할 수도 있고, 반환 타입에도 requires를 사용할 수 있다!
간단하게 사용 예시를 몇 가지 확인해 보자.
1. 단순 조건 확인
단순히 표현식들을 지정하여 해당 표현식들이 유효한지 확인하라고 요구한다.
template <typename T>
concept enable_plus_minus_operator = requires (T t1, T t2)
{
t1 + t2; // T에 대해 + 연산이 가능한지 확인
};
template <enable_plus_minus_operator T>
T add(T t1, T t2)
{
return t1 + t2;
}
template <typename T>
concept printable = requires(T t)
{
// T에 대해 print 메서드가 있는지 확인 및 그 반환 값이 void인지 확인
{ t.print() } -> std::same_as<void>;
};
template<printable T>
void invoke_print(const T& t)
{
t.print();
}
위에서 사용한 방법이다. 위 코드에서는 T에 대해 + 연산이 가능한지 확인(t1 + t2)하는 것이다. '{}' 안에 확인할 표현식들을 넣어주면된다.
만약 + 뿐만 아니라, T가 - 연산도 가능하도록 하려면, t1 - t2를 넣어주든지 하면 될 것이다.
두 번째 코드도, T에 대해 print라는 메서드가 있는지 확인하고, return value(->)가 void 타입인지 확인한다. std::same_as는 미리 정의된 concept이다.
2. 타입 조건 확인
표현식 대신, typename 키워드를 통해 특정 타입과 관련된 제약을 확인한다. 이 제약에는 특정 멤버 변수나, 메서드, 타입 변환, 템플릿 특수화 등이 유효한지가 해당 될 수 있다.
template<typename T>
struct S
{
using value_type = T;
void print() const { std::cout << "s" << std::endl; }
friend std::ostream& operator<<(std::ostream& os, const S& s) { print(); }
};
template<>
struct S<std::vector<int>>
{
void print() const { std::cout << "specialized s" << std::endl; }
};
template <typename T>
concept value_type_is_integral = requires(T t, std::ostream & os)
{
// T가 value_type 타입을 가지고 있는지 확인 (named nested type)
typename T::value_type;
// Class Template Specialization
// T 타입에 대해 std::vector로 템플릿 특수화가 가능한지 확인
typename S<T>;
// Nested Requirement
// T::value_type이 integral 타입인지 확인
requires std::is_integral_v<typename T::value_type>;
};
template <typename T>
concept printable = requires(T t, std::ostream & os)
{
//// T에 operator<<를 사용할 수 있는지 확인
os << t;
};
template<typename T>
concept printable_value_type_is_integral = requires { printable<T> && value_type_is_integral<T>; };
template<typename T>
concept printable_size = requires(T t) { t.size(); };
template<printable_size T>
void print_size(const T& t)
{
std::cout << t.size() << std::endl;
};
template<printable_value_type_is_integral T>
void print_os(std::ostream& os, const T& t)
{
os << t;
}
코드에서의 주석도 유심히 확인하자.
위 코드에서는 다음의 내용을 포함한다.
(requires std::is_integral_v<typename T::value_type> 제외)
1. typename 키워드로 T가 named nested type이 있는지 요구
2. Class Template Specialization이 타입에 맞게 가능한지 확인 요구
3. 논리 연산자(&&)를 사용하여 concept에 대한 논리 조합 가능
위 내용으로 간단하게 테스트를 해보자.
int main()
{
std::vector<int> v;
print_size(v); // ok
S<int> s;
print_os(std::cout, s); // ok
return 0;
}
먼저 std::vector에 대해 보자.
typename T::value_type
std::vector의 실제 헤더 파일을 보면 'using value_type = _Ty;'이 정의 되어 있는 것을 확인할 수 있다. 즉, value_type이 타입으로 정의되어 있으므로 조건에 부합한다.
typename S<T>
코드에서 아예 특수화를 시켜놓기는 했지만 (의미 없는...), 구조체 S에 대해 T에 특별한 타입을 명시하지 않았고 인스턴스 생성에 문제가 없으므로, 조건에 부합한다.
requires std::is_integral_v<typename T::value_type>
현재 v는 int 타입에 대한 std::vector 템플릿으로 선언했으므로, value_type은 int이다. 따라서 조합에 부합한다.
printable_size
std::vector는 size()라는 메서드가 있다. 조합에 부합한다.
따라서 v는 printable_value_type_is_integral concpet에 부합하므로, print_size 메서드를 호출 하는데 문제가 없다!
구조체 S에 대해서는 간단하므로따로 생각해보자.
3. 이미 정의되어 있는 concept 사용
concepts에서 지원하는 함수들로 컴파일 타임에 조건을 확인할 수 있다.
// T가 산수형인지 확인
// 기존의 string이나 A에 대해서는 에러가 날 것이다.
template <typename T>
requires std::is_arithmetic_v<T>
T add(T a, T b)
{
return a + b;
}
// T가 Parent를 상속하고 있는지 확인
struct Parent { void foo() const {}; };
struct Child : public Parent {};
template<typename T>
requires std::derived_from<T, Parent>
void foo(const T& child)
{
// Note: foo must be const method
// parameter child is ref of const T
child.foo();
}
// TEST
Child c;
foo(c); // ok
A a;
foo(a); // error: a is not derived from Parent
std::is_arithmetic_v를 사용해서 산술 타입인지 확인하는 코드이다. typename T에 대해 int나 double 등의 타입만 인스턴스 생성이 허용된다. std::is_arithmetic_v는 미리 정의된 concept이다.
std::derived_from을 사용하여, typename T(Derived)가 Base를 상속하고 있는지 확인한다. Child 구조체의 경우, Parent를 상속하고 있지만, A같은 경우 Parent를 상속하고 있지 않기 때문에 foo(a)에서 에러가 날 것이다. 마찬가지로 std::derived_from도 미리 정의되어 있는 concept이다.
아래 링크에 좀 더 자세한 내용이 포함되어 있다.
https://en.cppreference.com/w/cpp/language/requires
Requires expression (since C++20) - cppreference.com
Yields a prvalue expression of type bool that describes the constraints. [edit] Syntax requires { requirement-seq } (1) requires ( parameter-list (optional) ) { requirement-seq } (2) [edit] Explanation Requirements may refer to the template parameters
en.cppreference.com
C#에서는 where를 이용해 매우 간단하게 제네릭에 대한 조건을 지정할 수 있었는데, 비슷하게 C++도 C++20 부터 requires로 사용 가능하다!
특히 std::derived_from은 매우 유용한 것 같다. 흐흐
좀 더 생각해보면, 이전에 포스팅 했었던 std::sort와 std::list::sort와 엮을 수도 있다.
std::sort는 random_iterator가 가능한 구조에 대해 정렬을 하지만, std::list 같은 경우에는 단순 bidirectional_iterator라 std::sort를 사용할 수가 없다. 그렇다면, std::sort 템플릿 함수에 조건을 random_iterator만 가능하도록 concept을 이용해서 넣어주면 된다.
MSVC 기준으로 sort는 단순히, 이렇게 되어 있다.
_EXPORT_STD template <class _RanIt>
_CONSTEXPR20 void sort(const _RanIt _First, const _RanIt _Last) { // order [_First, _Last)
_STD sort(_First, _Last, less<>{});
}
따라서 아래 코드는 작성할 때는 문제가 없고, 컴파일 시 문제가 발생한다.
std::vector<int> v;
std::list<int> l;
std::sort(v.begin(), v.end());
std::sort(l.begin(), l.end());
이를 해결하려면, sort에 조건을 붙이면 될 것이다.
'Language > C++' 카테고리의 다른 글
[LNK2005] already defined in ~~.obj / inline 함수 (0) | 2024.10.28 |
---|---|
템플릿 특수화, constexpr if 그리고 concept (3) | 2024.10.16 |
Singleton 패턴 (0) | 2024.09.26 |
std::sort와 std::list.sort (1) | 2024.09.26 |
std::filesystem (0) | 2024.09.19 |