Devin.KR

C++ · 심화

RAII·템플릿·동시성 설계

concepts 로 템플릿 제약 걸기 - 읽히는 오류 메시지

requires 절과 concept 정의, 표준 concept(std::integral, std::ranges::range), SFINAE 와 비교, 제약으로 오버로드 나누기

개발자KR · 원고 갱신

이 장에서 배우는 것

concepts 로 템플릿 제약 걸기 - 읽히는 오류 메시지

앞 장에서 템플릿 인자 추론이 타입을 정하고, 오버로드 해석이 호출할 함수를 고르는 과정을 살펴보았다. 이번에는 그 과정에 “이 함수가 받아들일 수 있는 타입”이라는 조건을 추가한다. 주문 처리 함수가 정수 수량을 요구하고 주문 목록을 순회한다면, 그 요구를 함수 본문에만 남겨 두지 않고 선언에서 드러낼 수 있다.

제약(constraint)은 잘못된 호출을 함수 본문에 들어가기 전에 걸러 내는 조건이다. 콘셉트(concept)는 이런 조건에 붙인 이름이다. 콘셉트가 있다고 모든 오류 메시지가 짧아지는 것은 아니다. 다만 실패의 출발점을 템플릿 내부 구현에서 인터페이스의 요구 조건으로 옮길 수 있다. 이름을 잘 고르면 호출자는 어떤 능력이 부족한지도 더 쉽게 파악한다.

  • requires 절과 요구 표현식을 구분하고, 재사용할 조건을 콘셉트로 정의한다.
  • std::integral과 std::ranges::range를 주문 수량과 주문 묶음의 조건으로 사용한다.
  • SFINAE로 작성한 선언과 콘셉트를 사용한 선언의 차이를 설명한다.
  • 서로 포함 관계가 있는 제약으로 오버로드를 나눈다.
  • 타입에 대한 조건과 실행 중 데이터 검증의 경계를 구분한다.

문제 상황

작은 주문 처리 엔진이 주문 하나씩 받던 구조에서 주문 묶음을 받는 구조로 바뀌었다고 하자. 접수 모듈은 주문을 vector에 모으고, 재처리 모듈은 forward_list에 모은다. 두 컨테이너를 처리하는 함수의 본문은 거의 같다. 주문을 순회하고 수량을 검증한 뒤, 통과한 주문의 체결 로그를 남긴다.

문제는 템플릿으로 묶은 다음에 나타난다. 어떤 호출자는 주문 목록 대신 정수 목록을 전달한다. 다른 호출자는 write 함수의 인자를 다르게 선언한 로그 객체를 전달한다. 아무 조건도 없는 템플릿이라면 이런 호출도 우선 후보가 되고, 선택된 함수 본문을 인스턴스화하다가 멤버 접근이나 함수 호출에서 실패한다.

template<class R, class S>
void process_unchecked(const R& orders, S& sink)
{
    for (const auto& order : orders) {
        sink.write(order);
    }
}

이 선언만 읽어서는 R이 상수 객체로 순회 가능해야 한다는 사실도, S가 주문을 인자로 받는 write를 제공해야 한다는 사실도 알기 어렵다. 구현을 열어 보아야 계약을 추측할 수 있다. 함수 본문이 다른 템플릿을 여러 번 호출하면 오류가 표시되는 위치도 실제 호출 지점에서 멀어지기 쉽다.

이번 예제에서는 계약을 세 부분으로 나눈다. 수량은 bool을 제외한 정수 타입이어야 한다. 주문 묶음은 상수 참조로 순회할 수 있어야 하고, 순회로 얻는 원소는 const Order&여야 한다. 로그 객체는 const Order&를 받으며 void를 반환하는 write 호출을 지원해야 한다. 이 조건을 만족한 뒤에야 각 주문의 수량 값을 검증한다.

requires 절로 계약을 선언한다

조건을 붙이는 자리와 이름을 붙이는 자리

requires 절(requires clause)은 템플릿 선언에 조건을 붙인다. 다음 함수는 추론된 Q가 표준 정수 콘셉트를 만족할 때만 호출 후보로 남는다. std::integral은 정수 타입을 판별하며, 부동소수점 타입과 문자열 타입은 받아들이지 않는다. 이 예제의 requires 뒤에 오는 것은 실행 중 분기가 아니라 템플릿 제약식이다.

template<class Q>
requires std::integral<Q>
bool positive_quantity(Q quantity)
{
    return quantity > 0;
}

