CodeGym /행동 /C++ SELF /가장 단순한 concept와 requires

가장 단순한 concept와 requires

C++ SELF
레벨 57, 레슨 3
사용 가능

1. 문제가 생기는 원인: “템플릿이 아무거나 받아들임”

템플릿을 막 익혔을 때는 범용 함수를 작성하고 타입을 더는 신경쓰지 않으려는 충동이 듭니다. 이는 정상적인 과정입니다. 하지만 템플릿에게는 특유의 “농담”이 있습니다: 문제가 되는 타입으로 템플릿을 호출하기 전까지는 모든 것이 완벽해 보이고 컴파일도 통과하지만, 버그는 소파 아래 고양이처럼 숨어 있습니다.

작은 예를 보겠습니다. 보기에는 무해하지만 이미 문제가 숨겨져 있습니다: 타입에 대한 요구사항(operator+)이 인터페이스에 있지 않고 구현 내부에 “숨겨져” 있습니다.

#include <iostream>
#include <vector>

template <typename T>
T add(T a, T b) {
    return a + b; // T에 operator+가 있어야 함
}

int main() {
    std::cout << add(2, 3) << '\n'; // 5

    std::vector<int> x{1, 2};
    std::vector<int> y{3, 4};

    // std::cout << add(x, y) << '\n'; // 컴파일 오류 (메시지가 길 수 있음)
}

문제는 컴파일러가 “나쁨”이 아니라는 점입니다. 컴파일러는 add<std::vector<int>>의 버전을 생성하려 시도하고, a + b에서 멈추며 “std::vector<int>에는 이러한 덧셈이 없다”고 말합니다. 그러나 실제 코드에서는 템플릿 내부에 알고리즘, 이터레이터, 일반화된 코드가 많아 근본 원인이 세부사항에 묻히기 때문에 오류 메시지가 시끄러울 수 있습니다.

여기서 드는 아이디어는: 컴파일러에게 미리 “add는 덧셈이 가능한 타입에 대해서만 동작한다”고 알려줄 수 없을까 하는 것입니다.

2. Constraint, concept와 서로 다른 두 종류의 requires

혼동을 줄이기 위해 용어를 정리합시다. "constraint"(제약)는 컴파일 단계에서 어떤 타입이 허용되는지에 대한 규칙을 의미합니다. 이는 런타임의 if가 아니며 실행 시 검사가 아닙니다. 지하철 개찰구와 비슷합니다: 토큰이 있거나 없거나 — 개찰구는 내부적으로 “생각”하지 않고 단지 통과를 허용하지 않습니다.

concept은 제약에 붙이는 이름입니다. 즉 “타입이 필요한 연산을 지원하는지”라는 검사를 사람이 이해하기 쉬운 이름으로 정의하는 것입니다. 그러면 함수 시그니처만 보고도 T에게 무엇이 기대되는지 읽을 수 있습니다.

하지만 C++에서의 requires는 두 가지 “역할”로 쓰이며, 이것이 초보자에게 흔한 혼란의 원인입니다.

첫 번째는 requires-expression입니다. 이는 연산이나 표현식에 대한 요구를 서술하는 표현식으로, 대략 다음과 같습니다: requires(...) { ... }. 안에서는 “다음 표현식들을 컴파일해봐라”라고 적습니다.

두 번째는 requires-clause(선언의 일부)입니다. 이미 조건을 서술해두고 함수에 “붙이는” 경우입니다: ... requires MyConcept<T>.

간단히 말하면, requires-expression은 요구사항을 설명하고, requires-clause는 선언에 요구사항을 부착합니다.

표준 위원회도 가끔 문서에서 문법적인 실수를 하며 나중에 편집으로 고치곤 한다는 작은 사실: 어떤 초안에서는 concept 정의 문법에서 필수 세미콜론을 누락했다가 수정한 적이 있습니다. 그러니 세미콜론을 잊었다면 “표준을 쓰는 사람들도 실수했다”는 위안을 가질 수 있습니다.

