제약과 개념
제약과 개념 (Constraints and concepts, C++20)
템플릿 인자에 대한 요구사항을 명시하고, 그걸로 가장 적절한 오버로드나 템플릿 전문화를 선택하고 싶을 때가 있어요. 클래스 템플릿, 함수 템플릿(제네릭 람다 포함), 그리고 다른 템플릿 함수(보통 클래스 템플릿의 멤버)는 제약(constraint) 과 연관될 수 있어요. 제약은 템플릿 인자에 대한 요구사항을 지정하고, 가장 적절한 함수 오버로드와 템플릿 전문화를 선택하는 데 쓰여요. 이 페이지에서 C++20의 개념(concept) 과 제약의 규칙을 살펴볼게요.
출처: cppreference
본문
이런 요구사항의 이름 붙은 집합을 개념(concept) 이라고 불러요. 각 개념은 컴파일 타임에 평가되는 조건 술어(predicate)이고, 템플릿에서 제약으로 사용되는 곳에서 템플릿의 인터페이스의 일부가 돼요.
#include <cstddef>
#include <concepts>
#include <functional>
#include <string>
// Declaration of the concept "Hashable", which is satisfied by any type "T"
// such that for values "a" of type "T", the expression std::hash<T>{}(a)
// compiles and its result is convertible to std::size_t
template<typename T>
concept Hashable = requires(T a)
{
{ std::hash<T>{}(a) } -> std::convertible_to<std::size_t>;
};
struct meow {};
// Constrained C++20 function template:
template<Hashable T>
void f(T) {}
//
// Alternative ways to apply the same constraint:
// template<typename T>
// requires Hashable<T>
// void f(T) {}
//
// template<typename T>
// void f(T) requires Hashable<T> {}
//
// void f(Hashable auto /* parameter-name */) {}
int main()
{
using std::operator""s;
f("abc"s); // OK, std::string satisfies Hashable
// f(meow{}); // Error: meow does not satisfy Hashable
}
제약 위반은 컴파일 타임에, 템플릿 인스턴스화 과정의 초기에 감지돼요. 그래서 따라가기 쉬운 오류 메시지가 나와요.
std::list<int> l = {3, -1, 10};
std::sort(l.begin(), l.end());
// Typical compiler diagnostic without concepts:
// invalid operands to binary expression ('std::_List_iterator<int>' and
// 'std::_List_iterator<int>')
// std::__lg(__last - __first) * 2);
// ~~~~~~ ^ ~~~~~~~
// ... 50 lines of output ...
//
// Typical compiler diagnostic with concepts:
// error: cannot call std::sort with std::_List_iterator<int>
// note: concept RandomAccessIterator<std::_List_iterator<int>> was not satisfied
개념의 의도는 구문적 제약(syntactic restrictions, HasPlus, Array)이 아니라 의미적 범주(semantic categories, Number, Range, RegularFunction)를 모델링하는 것이에요. ISO C++ 핵심 지침서 T.20에 따르면, "의미 있는 의미론을 지정하는 능력은 구문적 제약과 달리 진정한 개념의 정의적 특징이다."
개념 (Concepts)
개념은 이름 붙은 요구사항 집합이에요. 개념의 정의는 네임스페이스 스코프에 나타나야 해요.
개념의 정의는 다음 형태를 가져요.
template < template-parameter-list >
concept concept-name attr (optional) = constraint-expression;
attr— 원하는 개수의 속성 시퀀스.
// concept
template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
개념은 재귀적으로 자기 자신을 참조할 수 없고 제약될 수 없어요.
template<typename T>
concept V = V<T*>; // error: recursive concept
template<class T>
concept C1 = true;
template<C1 T>
concept Error1 = true; // Error: C1 T attempts to constrain a concept definition
template<class T> requires C1<T>
concept Error2 = true; // Error: the requires clause attempts to constrain a concept
개념의 명시적 인스턴스화, 명시적 전문화, 부분 전문화는 허용되지 않아요(제약의 원본 정의의 의미를 바꿀 수 없기 때문이에요).
개념은 id-표현식에서 이름 붙일 수 있어요. id-표현식의 값은 제약 표현식이 만족되면 true이고 그렇지 않으면 false예요.
개념은 다음의 일부로서 타입 제약(type-constraint)에서도 이름 붙일 수 있어요.
- 타입 템플릿 매개변수 선언,
- 플레이스홀더 타입 지정자,
- 복합 요구사항(compound requirement).
타입 제약에서 개념은 매개변수 목록이 요구하는 것보다 템플릿 인자를 하나 덜 받아요. 문맥상 추론된 타입이 암시적으로 개념의 첫 번째 인자로 사용되기 때문이에요.
template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
template<Derived<Base> T>
void f(T); // T is constrained by Derived<T, Base>
제약 (Constraints)
제약은 템플릿 인자에 대한 요구사항을 지정하는 논리 연산과 피연산자의 시퀀스예요. 제약은 requires 표현식 안에 나타나거나 개념의 본문으로 직접 나타날 수 있어요.
제약에는 (until C++26) 세 가지, (since C++26) 네 가지 타입이 있어요.
- 결합(conjunctions)
- 이접(disjunctions)
- 원자 제약(atomic constraints)
- 폴드 전개 제약(fold expanded constraints)(since C++26)
선언과 연관된 제약은 다음 순서의 피연산자를 가진 논리 AND 표현식을 정규화해서 결정돼요.
- 제약된 플레이스홀더 타입으로 선언된 각 제약된 타입 템플릿 매개변수 또는 비타입 템플릿 매개변수에 대해 도입되는 제약 표현식(나타나는 순서대로),
- 템플릿 매개변수 목록 뒤의 requires 절의 제약 표현식,
- 축약 함수 템플릿 선언에서 제약된 플레이스홀더 타입을 가진 각 매개변수에 대해 도입되는 제약 표현식,
- 후행 requires 절의 제약 표현식.
이 순서가 만족 여부를 검사할 때 제약이 인스턴스화되는 순서를 결정해요.
재선언 (Redeclarations)
제약된 선언은 같은 구문 형태로만 재선언될 수 있어요. 진단이 요구되지는 않아요.
// These first two declarations of f are fine
template<Incrementable T>
void f(T) requires Decrementable<T>;
template<Incrementable T>
void f(T) requires Decrementable<T>; // OK, redeclaration
// Inclusion of this third, logically-equivalent-but-syntactically-different
// declaration of f is ill-formed, no diagnostic required
template<typename T>
requires Incrementable<T> && Decrementable<T>
void f(T);
// The following two declarations have different constraints:
// the first declaration has Incrementable<T> && Decrementable<T>
// the second declaration has Decrementable<T> && Incrementable<T>
// Even though they are logically equivalent.
template<Incrementable T>
void g(T) requires Decrementable<T>;
template<Decrementable T>
void g(T) requires Incrementable<T>; // ill-formed, no diagnostic required
결합 (Conjunctions)
두 제약의 결합 은 제약 표현식에서 && 연산자를 사용해 형성돼요.
template<class T>
concept Integral = std::is_integral<T>::value;
template<class T>
concept SignedIntegral = Integral<T> && std::is_signed<T>::value;
template<class T>
concept UnsignedIntegral = Integral<T> && !SignedIntegral<T>;
두 제약의 결합은 두 제약이 모두 만족될 때만 만족돼요. 결합은 왼쪽에서 오른쪽으로 평가되고 단락(short-circuit)돼요(왼쪽 제약이 만족되지 않으면 오른쪽 제약으로의 템플릿 인자 치환이 시도되지 않아요. 이는 immediate 문맥 밖의 치환으로 인한 실패를 막아줘요).
template<typename T>
constexpr bool get_value() { return T::value; }
template<typename T>
requires (sizeof(T) > 1 && get_value<T>())
void f(T); // #1
void f(int); // #2
void g()
{
f('A'); // OK, calls #2. When checking the constraints of #1,
// 'sizeof(char) > 1' is not satisfied, so get_value<T>() is not checked
}
이접 (Disjunctions)
두 제약의 이접 은 제약 표현식에서 || 연산자를 사용해 형성돼요.
두 제약의 이접은 두 제약 중 하나라도 만족되면 만족돼요. 이접은 왼쪽에서 오른쪽으로 평가되고 단락돼요(왼쪽 제약이 만족되면 오른쪽 제약으로의 템플릿 인자 치환이 시도되지 않아요).
template<class T = void>
requires EqualityComparable<T> || Same<T, void>
struct equal_to;
원자 제약 (Atomic constraints)
원자 제약 은 표현식 E와 E 안에 나타나는 템플릿 매개변수에서 제약된 개체의 템플릿 매개변수를 포함하는 템플릿 인자로의 매핑(매개변수 매핑(parameter mapping))으로 구성돼요.
원자 제약은 제약 정규화 중에 형성돼요. E는 결코 논리 AND나 논리 OR 표현식이 아니에요(그것들은 각각 결합과 이접을 형성해요).
원자 제약의 만족은 매개변수 매핑과 템플릿 인자를 표현식 E에 치환해서 검사돼요. 치환이 잘못된 타입이나 표현식을 만들면 제약은 만족되지 않아요. 그렇지 않으면 lvalue-to-rvalue 변환 후의 E는 bool 타입의 prvalue 상수 표현식이어야 하고, true로 평가될 때만 제약이 만족돼요.
치환 후 E의 타입은 정확히 bool이어야 해요. 변환이 허용되지 않아요.
template<typename T>
struct S
{
constexpr operator bool() const { return true; }
};
template<typename T>
requires (S<T>{})
void f(T); // #1
void f(int); // #2
void g()
{
f(0); // error: S<int>{} does not have type bool when checking #1,
// even though #2 is a better match
}
두 원자 제약은 소스 레벨에서 같은 표현식에서 형성되고 매개변수 매핑이 동등할 때 동일(identical) 하다고 간주돼요.
template<class T>
constexpr bool is_meowable = true;
template<class T>
constexpr bool is_cat = true;
template<class T>
concept Meowable = is_meowable<T>;
template<class T>
concept BadMeowableCat = is_meowable<T> && is_cat<T>;
template<class T>
concept GoodMeowableCat = Meowable<T> && is_cat<T>;
template<Meowable T>
void f1(T); // #1
template<BadMeowableCat T>
void f1(T); // #2
template<Meowable T>
void f2(T); // #3
template<GoodMeowableCat T>
void f2(T); // #4
void g()
{
f1(0); // error, ambiguous:
// the is_meowable<T> in Meowable and BadMeowableCat forms distinct atomic
// constraints that are not identical (and so do not subsume each other)
f2(0); // OK, calls #4, more constrained than #3
// GoodMeowableCat got its is_meowable<T> from Meowable
}
폴드 전개 제약 (Fold expanded constraints)
폴드 전개 제약 은 제약 C와 폴드 연산자(&& 또는 ||)에서 형성돼요. 폴드 전개 제약은 팩 전개(pack expansion)예요.
N을 팩 전개 매개변수의 요소 수라 하자.
- 팩 전개가 유효하지 않으면(예: 크기가 다른 팩을 전개), 폴드 전개 제약은 만족되지 않아요.
N이 0이면, 폴드 연산자가&&일 때 폴드 전개 제약은 만족되고,||일 때 만족되지 않아요.- 양수
N인 폴드 전개 제약에 대해,[1, N]의 각i에 대해 각 팩 전개 매개변수가 증가하는 순서로 대응하는i번째 요소로 대체돼요.- 폴드 연산자가
&&인 폴드 전개 제약에 대해,j번째 요소의 대체가C를 위반하면 폴드 전개 제약은 만족되지 않아요. 이 경우j보다 큰 어떤i에 대해서도 치환이 일어나지 않아요. 그렇지 않으면 만족돼요. - 폴드 연산자가
||인 폴드 전개 제약에 대해,j번째 요소의 대체가C를 만족하면 폴드 전개 제약은 만족돼요. 이 경우j보다 큰 어떤i에 대해서도 치환이 일어나지 않아요. 그렇지 않으면 만족되지 않아요.
- 폴드 연산자가
template <class T> concept A = std::is_move_constructible_v<T>;
template <class T> concept B = std::is_copy_constructible_v<T>;
template <class T> concept C = A<T> && B<T>;
// in C++23, these two overloads of g() have distinct atomic constraints
// that are not identical and so do not subsume each other: calls to g() are ambiguous
// in C++26, the folds are expanded and constraint on overload #2 (both move and copy
// required), subsumes constraint on overload #1 (just the move is required)
template <class... T>
requires (A<T> && ...) void g(T...); // #1
template <class... T>
requires (C<T> && ...) void g(T...); // #2
(since C++26)
제약 정규화 (Constraint normalization)
제약 정규화 는 제약 표현식을 원자 제약의 결합과 이접의 시퀀스로 변환하는 과정이에요. 표현식의 정규형(normal form)은 다음과 같이 정의돼요.
- 표현식
(E)의 정규형은E의 정규형이에요. - 표현식
E1 && E2의 정규형은E1과E2의 정규형의 결합이에요. - 표현식
E1 || E2의 정규형은E1과E2의 정규형의 이접이에요. - 개념
C를 이름 붙이는 표현식C<A1, A2, ... , AN>의 정규형은,C의 각 원자 제약의 매개변수 매핑에서A1, A2, ... , AN을C의 각각의 템플릿 매개변수로 치환한 뒤의C의 제약 표현식의 정규형이에요. 매개변수 매핑으로의 그런 치환이 잘못된 타입이나 표현식을 만들면 프로그램은 진단 없이 ill-formed예요(no diagnostic required).
template<typename T>
concept A = T::value || true;
template<typename U>
concept B = A<U*>; // OK: normalized to the disjunction of
// - T::value (with mapping T -> U*) and
// - true (with an empty mapping).
// No invalid type in mapping even though
// T::value is ill-formed for all pointer types
template<typename V>
concept C = B<V&>; // Normalizes to the disjunction of
// - T::value (with mapping T-> V&*) and
// - true (with an empty mapping).
// Invalid type V&* formed in mapping => ill-formed NDR
- 표현식
(E && ...)과(... && E)의 정규형은 폴드 전개 제약이에요. 여기서C는E의 정규형이고 폴드 연산자는&&예요. - 표현식
(E || ...)과(... || E)의 정규형은 폴드 전개 제약이에요. 여기서C는E의 정규형이고 폴드 연산자는||예요. - 표현식
(E1 && ... && E2)과(E1 || ... || E2)의 정규형은 각각E1이 전개되지 않은 팩을 포함하면(E1 && ...) && E2와(E1 || ...) || E2의 정규형이거나,- 그렇지 않으면
E1 && (... && E2)와E1 || (... || E2)의 정규형이에요. (since C++26)
- 다른 어떤 표현식
E의 정규형은, 그 표현식이E이고 매개변수 매핑이 항등 매핑인 원자 제약이에요. 여기에는&&나||연산자로 폴드하는 것을 포함한 모든 폴드 표현식이 들어가요.
&&나 ||의 사용자 정의 오버로드는 제약 정규화에 영향을 주지 않아요.
requires 절 (requires clauses)
키워드 requires는 requires 절(requires clause) 을 도입하는 데 쓰여요. requires 절은 템플릿 인자나 함수 선언에 대한 제약을 지정해요.
template<typename T>
void f(T&&) requires Eq<T>; // can appear as the last element of a function declarator
template<typename T> requires Addable<T> // or right after a template parameter list
T add(T a, T b) { return a + b; }
이 경우 키워드 requires 뒤에는 어떤 상수 표현식이 와야 해요(requires true라고 쓸 수 있는 이유예요). 하지만 의도는 (위 예시처럼) 이름 붙은 개념이나, 이름 붙은 개념의 결합/이접, 또는 requires 표현식을 사용하는 것이에요.
그 표현식은 다음 형태 중 하나여야 해요.
- 기본 표현식(primary expression). 예:
Swappable<T>,std::is_integral<T>::value,(std::is_object_v<Args> && ...), 또는 어떤 괄호로 둘러싼 표현식. &&연산자로 이어진 기본 표현식의 시퀀스.||연산자로 이어진 앞선 표현식들의 시퀀스.
template<class T>
constexpr bool is_meowable = true;
template<class T>
constexpr bool is_purrable() { return true; }
template<class T>
void f(T) requires is_meowable<T>; // OK
template<class T>
void g(T) requires is_purrable<T>(); // error, is_purrable<T>() is not a primary expression
template<class T>
void h(T) requires (is_purrable<T>()); // OK
제약의 부분 순서 (Partial ordering of constraints)
추가 분석 전에, 모든 이름 붙은 개념의 본문과 모든 requires 표현식을 치환해서 원자 제약에 대한 결합과 이접의 시퀀스만 남을 때까지 제약이 정규화돼요.
제약 P는 P와 Q의 원자 제약의 동일성까지 P가 Q를 함의함을 증명할 수 있으면 제약 Q를 포괄(subsume) 한다고 말해요. (타입과 표현식은 동등성을 분석하지 않아요. N > 0은 N >= 0을 포괄하지 않아요.)
구체적으로, 먼저 P는 이접 정규형(disjunctive normal form)으로, Q는 결합 정규형(conjunctive normal form)으로 변환돼요. P가 Q를 포괄하는 것은 다음이 성립할 때만이에요.
P의 이접 정규형의 모든 이접 절이Q의 결합 정규형의 모든 결합 절을 포괄하는데, 여기서- 이접 절이 어떤 결합 절을 포괄하는 것은, 이접 절에 원자 제약
U가 있고 결합 절에 원자 제약V가 있어U가V를 포괄할 때만이에요. - 원자 제약
A가 원자 제약B를 포괄하는 것은 위에서 설명한 규칙으로 동일할 때만이에요.
- 이접 절이 어떤 결합 절을 포괄하는 것은, 이접 절에 원자 제약
- 폴드 전개 제약
A가 다른 폴드 전개 제약B를 포괄하는 것은, 같은 폴드 연산자를 가지고,A의 제약C가B의 제약을 포괄하며, 둘 다 동등한 전개되지 않은 팩을 포함할 때예요. (since C++26)
포괄 관계는 제약의 부분 순서를 정의하고, 이는 다음을 결정하는 데 쓰여요.
- 오버로드 해석에서 비-템플릿 함수의 최선의 생존 후보
- 오버로드 집합에서 비-템플릿 함수의 주소
- 템플릿 템플릿 인자에 대한 최선의 일치
- 클래스 템플릿 전문화의 부분 순서
- 함수 템플릿의 부분 순서
이 섹션은 불완전해요. 사유: 위에서 여기로의 백링크.
선언 D1과 D2가 제약되어 있고 D1의 연관 제약이 D2의 연관 제약을 포괄한다면(또는 D2가 제약되지 않았다면), D1은 D2만큼 적어도 제약되어(at least as constrained) 있다고 말해요. D1이 D2만큼 적어도 제약되어 있고 D2가 D1만큼 적어도 제약되어 있지 않다면, D1이 D2보다 더 제약되어(more constrained) 있다고 말해요.
다음 조건을 모두 만족하면 비-템플릿 함수 F1은 비-템플릿 함수 F2보다 부분 순서적으로 더 제약되어 있다고 말해요.
- 명시적 객체 매개변수의 타입을 제외하고(since C++23) 같은 매개변수 타입 목록을 가져요.
- 멤버 함수라면 둘 다 같은 클래스의 직접 멤버예요.
- 둘 다 비정적 멤버 함수라면 객체 매개변수 타입이 같아요.
F1이F2보다 더 제약되어 있어요.
template<typename T>
concept Decrementable = requires(T t) { --t; };
template<typename T>
concept RevIterator = Decrementable<T> && requires(T t) { *t; };
// RevIterator subsumes Decrementable, but not the other way around
template<Decrementable T>
void f(T); // #1
template<RevIterator T>
void f(T); // #2, more constrained than #1
f(0); // int only satisfies Decrementable, selects #1
f((int*)0); // int* satisfies both constraints, selects #2 as more constrained
template<class T>
void g(T); // #3 (unconstrained)
template<Decrementable T>
void g(T); // #4
g(true); // bool does not satisfy Decrementable, selects #3
g(0); // int satisfies Decrementable, selects #4 because it is more constrained
template<typename T>
concept RevIterator2 = requires(T t) { --t; *t; };
template<Decrementable T>
void h(T); // #5
template<RevIterator2 T>
void h(T); // #6
h((int*)0); // ambiguous
참고 (Notes)
피처 테스트 매크로 (Feature-test macro)
| 매크로 | 값 | 표준 | 피처 |
|---|---|---|---|
__cpp_concepts |
201907L | (C++20) | 제약 |
__cpp_concepts |
202002L | (C++20) | 조건부 사소한 특별 멤버 함수 |
__cpp_fold_expressions |
202406L | (C++26) | 폴드 표현식을 포함하는 제약의 순서 |
키워드 (Keywords)
concept, requires, typename
결함 보고 (Defect reports)
다음 동작 변경 결함 보고는 이전에 발표된 C++ 표준에 소급 적용됐어요.
| DR | 적용 대상 | 발표된 동작 | 올바른 동작 |
|---|---|---|---|
| CWG 2428 | C++20 | 개념에 속성을 적용할 수 없었음 | 허용 |