Coding Memo

[C++20] concepts - requires 본문

Language/C++

[C++20] concepts - requires

minttea25 2024. 10. 2. 23:21

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++' 카테고리의 다른 글

Singleton 패턴  (0) 2024.09.26
std::sort와 std::list.sort  (1) 2024.09.26
std::filesystem  (0) 2024.09.19
dllexport / dllimport (MSVC)  (0) 2024.09.04
[winsock] getpeername 호출 시, WSAENOTCONN(10057) 에러  (0) 2024.05.23