3. 최소한의 concept: 표현식의 존재 여부 확인

지금 우리는 가장 작은 “블록”을 만듭니다: requires-expression으로 작성한 간단한 concept. 코드가 조금 더 “수학적”처럼 보이지만, 실제 의미는 아주 실용적입니다: “x.size()를 쓸 수 있다면 타입이 적합하다”라는 것뿐입니다.

참고로, requires는 런타임에 size()를 호출하지 않습니다. 컴파일 단계에서 해당 표현식이 유효한지만 검사합니다. 즉 이건 값이 아니라 코드 형태에 대한 검사입니다.

#include <string>
#include <vector>

template <class T>
concept HasSize = requires(const T& x) {
    x.size(); // 표현식이 유효해야 함
};

int main() {
    static_assert(HasSize<std::string>);
    static_assert(HasSize<std::vector<int>>);
    // static_assert(HasSize<int>); // 컴파일되지 않음: int에는 size()가 없음
}

여기서 중요한 점은 requires(const T& x)처럼 참조를 사용하는 것입니다. requires(T x)가 아니라는 점을 주목하세요. 우리는 객체에 대한 참조를 받아 x.size()를 시도해보겠다고 말하는 것입니다.

참조는 복사나 이동을 요구하지 않기 때문에 거의 항상 가장 관대하고 덜 요구적인 선택입니다.

4. 최소 문법: concept을 함수에 적용하는 방법

concept을 정의하면 다음 단계는 그것을 함수에 “붙이는” 것입니다. C++에는 여러 방식이 있으며 의미는 비슷하지만 가독성에서 차이가 있습니다. 모든 것을 암기하기보다는 이것들이 같은 계약을 표현하는 세 가지 표기법임을 이해하는 것이 중요합니다.

제한된 템플릿 파라미터: template <HasSize T>

이 스타일은 읽기 쉽습니다: 문자 그대로 “T는 HasSize여야 한다”고 말합니다.

#include <cstddef>
#include <string>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

template <HasSize T>
std::size_t len(const T& x) {
    return x.size();
}

int main() {
    std::string s = "hello";
    (void)len(s);

    // (void)len(10); // 적합하지 않음: int는 HasSize가 아님
}

requires-clause: … requires HasSize<T>

이 스타일은 시그니처가 복잡하거나, 먼저 타입을 선언하고 나중에 계약을 덧붙이고 싶을 때 유용합니다.

#include <cstddef>
#include <string>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

template <class T>
std::size_t len2(const T& x) requires HasSize<T> {
    return x.size();
}

int main() {
    std::string s = "abc";
    (void)len2(s);
}

제한된 플레이스홀더: HasSize auto&

약간 생소해 보이지만 작은 함수에서는 매우 편리합니다. 파라미터가 HasSize를 만족하는 “무언가”임을 말하는 방식입니다.

#include <cstddef>
#include <string>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

std::size_t len3(const HasSize auto& x) {
    return x.size();
}

int main() {
    std::string s = "world";
    (void)len3(s);
}

이 모든 것을 혼돈으로 기억하지 않으려면 한 번만 간단한 표를 보고 필요할 때 꺼내 쓰면 됩니다.

표기 읽는 법 언제 편한가
template <HasSize T>
“T는 HasSize여야 함” 템플릿 함수가 단순할 때
template <class T> ... requires HasSize<T>
“함수는 다음 조건일 때 사용 가능” 조건이 여러 개이거나 수학적 표기가 좋을 때
HasSize auto&
“파라미터가 concept을 만족함” 함수가 짧고 유틸리티성일 때

예제: 콘솔 유틸리티 print_size