같은 선언을 template<std::integral Q>로 짧게 쓸 수도 있다. 짧은 표기는 Q의 타입이 std::integral이어야 한다는 뜻이 아니다. std::integral은 타입 이름이 아니라 Q가 만족해야 할 조건의 이름이다. 함수 선언의 끝, 본문 앞에 requires 절을 배치하는 형식도 있다. 이 장에서는 조건의 재사용 관계가 잘 보이도록 이름 있는 콘셉트와 템플릿 매개변수의 짧은 표기를 함께 사용한다.

std::integral에는 bool과 문자 타입도 포함된다. 따라서 “정수”라는 언어의 분류와 “주문 수량으로 허용한다”라는 업무 규칙을 같은 것으로 취급해서는 안 된다. 이번 엔진은 bool만 제외하고 나머지 정수 타입을 수량 계산에 허용한다. 문자 타입까지 제외하려면 별도 조건을 추가해야 한다.

template<class Q>
concept Quantity =
    std::integral<Q> &&
    (!std::same_as<std::remove_cv_t<Q>, bool>);

template<Quantity Q>
bool valid_quantity(Q quantity)
{
    return quantity > 0 && quantity <= 1000;
}

concept 선언은 조건에 이름을 붙인다. Quantity<Q>는 Q에 대한 조건의 만족 여부를 나타낸다. remove_cv_t는 const와 volatile 한정을 제거하므로, bool에 한정자가 붙었다는 이유로 제외 조건을 빠져나가지 않는다. 다만 참조는 제거하지 않는다. std::integral<int&>도 거짓이므로 Quantity<int&>는 만족되지 않는다. 여기서는 함수가 값을 받으므로 일반적인 호출의 Q는 참조 타입으로 추론되지 않는다.

Quantity는 양수 여부를 검사하지 않는다. int 타입의 -3도 타입 조건은 만족한다. 1부터 1000까지라는 허용 범위는 함수 본문의 실행 중 검증이 담당한다. 타입에 대한 조건을 붙였다고 값에 대한 검증을 지우면 안 된다. 반대로 실행 중 전달되는 수량을 requires 절에서 검사하려고 해도, 그 값은 템플릿 후보를 결정하는 조건으로 사용할 수 없다.

표현식이 가능한지 검사한다

요구 표현식(requires-expression)은 특정 타입으로 어떤 표현식을 만들 수 있는지 검사한다. requires 뒤의 괄호에는 검사에 사용할 이름을 선언하고, 중괄호 안에는 필요한 연산을 나열한다. 이 이름을 선언한다고 실제 객체가 생성되지는 않는다. 중괄호 안에 함수 호출을 적어도 그 호출이 실행되지는 않는다.

template<class S>
concept ExecutionSink = requires(S& sink, const Order& order) {
    { sink.write(order) } -> std::same_as<void>;
};

중괄호로 감싼 호출과 화살표를 함께 쓰면 호출 가능 여부뿐 아니라 결과 타입에도 조건을 붙인다. 위 요구는 sink.write(order)가 유효하고, 그 결과 타입이 void여야 한다는 뜻이다. 검사 대상은 해당 표현식의 decltype((표현식))으로 얻는 타입이다. 여기서는 반환값이 없으므로 참조 여부를 구분할 일이 없지만, 참조를 반환하는 연산을 검사할 때는 이 차이가 중요하다.

sink.write(order);만 적으면 호출이 가능한지만 검사한다. write가 int를 반환해도 그 요구는 만족한다. 반면 위처럼 std::same_as<void>를 붙이면 int를 반환하는 write는 거부한다. 반환값을 무시해도 되는 인터페이스인지, 반환 타입까지 고정할 인터페이스인지 먼저 결정해야 한다.

이 콘셉트는 write가 특정 형태의 멤버 함수로 선언되었는지까지 고정하지 않는다. 호출을 받아 주는 멤버 함수 템플릿도 조건을 만족할 수 있다. 또한 디스크 기록의 성공, 로그 순서 보장, 예외 발생 여부는 이 조건에 들어 있지 않다. 콘셉트의 이름은 의미를 전달하지만, 컴파일러가 검사하는 범위는 실제로 적은 요구까지다.

requires가 쓰이는 위치에 따라 검사 대상이 달라진다
형식역할읽는 방법
requires Quantity<Q>선언의 후보 조건Q가 수량 타입 조건을 만족해야 한다
requires(S& s) { s.flush(); }표현식의 유효성 검사s.flush()를 작성할 수 있어야 한다
{ s.flush() } -> std::same_as<void>;호출과 결과 타입 검사호출 결과가 void여야 한다
requires std::integral<Q>;요구 표현식 안의 중첩 요구정수 타입 조건 자체가 참이어야 한다
템플릿 제약은 타입의 계약을 검사하고 함수 본문은 주문 수량의 값을 검사한다

표준 콘셉트에서 주문 엔진의 계약으로

순회 가능하다는 조건에 필요한 조건을 더한다

std::ranges::range는 범위에 대한 표준 콘셉트다. 해당 타입의 객체에 대해 std::ranges::begin과 std::ranges::end를 사용할 수 있는지를 바탕으로 순회 가능한 범위인지 판별한다. 이름에 ranges가 들어 있어도 지연 처리 파이프라인을 만들 필요는 없다. 여기서는 컨테이너를 받아들일 조건으로만 사용한다.

range라는 사실만으로 원소가 주문이라는 점이나 원소 수를 바로 얻을 수 있다는 점까지 보장되지는 않는다. 또한 R을 순회할 수 있다는 사실과 const R을 순회할 수 있다는 사실은 별개다. 처리 함수가 const R&를 받는다면 검사도 const R을 대상으로 해야 한다. 선언의 조건과 본문이 실제 사용하는 객체 형태를 맞추는 것이 핵심이다.

template<class R>
concept OrderRange =
    std::ranges::range<const R> &&
    std::same_as<
        std::ranges::range_reference_t<const R>,
        const Order&>;

template<class R>
concept SizedOrderRange =
    OrderRange<R> && std::ranges::sized_range<const R>;

range_reference_t는 범위의 반복자를 역참조했을 때 얻는 타입을 나타낸다. OrderRange는 그 타입이 정확히 const Order&이기를 요구한다. 따라서 상수 vector<Order>와 상수 forward_list<Order>는 받아들이지만, 정수 목록이나 Order 값을 매번 새로 만들어 반환하는 범위는 받아들이지 않는다. 이는 모든 주문 공급원을 수용하려는 조건이 아니라, 기존 주문 객체를 읽는 이번 함수의 범위를 명확히 정한 조건이다.

앞의 range 조건이 만족되지 않으면 뒤의 조건을 검사할 필요가 없다. 제약식의 &&는 이런 순서를 보장하므로, 범위가 아닌 타입에 range_reference_t를 무리하게 적용하는 일을 피한다. 의존적인 타입 별칭을 사용하는 조건은 그 별칭을 사용할 수 있게 하는 조건 뒤에 배치하는 편이 읽기도 쉽다.

SizedOrderRange는 주문 범위의 조건을 유지하면서 원소 수를 얻는 표준 조건을 더한다. std::ranges::sized_range는 std::ranges::size를 사용할 수 있어야 한다. 표준이 요구하는 의미까지 만족하는 범위라면, 그 크기는 원소 수와 일치하고 상환 상수 시간에 얻을 수 있다. 사용자가 작성한 타입이 이런 의미를 지키는지까지 컴파일러가 증명해 주지는 않는다.

조건을 공유해야 더 구체적인 오버로드가 된다

엔진은 크기를 알 수 있는 주문 묶음에는 개수를 먼저 출력하고, 그 밖의 주문 묶음에는 개수 없이 처리를 시작한다. 이를 하나의 함수 안에서 나눌 수도 있지만, 여기서는 제약에 따른 오버로드 선택을 확인하기 위해 두 선언으로 나눈다.

template<OrderRange R, ExecutionSink S>
std::size_t process_orders(const R& orders, S& sink);

template<SizedOrderRange R, ExecutionSink S>
std::size_t process_orders(const R& orders, S& sink);

vector<Order>를 전달하면 두 선언 모두 후보 조건을 만족한다. 이때 두 선언의 매개변수 형태와 변환 조건이 같고, SizedOrderRange가 OrderRange를 포함하므로 더 제약된 두 번째 선언이 선택된다. forward_list<Order>는 원소 수를 바로 제공하지 않으므로 첫 번째 선언만 조건을 만족한다. 첫 번째 선언은 “크기를 알 수 없는 범위만 허용한다”는 뜻이 아니라, 크기를 요구하지 않는 일반적인 후보라는 뜻이다.

이 관계를 제약의 포섭(subsumption)이라고 한다. 컴파일러는 조건의 문장 뜻을 해석하거나 임의의 논리식을 증명하지 않는다. 제약을 정규화하여 구성 요소의 동일성과 조합 관계를 비교한다. 더 구체적인 콘셉트가 기존 콘셉트를 이름으로 재사용하면 이런 관계를 명확하게 만들 수 있다.

두 조건이 서로 다른 능력만 요구한다면 어느 쪽도 더 구체적이지 않을 수 있다. 예를 들어 한 선언은 로그 출력 능력을 요구하고 다른 선언은 네트워크 전송 능력을 요구한다면, 두 능력을 모두 가진 타입에 대해 호출이 모호해질 수 있다. 콘셉트를 많이 붙이는 것보다 후보들의 관계를 먼저 설계하는 일이 중요하다.

두 후보가 모두 가능한 주문 범위에는 공유한 조건을 더 강화한 오버로드가 선택된다

SFINAE와 비교해서 읽는다

SFINAE는 “치환 실패는 오류가 아니다”라는 규칙을 가리킨다. 템플릿 인자를 대입하는 과정에서 선언의 직접적인 문맥에 특정 종류의 부적합한 타입이나 표현식이 생기면, 프로그램 전체를 즉시 오류로 만드는 대신 해당 후보를 제외한다. 함수 본문에서 생기는 모든 오류를 조용히 제거하는 규칙은 아니다.

콘셉트를 사용하기 전에는 std::enable_if_t를 반환 타입이나 추가 템플릿 매개변수에 넣어 후보를 제한하는 방식이 자주 쓰였다. 다음 선언은 Quantity와 같은 타입 조건을 반환 타입에 넣은 예다. 조건이 참이면 반환 타입이 bool이 되고, 거짓이면 반환 타입을 만들 수 없어 후보에서 제외된다.

template<class Q>
std::enable_if_t<
    std::is_integral_v<Q> &&
    !std::is_same_v<std::remove_cv_t<Q>, bool>,
    bool>
valid_quantity_sfinae(Q quantity)
{
    return quantity > 0 && quantity <= 1000;
}

이 선언을 읽으려면 반환 타입을 계산하는 코드와 후보를 제한하는 코드를 동시에 해석해야 한다. 콘셉트 버전에서는 반환 타입은 bool로 남고, Quantity라는 이름이 입력 계약을 설명한다. 조건을 다른 함수에서 재사용하거나 오버로드 사이의 포함 관계를 표현할 때도 의도가 더 잘 드러난다.

콘셉트는 SFINAE의 다른 철자만은 아니다. 제약 만족 여부를 판정하는 절차와 제약을 이용한 오버로드 순서가 언어에 들어 있다. 기존 라이브러리나 이전 언어 버전을 지원하는 코드에서는 SFINAE를 계속 만날 수 있지만, C++20으로 새 인터페이스를 설계한다면 먼저 제약으로 표현할 수 있는지 살펴볼 만하다.

오류 메시지를 읽을 때는 가장 아래쪽의 긴 타입 목록부터 보지 않는다. 호출할 함수가 없다는 보고 뒤에서, 어느 후보의 어떤 제약이 만족되지 않았는지를 찾는다. Quantity가 거짓인지, OrderRange의 원소 참조 타입이 다른지, ExecutionSink의 호출이 유효하지 않은지를 좁힌다. 구체적인 진단 문구와 표시 깊이는 컴파일러와 버전에 따라 달라진다.

완성 코드

다음 프로그램을 main.cpp로 저장한다. 모든 주문은 이미 접수되어 두 컨테이너에 들어 있다고 가정한다. 수량 검증을 통과하면 체결 로그를 출력한다. 실제 가격 매칭과 저장 장치는 생략하고, 타입 계약과 후보 선택에 필요한 부분만 둔다. 주석의 번호는 이어지는 해설과 대응한다.

#include <concepts>
#include <cstddef>
#include <forward_list>
#include <iostream>
#include <ranges>
#include <type_traits>
#include <vector>

// [1]
struct Order {
    int id;
    int quantity;
};

// [2]
template<class Q>
concept Quantity =
    std::integral<Q> &&
    (!std::same_as<std::remove_cv_t<Q>, bool>);

template<Quantity Q>
bool valid_quantity(Q quantity)
{
    return quantity > 0 && quantity <= 1000;
}

// [3]
template<class R>
concept OrderRange =
    std::ranges::range<const R> &&
    std::same_as<
        std::ranges::range_reference_t<const R>,
        const Order&>;

template<class R>
concept SizedOrderRange =
    OrderRange<R> && std::ranges::sized_range<const R>;

// [4]
template<class S>
concept ExecutionSink = requires(S& sink, const Order& order) {
    { sink.write(order) } -> std::same_as<void>;
};

// [5]
struct ConsoleLog {
    void write(const Order& order)
    {
        std::cout << "fill id=" << order.id
                  << " qty=" << order.quantity << '\n';
    }
};

// [6]
template<OrderRange R, ExecutionSink S>
std::size_t run_orders(const R& orders, S& sink)
{
    std::size_t filled = 0;

    for (const Order& order : orders) {
        if (!valid_quantity(order.quantity)) {
            std::cout << "reject id=" << order.id
                      << " qty=" << order.quantity << '\n';
            continue;
        }

        sink.write(order);
        ++filled;
    }

    return filled;
}

// [7]
template<OrderRange R, ExecutionSink S>
std::size_t process_orders(const R& orders, S& sink)
{
    std::cout << "batch unsized\n";
    return run_orders(orders, sink);
}

// [8]
template<SizedOrderRange R, ExecutionSink S>
std::size_t process_orders(const R& orders, S& sink)
{
    std::cout << "batch sized count="
              << std::ranges::size(orders) << '\n';
    return run_orders(orders, sink);
}

// [9]
int main()
{
    const std::vector<Order> incoming{
        {101, 4},
        {102, 0},
        {103, 7}
    };

    const std::forward_list<Order> retry{
        {201, -2},
        {202, 3}
    };

    ConsoleLog log;
    const auto first = process_orders(incoming, log);
    const auto second = process_orders(retry, log);

    std::cout << "total filled=" << first + second << '\n';
}

줄별 해설

[1]과 [2]: 주문의 표현과 수량의 계약

Order는 식별자와 수량을 int로 저장한다. Quantity는 저장 구조를 바꾸지 않고 수량 검증 함수가 받을 타입을 제한한다. valid_quantity의 호출에서는 order.quantity로부터 Q가 int로 추론된다. 따라서 타입 조건은 만족하지만 수량이 0이거나 음수라면 함수의 반환값은 false다.

수량 비교에는 0과 1000이라는 표현 가능한 경계를 사용한다. 이 함수는 정수 수량을 다른 정수 타입으로 먼저 변환하지 않으며 원래 타입의 값으로 비교한다. bool 호출을 제외한 것은 true가 수량 1로 취급되는 사용 실수를 인터페이스에서 드러내기 위해서다. 이 선택은 언어의 정수 분류가 아니라 엔진의 정책이다.

[3]: 함수가 실제로 사용할 범위를 검사한다

OrderRange의 첫 줄은 상수 범위의 순회 가능 여부를 검사하고, 다음 조건은 역참조 결과를 검사한다. 값 타입만 Order인지 검사하는 것과 다르다. 이 함수는 기존 원소에 대한 상수 참조를 읽도록 설계했으므로 참조 타입 자체를 계약으로 삼는다. 임시 값을 반환하거나 대리 참조를 사용하는 범위를 지원하려면 계약과 본문의 사용 방식을 함께 다시 검토해야 한다.

SizedOrderRange는 OrderRange를 이름으로 포함한다. 원소 조건을 다시 풀어 쓰지 않는 덕분에 기본 계약의 변경이 한곳에 모이고, 두 오버로드가 공유하는 제약도 드러난다. 크기를 얻는 조건은 함수가 받는 const R에 대해 검사한다.

[4]와 [5]: 로그 객체를 호출 형태로 연결한다

ExecutionSink는 상속 관계나 기반 클래스 없이 로그 객체를 검사한다. ConsoleLog는 따로 등록하지 않아도 요구된 호출을 지원하므로 조건을 만족한다. sink를 S&로 검사하고 실제 함수에서도 S&로 받는 이유는 로그 객체가 내부 카운터나 출력 상태를 변경할 수 있도록 하기 위해서다.

write의 인자는 const Order&이므로 로그를 남기기 위해 주문을 복사하지 않는다. 콘셉트는 주문 참조를 보관해도 되는지까지 보장하지 않는다. 이 구현은 호출 중에 필드를 출력하고 참조를 저장하지 않는다. 나중에 로그 객체가 주문을 보관하도록 바뀐다면 기본서에서 다룬 참조 수명 문제를 다시 점검해야 한다.

[6]: 타입 조건이 성립한 뒤 값을 검사한다

run_orders는 공통 처리 본문이다. 범위와 로그 객체의 조건을 만족한 호출만 이 본문을 사용한다. 각 주문의 수량이 허용 범위를 벗어나면 거절 로그를 출력하고 다음 주문으로 이동한다. 검증을 통과한 주문만 write에 전달하고 filled를 증가시킨다.

filled는 주문 수를 세므로 std::size_t를 사용한다. 여기서 반환하는 수는 검증을 통과하여 write 호출이 정상적으로 돌아온 주문 수다. 출력 장치의 영속 저장 성공을 뜻하지 않는다. 또한 write가 예외를 던지면 이 함수는 끝까지 진행하지 않는다. ExecutionSink에 예외 금지 조건을 적지 않았으므로 제약만으로 그런 동작을 제한할 수는 없다.

[7]과 [8]: 더 많은 조건을 만족하면 별도 후보를 고른다

일반 후보는 크기를 조회하지 않고 처리한다. 더 제약된 후보는 std::ranges::size를 출력한 뒤 같은 공통 함수를 호출한다. 크기 조회가 가능한지 검사한 대상과 실제 조회 대상이 모두 상수 범위이므로 조건과 사용이 일치한다.

이 선택을 위해 실행 중에 컨테이너 종류를 검사하지 않는다. 호출 시 추론된 타입과 제약 관계로 함수가 결정된다. 그렇다고 함수 본문의 출력과 순회까지 컴파일 중에 실행되는 것은 아니다. 후보 선택 시점과 주문 처리 시점을 구분해야 한다.

[9]: 두 입력으로 두 경로를 확인한다

incoming은 크기를 제공하므로 더 제약된 후보가 선택된다. retry는 순회할 수 있지만 크기를 바로 제공하지 않으므로 일반 후보가 선택된다. forward_list의 원소 수를 미리 세려고 순회를 한 번 더 하지 않는다. 두 컨테이너 모두 작성된 순서대로 원소를 순회하므로 로그 순서도 일정하다.

첫 묶음에서는 수량 0인 주문 하나를 거절하고 두 건을 기록한다. 두 번째 묶음에서는 음수 수량인 주문 하나를 거절하고 한 건을 기록한다. 두 호출을 별도 문장에 두었으므로 첫 묶음의 출력이 끝난 다음 두 번째 묶음의 출력이 시작된다.

실행 결과

C++20을 지원하는 컴파일러와 표준 라이브러리를 사용한다. macOS와 Linux에서 다음 명령으로 빌드하고 실행할 수 있다. 예제는 스레드를 만들지 않지만 책의 공통 빌드 옵션인 -pthread를 포함한다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o orders
./orders

예상 표준 출력은 다음과 같다.

batch sized count=3
fill id=101 qty=4
reject id=102 qty=0
fill id=103 qty=7
batch unsized
reject id=201 qty=-2
fill id=202 qty=3
total filled=3

제약 실패를 확인하려면 완성 코드의 main에 다음 호출을 하나씩 추가한다. 이 코드는 오류 관찰용이므로 정상 실행 예제에는 넣지 않는다. 첫 호출은 Quantity를 만족하지 않고, 두 번째 호출은 OrderRange가 요구하는 원소 참조 타입을 만족하지 않는다.

valid_quantity(2.5);

const std::vector<int> numbers{1, 2, 3};
process_orders(numbers, log);

오류 메시지에서 제약을 만족하지 못했다는 보고와 해당 조건을 찾아본다. 함수 본문 안의 order.quantity 같은 접근이 아니라, 함수의 입력 계약에서 거절 이유를 찾는 연습이다. 표시 형식이 달라도 확인할 조건의 이름은 동일하다.

실무에서 자주 틀리는 것

조건식이 유효하다는 사실을 참이라는 뜻으로 읽는다

다음 잘못된 콘셉트는 std::integral<T>의 결과가 참인지 검사하지 않는다. bool 값을 나타내는 그 표현식이 유효한지만 검사한다. 따라서 double에도 조건이 만족된다. 요구 표현식의 단순 요구는 표현식을 작성할 수 있는지를 묻는다는 점을 기억해야 한다.

// 잘못된 코드
template<class T>
concept IntegerLike = requires {
    std::integral<T>;
};

값으로 쓰인 조건을 검사하려면 콘셉트 정의에 직접 연결하면 된다. 다른 요구와 함께 중괄호 안에 넣어야 한다면 requires를 한 번 더 사용하는 중첩 요구로 작성한다.

// 고친 코드
template<class T>
concept IntegerLike = std::integral<T>;

// 중첩 요구로 표현한 같은 조건
template<class T>
concept IntegerLikeNested = requires {
    requires std::integral<T>;
};

std::integral이면 주문 수량 정책도 충족한다고 생각한다

다음 함수에는 bool도 전달할 수 있다. true를 넘기면 검증 결과가 참이 된다. 수량을 잘못된 타입으로 전달한 실수를 일반적인 수량 1처럼 받아들이는 셈이다. 정수 타입 조건과 업무용 타입 조건을 구분해야 한다.

// 잘못된 코드: bool을 허용하지 않으려는 정책과 다르다.
template<std::integral Q>
bool accepts_quantity(Q quantity)
{
    return quantity > 0 && quantity <= 1000;
}

완성 코드의 Quantity를 재사용하면 bool은 후보 조건에서 제외된다. 허용 범위를 검사하는 본문은 그대로 필요하다. 콘셉트가 있다고 음수와 초과 수량까지 자동으로 걸러지는 것은 아니다.

// 고친 코드
template<Quantity Q>
bool accepts_quantity(Q quantity)
{
    return quantity > 0 && quantity <= 1000;
}

변경 가능한 객체를 검사하고 상수 객체를 사용한다

다음 선언은 R을 검사하지만 본문에서는 const R을 순회한다. 비상수 begin과 end만 제공하는 타입은 제약을 통과하고도 본문에서 실패할 수 있다. 제약을 붙였다는 사실만으로 선언과 구현의 불일치가 사라지지는 않는다.

// 잘못된 코드
template<std::ranges::range R>
void inspect(const R& orders)
{
    for (const auto& order : orders) {
        (void)order;
    }
}

함수가 받는 객체의 한정자를 검사 대상에 반영한다. 아래 코드는 순회만 확인하는 함수의 수정이다. 주문 필드를 사용하려면 여기에 원소 계약도 필요하며, 완성 코드에서는 OrderRange가 그 역할을 맡는다.

// 고친 코드
template<class R>
requires std::ranges::range<const R>
void inspect(const R& orders)
{
    for (const auto& order : orders) {
        (void)order;
    }
}

같은 조건을 다시 쓰면 같은 제약이라고 생각한다

다음 두 선언에서 sizeof(T) > 1은 글자가 같지만 서로 다른 위치에 작성된 별개의 원자 제약이다. 두 번째 조건이 첫 번째 조건을 논리적으로 포함한다고 사람이 판단해도, 그것만으로 포섭 관계가 생기지는 않는다. macOS와 Linux의 일반적인 int에 route_quantity(7)을 호출하면 두 후보 사이에서 모호성이 발생한다.

// 잘못된 코드
template<class T>
requires (sizeof(T) > 1)
void route_quantity(T) {}

template<class T>
requires (sizeof(T) > 1 && std::integral<T>)
void route_quantity(T) {}

공유할 조건에 이름을 붙여 두 선언에서 재사용한다. 이제 두 번째 선언은 동일한 WideQuantity 조건에 정수 타입 조건을 추가한다. 매개변수 형태가 같은 두 후보 사이에서 더 제약된 선언을 선택할 수 있다. 이 예의 크기 조건은 제약 관계를 설명하기 위한 것이며, 실제 주문 수량의 허용 범위 정책을 대신하지 않는다.

// 고친 코드
template<class T>
concept WideQuantity = (sizeof(T) > 1);

template<class T>
requires WideQuantity<T>
void route_quantity(T) {}

template<class T>
requires (WideQuantity<T> && std::integral<T>)
void route_quantity(T) {}

한눈에 보기

타입 계약과 실행 중 검증의 책임을 나누어 읽는다
도구확인하는 것예제의 역할별도로 필요한 것
requires 절후보의 제약 만족 여부허용할 호출 제한본문의 정확성
concept 정의이름 붙인 조건Quantity 등 계약 공유이름에 맞는 요구 설계
요구 표현식표현식과 결과 타입write 호출 검사로그 성공과 수명 관리
std::integral정수 타입 여부수량 타입의 기본 조건bool 제외와 값 검증
std::ranges::range범위 접근 가능 여부주문 목록 순회 조건원소와 상수 접근 조건
제약 포섭제약 사이의 구조적 관계크기 제공 후보 선택공유 콘셉트의 재사용
SFINAE치환 중 특정 선언의 유효성기존 후보 제한 방식 이해실패가 발생한 문맥 확인

좋은 제약은 함수 본문이 사용할 연산을 설명한다. 필요한 것보다 약하면 오류가 본문으로 밀려나고, 필요 이상으로 강하면 유효한 타입까지 제외한다. 이번 OrderRange처럼 지원 범위를 의도적으로 좁혔다면 그 이유를 문서에 남긴다. 선언만 읽은 사람도 어떤 주문 공급원을 연결할 수 있는지 판단할 수 있어야 한다.

제약은 컴파일 과정에서 판정되지만 일반적인 데이터 처리는 여전히 실행 중에 이루어진다. 다음 장에서는 컴파일 타임 계산을 다루며, 이번 장의 타입 조건 판정과 실제 계산을 컴파일 중에 수행하는 일의 차이를 이어서 살펴본다.

연습 문제

  1. 완성 코드의 Quantity와 valid_quantity를 기준으로 valid_quantity(5u), valid_quantity(false), valid_quantity(-2), valid_quantity(1001)의 결과를 구분하라. 컴파일할 수 있는 호출은 반환값도 적어라.
  2. void가 아닌 int를 반환하는 write를 가진 로그 객체를 작성하라. 현재 ExecutionSink가 이를 받아들이는지 설명하고, 반환 타입에 관계없이 호출만 가능하면 받아들이도록 콘셉트를 수정하라.
  3. vector<Order>, forward_list<Order>, vector<int>를 각각 process_orders에 전달할 때 선택되는 후보를 설명하라. 로그 객체는 ConsoleLog라고 가정한다.
  4. ExecutionSink를 만족하면서 flush()가 void를 반환하는 로그 객체를 나타내는 FlushableExecutionSink를 정의하라. 두 콘셉트로 finish_log를 오버로드하여, 추가 조건을 만족할 때만 flush를 호출하게 하라.

정답과 해설

  1. 5u는 unsigned int이므로 타입 조건을 만족하고 true를 반환한다. false는 bool이므로 Quantity를 만족하지 않아 호출할 수 없다. -2와 1001은 모두 int이므로 컴파일할 수 있지만, 허용 범위 밖이어서 false를 반환한다. 타입 조건에서 거절된 호출에는 실행 중 반환값이 없다.

  2. 다음 StatusLog의 호출 결과는 int이므로 현재 ExecutionSink를 만족하지 않는다. 반환 타입을 제한하지 않으려면 복합 요구를 단순 요구로 바꾼다. 반환값에 오류 정보가 담겨 있다면 이를 무시해도 되는지 별도의 설계 판단이 필요하다.

    struct StatusLog {
        int write(const Order&)
        {
            return 0;
        }
    };
    
    template<class S>
    concept CallableExecutionSink =
        requires(S& sink, const Order& order) {
            sink.write(order);
        };
  3. vector<Order>는 OrderRange와 SizedOrderRange를 모두 만족하므로 크기를 출력하는 후보가 선택된다. forward_list<Order>는 OrderRange만 만족하므로 일반 후보가 선택된다. vector<int>는 순회와 크기 조회가 가능해도 원소 참조 타입이 const Order&가 아니므로 두 후보 모두 사용할 수 없다. 크기를 제공한다는 사실이 원소 계약의 실패를 보완하지는 않는다.

  4. 강화한 콘셉트가 ExecutionSink를 이름으로 재사용하게 한다. 일반 후보는 아무 작업도 하지 않고, 더 제약된 후보만 flush를 호출한다. 이름을 생략한 매개변수는 의도적으로 사용하지 않는다는 뜻이며, 지정된 경고 옵션에서도 불필요한 매개변수 경고를 피할 수 있다.

    template<class S>
    concept FlushableExecutionSink =
        ExecutionSink<S> &&
        requires(S& sink) {
            { sink.flush() } -> std::same_as<void>;
        };
    
    template<ExecutionSink S>
    void finish_log(S&)
    {
    }
    
    template<FlushableExecutionSink S>
    void finish_log(S& sink)
    {
        sink.flush();
    }

    ConsoleLog에는 flush가 없으므로 일반 후보가 선택된다. write와 flush를 모두 요구대로 제공하는 타입에는 두 후보가 가능하지만, 공유 제약을 강화한 후보가 선택된다. flush가 실제로 데이터를 저장 장치에 반영하는지는 함수 구현과 문서가 보장해야 한다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.