이걸 추상적으로만 두지 않기 위해, 코스의 이 시점까지 이미 간단한 콘솔 앱(MiniStats)이 있다고 가정합시다: double를 저장한 std::vector<double>를 보관하고 출력하며 합계와 평균을 계산합니다. 우리는 문자열, 벡터, 다양한 컨테이너에 재사용할 수 있는 범용 함수를 작성하고 싶습니다.

실용적인 목표부터 시작합시다: 객체에 size()가 있으면 길이를 출력하고 싶습니다. std::string과 std::vector는 가능하지만, int는 아닙니다.

#include <iostream>
#include <string>
#include <vector>

template <class T>
concept HasSize = requires(const T& x) { x.size(); };

template <HasSize T>
void print_size(const T& x) {
    std::cout << "size = " << x.size() << '\n';
}

int main() {
    std::string s = "abc";
    print_size(s);              // size = 3

    std::vector<int> v{10, 20};
    print_size(v);              // size = 2

    // int n = 5;
    // print_size(n);           // 컴파일되지 않음: size() 없음
}

여기서 교육적 마법이 일어납니다: 오류가 템플릿 내부 어딘가가 아니라 print_size(n) 호출 지점에서 바로 발생합니다. 즉 계약이 인터페이스에서 드러나고 구현 내부 깊숙이 숨어있지 않습니다.

5. Addable: “더할 수 있음”

다음 흔한 시나리오는 “타입을 더할 수 있다”는 것입니다. 범용 합계, 연결 등을 작성할 때 필요합니다. 중요한 점은 실제로 사용하는 것만 요구해야 한다는 것입니다.

a + b 표현식이 유효한지 검사하는 Addable을 만들어봅시다.

#include <string>

template <class T>
concept Addable = requires(const T& a, const T& b) {
    a + b; // 컴파일 가능해야 함
};

template <Addable T>
T add(T a, T b) {
    return a + b;
}

int main() {
    (void)add(1, 2);
    (void)add(std::string{"ab"}, std::string{"cd"});
}

여기서 주의할 점은 requires에서 const T&를 사용했고 값으로 받지 않았다는 것입니다. 이것은 단순한 미학이 아니라, 타입이 우연히 복사 가능해야 한다는 불필요한 요구를 방지합니다. 다음 섹션에서 이유를 보겠습니다.

6. 함정: requires(T x) — 불필요한 타입 요구

requires(...) 안에서 "가짜" 파라미터를 선언해 표현식을 검사합니다. 아주 쉽게 의도하지 않은 방식으로 작성할 수 있습니다.

requires(T x)처럼 쓰면 종종 컴파일러가 값으로서 T 객체를 생성할 수 있는지를 확인하게 됩니다. 이는 복사나 이동 가능성을 요구할 수 있습니다. 리소스를 소유하는 많은 타입은 복사가 금지되어 있고, 그 결과 당신의 concept는 본래 의도와 달리 그러한 타입들을 거부하게 됩니다.

"1을 더할 수 있는가"를 검사하는 나쁜 버전과 좋은 버전을 비교해봅시다.

#include <memory>

template <class T>
concept BadConcept = requires(T x) {
    x; // 여기서부터 생성/복사에 대한 불필요한 요구가 생길 수 있음
};

template <class T>
concept GoodConcept = requires(const T& x) {
    x; // 참조는 요구가 훨씬 관대함
};

int main() {
    using Ptr = std::unique_ptr<int>;

    // static_assert(BadConcept<Ptr>);  // 불필요한 요구로 거짓일 수 있음
    static_assert(GoodConcept<Ptr>);    // 참일 가능성이 훨씬 큼
}

예제는 일부러 경계에 맞춘 것입니다: 실제로는 concept을 연산용으로 작성했는데 복사 가능성을 요구하게 되어 std::unique_ptr, std::mutex 같은 "비복사 가능하지만 정상적인" 타입들에 대해 컴파일러가 불만을 표시하는 식으로 나타납니다.

교훈은 간단합니다: 연산을 검사할 때는 대체로 requires(const T& x) 또는 requires(T& x)로 시작하는 것이 안전하다는 점입니다.

표준 문서에서도 requires-expression이 언어 문법의 일부로 별도 표기된다는 점을 언급합니다(단순한 문법적 설탕이 아니라 진짜 언어 구성임). 따라서 어떤 파라미터를 선언했는지는 검사 의미를 실제로 바꿉니다.

7. concept은 인터페이스의 계약이지 “값 검사”가 아니다

특히 if나 while에 익숙하면 requires가 어떤 “조건”처럼 보이기 쉽습니다. 그러나 이는 값에 대한 조건이 아니라 코드의 올바름에 대한 조건입니다. 즉 HasSize는 size()가 “좋은 수”를 반환하는지, 또는 컨테이너가 비어있지 않은지를 검사하지 않습니다. 단지 x.size()라는 표현식을 쓸 수 있는지 확인할 뿐입니다.

이것은 올바른 설계 습관을 길러줍니다: concept은 함수에 필요한 연산의 정확한 집합만 기술해야 합니다. 불필요한 것을 요구하면 재사용성이 떨어지고, 너무 적게 요구하면 구현 내부에서 오류가 발생해 다시 과거의 문제로 돌아갑니다.

8. 흔한 실수

실수 #1: requires-expression과 requires-clause를 혼동하는 것.
같은 단어처럼 보이지만 의미가 다릅니다. concept 안에서는 보통 requires(...) { ... }을 쓰고, 선언부에서는 ... requires HasSize<T>처럼 쓰는 것을 기억하세요. 혼동하면 이상한 구문을 쓰고 컴파일 오류에 당황하게 됩니다.

실수 #2: requires(T x)를 써서 의도치 않게 복사/생성 가능성을 요구하는 것.
초기에는 단순히 변수를 선언하는 것뿐이라 생각하지만, concept에서는 그것이 검사 일부가 되어 버립니다. 그 결과 std::unique_ptr 같은 타입이 불합격 처리될 수 있습니다. 거의 항상 requires(const T& x)로 시작해 필요한 표현식만 검사하세요.

실수 #3: 모든 경우에 맞는 거대한 concept을 만드는 것.
제약의 힘을 처음 느끼면 UniversalType 같은 것을 만들고 싶어질 수 있습니다: UniversalType = requires(...) { ... 20가지 요구 ... };. 그러나 나중에 여러 곳에서 쓰면 너무 엄격해서 많은 타입이 통과하지 못합니다. 해결책은 단일 책임 원칙처럼, 한 가지 의미당 작은 concept 하나를 만드는 것입니다.

실수 #4: concept이 값 검사를 해줄 것이라고 기대하는 것.
concept은 컨테이너가 비어있지 않거나 숫자가 양수인지 같은 값을 검사하지 않습니다. 그런 값 검사는 런타임의 if와 직접적인 데이터 검사를 통해 해야 합니다.

실수 #5: concept 정의 뒤에 세미콜론을 잊는 것.
concept 정의는 언어 수준의 선언이며 끝에 ;가 필요합니다. 표준 문서에서도 한때 문법에서 세미콜론 누락을 따로 지적하고 고친 적이 있으니, 만약 컴파일러가 expected ';' 같은 메시지를 띄운다면 혼자가 아니라는 것을 아세요.

1
과제
C++ SELF, 레벨 57, 레슨 3
잠금
인벤토리 길이
인벤토리 길이
1
과제
C++ SELF, 레벨 57, 레슨 3
잠금
안전한 덧셈
안전한 덧셈
1
과제
C++ SELF, 레벨 57, 레슨 3
잠금
제약 조건을 쓰는 세 가지 표기
제약 조건을 쓰는 세 가지 표기
1
과제
C++ SELF, 레벨 57, 레슨 3
잠금
객체 설명
객체 설명
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION