Devin.KR

컴파일 타임 계산 - constexpr·consteval·static_assert

개발자KR 조회 10

이 장에서 배우는 것

주문 처리 엔진에는 실행 중에 바뀌는 값과 빌드할 때 이미 정해진 값이 함께 존재한다. 주문 번호와 수량은 접수할 때 알지만, 수량 상한이나 프로그램에 내장하는 수수료 정책은 소스 코드에 고정할 수 있다. 두 종류의 값을 구분하면 고정된 규칙을 미리 계산하고, 규칙 사이의 모순을 프로그램 실행 전에 발견할 수 있다.

앞 장에서 템플릿이 받아들일 타입의 조건을 표현했다면, 이번에는 계산에 필요한 값과 조건을 언제 확인하는지 살펴본다. 중심 질문은 “이 계산을 미리 할 수 있는가”와 “이 계산을 미리 하도록 요구해야 하는가”다. 두 질문의 답은 같지 않다.

  • constexpr 함수와 변수의 역할을 구분하고 상수 표현식(constant expression)이 필요한 위치를 판단한다.
  • consteval로 정책 생성 함수의 계산 시점을 제한한다.
  • if constexpr로 템플릿 인스턴스마다 필요한 코드만 선택한다.
  • 수량별 수수료율 표를 만들고 실행 중 주문 검증에 사용한다.
  • static_assert로 표의 성질과 정수 계산의 범위를 고정한다.

문제 상황

작은 주문 처리 엔진이 수량에 따라 수수료율을 다르게 적용한다고 하자. 수량이 1부터 4까지면 거래 대금의 25bp, 5부터 8까지면 20bp를 부과한다. 베이시스 포인트(basis point)를 줄인 bp는 1만분의 1을 뜻한다. 여기서는 선택한 구간의 비율을 거래 대금 전체에 적용하고, 계산 결과의 소수 부분은 버린다.

주문을 처리할 때마다 여러 조건문으로 구간을 찾을 수도 있다. 그러나 가능한 수량이 여덟 개뿐이고 정책이 빌드마다 고정된다면, 수량을 인덱스로 쓰는 작은 표가 더 직접적인 표현이다. 표를 손으로 작성하면 경계가 바뀔 때 일부 원소를 고치지 않는 실수가 생긴다. 정책에서 표를 생성하면 경계와 비율을 한곳에서 관리할 수 있다.

이 선택은 정책의 운영 방식에 달려 있다. 운영자가 실행 중에 요율을 바꿔야 하는 서비스라면 외부 설정을 읽고 실행 중에 검증하는 편이 맞다. 이번 엔진은 정책을 바꿀 때 다시 빌드하는 배포 방식을 가정한다. 컴파일 타임 계산은 이러한 전제가 맞을 때 사용하는 설계 도구다.

엔진의 실행 경로는 접수된 주문을 검증하고, 거래 대금과 수수료를 계산한 다음 체결 로그를 남기는 순서다. 매수·매도 주문을 서로 연결하는 로직은 생략한다. 예제의 단가는 이미 결정된 체결 단가이며, 검증을 통과한 주문은 그 단가로 체결된다고 가정한다. 금액은 모두 같은 최소 화폐 단위의 정수로 표현한다.

constexpr는 가능한 계산과 필요한 계산을 구분한다

함수 선언과 변수 선언의 차이

constexpr 함수는 조건을 만족하는 호출을 상수 표현식에서 사용할 수 있는 함수다. 함수에 이 지정자를 붙였다고 모든 호출이 컴파일 타임에 평가되는 것은 아니다. 실행 중 입력을 받아 호출할 수도 있으며, 이때는 일반 함수처럼 동작한다. 함수 내부에 반복문이나 지역 변수가 있다는 이유만으로 상수 평가가 막히지는 않는다.

constexpr unsigned rate_for(unsigned quantity) {
    return quantity <= 4 ? 25U : 20U;
}

constexpr auto fixed_rate = rate_for(3);

unsigned quantity = 0;
std::cin >> quantity;
const auto input_rate = rate_for(quantity);

fixed_rate는 constexpr 변수이므로 초기화에 상수 표현식이 필요하다. 반면 input_rate의 const는 초기화 후 값을 바꾸지 못하게 한다. 입력으로 읽은 수량을 컴파일러가 미리 알아야 한다는 요구는 없다. 변경 가능성과 상수 평가 가능성은 별개의 축이다.

일부 const 정수 변수도 초기화 방식과 사용 위치에 따라 상수 표현식에서 사용할 수 있다. 따라서 “const는 언제나 실행 중 값”이라고 외우면 틀린다. 다만 설계 의도가 컴파일 타임에 정해지는 값이라면 constexpr로 그 요구를 드러내는 편이 분명하다.

상수 평가에는 실행 중 계산보다 엄격한 제한이 적용된다. 예를 들어 이 장의 환경에서 표준 출력으로 문자를 보내는 연산은 상수 평가할 수 없다. 계산이 방문하는 경로에서 허용되지 않는 연산을 수행하면 그 호출은 상수 표현식이 되지 못한다. 함수 이름이나 인수의 모양만 보지 말고 실제로 평가되는 연산을 살펴야 한다.

constexpr 함수는 상수식이 필요한 초기화와 실행 중 입력을 사용하는 호출에 모두 쓰인다
계산 시점을 결정하는 것은 선언과 사용 위치의 조합이다
형태요구 사항이번 엔진의 용도
constexpr 함수조건을 만족하는 호출을 상수식에서 사용 가능수수료 산술식 공유
constexpr 변수초기화에 상수 표현식 필요수량 상한과 요율 표
consteval 함수일반적인 호출 지점에서 상수 평가 요구내장 정책의 표 생성
static_assert컴파일 시 참인 상수 조건 필요경계와 계산 범위 확인

최적화와 언어의 요구를 나누어 본다

컴파일러는 일반 함수의 계산도 최적화 과정에서 미리 끝낼 수 있다. 반대로 constexpr 함수가 실행 중 입력을 받으면 실제 실행 코드가 필요할 수 있다. 최적화 결과는 컴파일러와 설정에 따라 달라지지만, constexpr 변수의 초기화가 상수 표현식이어야 한다는 요구는 언어 규칙이다.

따라서 이 장에서는 실행 속도가 빨라질 것이라는 기대보다, 잘못된 내장 정책을 빌드 단계에서 거절한다는 성질에 집중한다. 작은 표 조회가 조건문보다 빠른지도 여기서 단정하지 않는다. 표의 크기, 접근 패턴, 최적화 결과가 달라지면 실제 비용 역시 달라진다.

consteval로 표를 만들고 static_assert로 가정을 고정한다

내장 정책을 만드는 입구

consteval로 선언한 함수는 즉시 함수(immediate function)다. 이번 코드처럼 일반적인 초기화 식에서 호출하면 그 호출 자체가 상수 표현식이어야 한다. 반환값을 받는 변수가 단순한 auto여도 이 요구는 사라지지 않는다. 결과를 나중에 변경할 수 있는지와 함수를 언제 평가해야 하는지는 서로 다르기 때문이다.

표 생성 함수가 constexpr여도 결과를 constexpr 변수에 넣으면 그 초기화에는 상수 평가가 요구된다. 여기서 consteval을 선택하는 이유는 함수를 다른 곳에서 사용하더라도 내장 정책을 만드는 용도로만 쓰도록 제한하기 위해서다. 일반 산술 함수에는 실행 중 사용을 허용하고, 정책 생성 함수에는 허용하지 않는 구분이다.

표 생성 함수는 경계가 허용 수량 안에 있는지, 요율이 0보다 크고 10,000 이하인지 확인한다. 잘못된 인수가 들어오면 throw 식에 도달하도록 작성한다. 상수 평가 중에는 예외를 던지는 연산을 완료할 수 없으므로 해당 호출이 컴파일 오류가 된다. 이것은 실행 중 예외를 잡아 복구하는 경로가 아니다.

오류 메시지에 문자열이 어떤 모양으로 표시되는지는 컴파일러마다 다르다. 문자열이 반드시 그대로 출력된다고 기대하기보다, 잘못된 정책을 사용한 호출 위치와 실패한 평가 경로가 진단된다는 점을 이용한다. 호출 인수의 구체적인 값을 검사하는 이유로 이 방식을 사용한다.

표의 모양과 사용 계약

표에는 0번부터 8번까지 아홉 원소를 둔다. 0번 원소는 사용하지 않는 자리이며 값은 0이다. 이 자리를 남겨 두면 유효한 주문 수량을 그대로 인덱스로 쓸 수 있다. 그렇다고 수량 0이 유효한 주문이 되는 것은 아니다. 표를 조회하기 전에 주문의 유효 범위를 확인해야 한다.

생성 함수는 값으로 초기화한 std::array를 만들고 1번부터 마지막 원소까지 채운다. 상수 평가 중에 지역 배열을 수정하고 반복문을 실행하는 것은 이 코드에서 허용된다. 표가 완성된 뒤 반환되며, 호출자는 결과를 constexpr 변수에 보관한다.

하나의 정책에서 수량별 요율 표를 만들고 실행 중에는 검증한 수량으로 원소를 선택한다

static_assert는 고정된 가정이 깨지면 번역을 실패시킨다. 표의 크기와 경계 원소를 확인하면 정책 수정이 어느 부분에 영향을 주는지 드러난다. 이 검사는 실행 중 비용이 없으며, 일반적인 실행 중 단언과 달리 NDEBUG 정의로 제거되는 검사도 아니다.

다만 경계 몇 개를 검사했다고 표 전체의 의미가 증명되는 것은 아니다. 반복문과 조건식이 어떻게 원소를 채우는지 읽어야 한다. 여기서는 생성 알고리즘이 단순하므로 시작점, 구간의 끝, 다음 구간의 시작점, 마지막 원소를 검사해 의도한 경계를 기록한다.

정수 계산의 범위도 같은 방식으로 고정한다. 주문 수량과 단가에 상한을 두면 최대 거래 대금을 구할 수 있다. 이 값에 최대 요율을 곱해도 std::uint64_t 범위 안인지 검사한다. 나눗셈 전에 곱셈이 수행되므로, 최종 수수료가 작다는 사실만으로는 중간 계산의 안전성을 설명할 수 없다.

if constexpr는 템플릿에서 필요한 코드만 남긴다

체결 로그에는 두 가지 표현이 필요하다고 하자. 상세 로그에는 주문 번호, 수량, 거래 대금, 수수료를 기록한다. 간략 로그에는 주문 번호만 기록한다. 저장하는 정보가 다른 두 타입에 같은 출력 함수를 제공하되, 간략 타입에 없는 멤버를 요구하고 싶지는 않다.

template <bool Detailed, typename Entry>
void write_log(const Entry& entry) {
    std::cout << "trade id=" << entry.id;
    if constexpr (Detailed) {
        std::cout << " qty=" << entry.quantity
                  << " gross=" << entry.gross
                  << " fee=" << entry.fee;
    }
    std::cout << '\n';
}

Detailed는 함수 인수가 아니라 템플릿 인수다. write_log<false>를 인스턴스화하면 상세 출력 부분은 버려지는 문장(discarded statement)이 된다. 그 안의 entry.quantity처럼 템플릿 매개변수에 의존하는 멤버 접근은 해당 인스턴스에서 실제 멤버가 있는지 검사하도록 인스턴스화되지 않는다.

그러므로 주문 번호만 가진 타입도 write_log<false>에 전달할 수 있다. 같은 타입을 write_log<true>에 전달하면 상세 멤버가 필요하므로 컴파일 오류가 된다. 함수는 선택한 형식에 필요한 정보만 타입에 요구한다.

일반 if도 실행할 때는 한쪽만 선택하지만, 템플릿을 인스턴스화할 때 양쪽 코드가 유효해야 한다. 이것이 여기서 if constexpr를 사용하는 이유다. 실행 중에 읽은 로그 설정을 조건으로 넣을 수는 없다. 그 설정은 일반 if로 확인하고, 각 경로에서 미리 정해진 템플릿 인수를 사용해 호출해야 한다.

버려지는 문장이 소스에서 지워진 것처럼 취급되는 것은 아니다. 구문은 올바르게 작성해야 하며, 템플릿 매개변수에 의존하지 않는 이름의 오류를 숨기는 수단으로도 사용할 수 없다. 특히 템플릿 밖의 if constexpr를 “컴파일하지 않을 코드 영역”으로 생각하면 안 된다.

완성 코드

다음 내용을 main.cpp로 저장한다. 처음 두 주문은 서로 다른 수수료 구간을 사용하고, 마지막 주문은 수량 상한을 넘는다. 상세 타입과 간략 타입에서 같은 로그 템플릿을 사용하는 모습을 확인하기 위해, 유효한 주문마다 두 형식의 로그를 연달아 출력한다.

#include <array>
#include <cstddef>
#include <cstdint>
#include <iostream>
#include <limits>

constexpr std::size_t max_quantity = 8;
constexpr std::uint64_t max_unit_price = 1'000'000;
constexpr std::uint64_t basis = 10'000;

struct FeePolicy {
    std::size_t boundary;
    std::uint32_t small_bps;
    std::uint32_t large_bps;
};

consteval auto make_fee_table(FeePolicy policy) {
    if (policy.boundary == 0 ||
        policy.boundary >= max_quantity ||
        policy.small_bps == 0 ||
        policy.large_bps == 0 ||
        policy.small_bps > basis ||
        policy.large_bps > basis) {
        throw "invalid fee policy";
    }

    std::array<std::uint32_t, max_quantity + 1> table{};
    for (std::size_t q = 1; q < table.size(); ++q) {
        table[q] = q <= policy.boundary
            ? policy.small_bps
            : policy.large_bps;
    }
    return table;
}

constexpr FeePolicy fee_policy{4, 25, 20};
constexpr auto fee_table = make_fee_table(fee_policy);

constexpr std::uint64_t max_rate =
    fee_policy.small_bps > fee_policy.large_bps
        ? fee_policy.small_bps
        : fee_policy.large_bps;

constexpr std::uint64_t money_limit =
    std::numeric_limits<std::uint64_t>::max();

static_assert(max_quantity > 0, "quantity limit must be positive");
static_assert(max_unit_price <= money_limit / max_quantity,
              "gross amount would overflow");

constexpr std::uint64_t max_gross =
    max_unit_price * max_quantity;

static_assert(max_rate > 0, "maximum rate must be positive");
static_assert(max_gross <= money_limit / max_rate,
              "fee multiplication would overflow");
static_assert(fee_table.size() == max_quantity + 1,
              "table size mismatch");
static_assert(fee_table[0] == 0, "zero slot must be unused");
static_assert(fee_table[1] == 25 && fee_table[4] == 25,
              "small quantity rate mismatch");
static_assert(fee_table[5] == 20 && fee_table[8] == 20,
              "large quantity rate mismatch");

constexpr std::uint64_t calculate_fee(
    std::uint64_t gross, std::uint32_t bps) {
    return gross * bps / basis;
}

static_assert(calculate_fee(3'600, fee_table[3]) == 9,
              "fee calculation mismatch");
static_assert(calculate_fee(9'600, fee_table[8]) == 19,
              "fee rounding mismatch");

struct Order {
    std::uint64_t id;
    std::size_t quantity;
    std::uint64_t unit_price;
};

struct Trade {
    std::uint64_t id;
    std::size_t quantity;
    std::uint64_t gross;
    std::uint64_t fee;
};

struct TradeId {
    std::uint64_t id;
};

template <bool Detailed, typename Entry>
void write_log(const Entry& entry) {
    std::cout << "trade id=" << entry.id;
    if constexpr (Detailed) {
        std::cout << " qty=" << entry.quantity
                  << " gross=" << entry.gross
                  << " fee=" << entry.fee;
    }
    std::cout << '\n';
}

void process_order(const Order& order) {
    if (order.quantity == 0 ||
        order.quantity > max_quantity ||
        order.unit_price == 0 ||
        order.unit_price > max_unit_price) {
        std::cout << "reject id=" << order.id << '\n';
        return;
    }

    const std::uint64_t gross =
        order.unit_price * order.quantity;
    const auto fee =
        calculate_fee(gross, fee_table[order.quantity]);

    const Trade trade{order.id, order.quantity, gross, fee};
    write_log<true>(trade);
    write_log<false>(TradeId{order.id});
}

int main() {
    const std::array<Order, 3> orders{{
        {101, 3, 1'200},
        {102, 8, 1'200},
        {103, 9, 1'200}
    }};

    for (const auto& order : orders) {
        process_order(order);
    }
}

줄별 해설

상한과 정책 선언

첫 다섯 줄의 헤더는 배열, 크기 타입, 정수 타입, 출력, 정수 범위 조회를 위해 사용한다. max_quantity는 배열 크기와 반복문의 인덱스에 함께 쓰이므로 std::size_t로 선언한다. 가격과 계산 결과는 std::uint64_t로 통일해 이 예제의 금액 범위를 표현한다.

basis는 비율의 분모다. 숫자에 들어간 작은따옴표는 자릿수를 읽기 쉽게 하는 구분자이며 값에 영향을 주지 않는다. FeePolicy의 boundary는 낮은 수량 구간에 포함되는 마지막 수량이다. 나머지 두 멤버는 각각 그 구간과 다음 구간의 요율이다.

표 생성 함수의 각 단계

make_fee_table의 첫 조건문은 두 구간이 모두 존재하도록 경계를 제한하고 요율의 허용 범위를 확인한다. 조건 중 하나라도 참이면 throw에 도달한다. 유효한 정책에서는 이 경로를 방문하지 않으므로 표를 정상적으로 계산할 수 있다.

table{}은 모든 원소를 0으로 초기화한다. 반복 변수를 1에서 시작하므로 0번 원소는 그대로 남는다. q < table.size()는 마지막 유효 인덱스까지 방문하고 그다음 반복을 막는다. 각 원소에는 경계 비교 결과에 따라 두 요율 중 하나를 대입한다.

fee_policy는 표를 만드는 입력값을 이름 붙여 보관한다. fee_table의 초기화가 끝나면 수량별 결과가 고정된다. 이 표는 실행 중 조회할 수 있는 객체이기도 하다. 컴파일 타임에 만들었다는 사실이 실행 파일에 저장 공간이 필요 없다는 뜻은 아니다.

산술식의 범위를 확인하는 줄

max_rate는 두 요율 중 큰 값이다. 이후 최대 거래 대금과 곱할 값이므로 넓은 정수 타입으로 보관한다. money_limit는 그 타입이 표현할 수 있는 가장 큰 값이다. 이 두 이름은 범위 검사의 식을 읽을 때 무엇을 비교하는지 보여 준다.

첫 번째 곱셈 범위 검사는 max_unit_price * max_quantity를 먼저 계산해 비교하지 않는다. 곱셈 자체가 범위를 넘을 수 있기 때문이다. 대신 최대 단가가 money_limit / max_quantity 이하인지 확인한다. 수량 상한이 양수라는 조건이 이 나눗셈의 전제다.

max_gross를 정한 뒤에는 같은 방식으로 요율을 곱할 수 있는지 검사한다. 실행 경로에서 검증을 통과한 거래 대금은 max_gross 이하이고, 표에서 읽는 요율은 max_rate 이하다. 고정된 상한의 검사와 실행 중 입력 검사가 연결되어 실제 곱셈의 범위를 설명한다.

calculate_fee는 곱한 뒤 나누므로 정수 단위 미만을 버린다. 이 함수 자체는 모든 가능한 인수에 대해 범위를 검사하지 않는다. 여기서는 검증된 거래 대금과 내장 표의 요율만 전달한다는 호출 계약으로 사용한다. 다른 곳에서 임의의 큰 값을 전달하려면 별도의 범위 검사가 필요하다.

타입별 로그와 주문 처리

Order는 접수 정보, Trade는 계산 결과, TradeId는 간략 로그에 필요한 정보만 담는다. write_log는 공통 멤버인 id를 먼저 출력한다. 상세 멤버를 읽는 줄은 Detailed가 참인 인스턴스에서만 필요하다. 마지막 개행은 두 형식에 공통으로 적용된다.

process_order의 조건문은 수량과 단가를 함께 검사한다. 거절할 주문은 즉시 반환하므로 아래쪽 코드에서는 표의 인덱스와 산술 범위의 전제를 사용할 수 있다. 이 순서가 중요하다. 컴파일 타임에 검증한 표도 잘못된 실행 중 인덱스를 대신 막아 주지는 않는다.

gross와 fee는 주문마다 달라지는 지역 변수다. 이곳의 calculate_fee 호출에는 상수 평가 요구가 없다. 같은 함수가 앞의 static_assert에서는 컴파일 타임 계산에 쓰이고, 여기서는 실행 중 주문 처리에 쓰인다.

마지막 두 호출은 상세 결과와 번호만 가진 임시 객체를 각각 전달한다. main의 반복문은 준비한 주문을 순서대로 처리한다. 주문 목록이 예제 소스에 적혀 있어도 이 처리 경로 전체가 언어 규칙상 컴파일 타임 실행을 요구받는 것은 아니다.

실행 결과

macOS 또는 Linux의 C++20 지원 컴파일러에서 다음 명령으로 빌드하고 실행한다. 이 프로그램은 스레드를 만들지 않지만 책의 공통 실행 옵션인 -pthread를 그대로 사용한다.

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

예상 출력은 다음과 같다.

trade id=101 qty=3 gross=3600 fee=9
trade id=101
trade id=102 qty=8 gross=9600 fee=19
trade id=102
reject id=103

첫 주문의 수수료는 3600 * 25 / 10000으로 9다. 두 번째 주문은 9600 * 20 / 10000이며 정수 나눗셈으로 19가 된다. 세 번째 주문은 수량 9를 거절하므로 표의 범위를 벗어난 원소를 읽지 않는다. 표 생성이나 정적 검사는 실행 중 출력에 별도 흔적을 남기지 않는다.

실무에서 자주 틀리는 것

1. constexpr 함수의 모든 호출이 미리 계산된다고 생각한다

다음 코드의 문제는 함수가 아니라 결과 변수의 선언이다. 입력으로 읽은 값을 사용하면서 constexpr 초기화를 요구하고 있다.

std::uint64_t gross = 0;
std::cin >> gross;

// 잘못된 코드
constexpr auto fee = calculate_fee(gross, 25);

입력에 따른 계산은 실행 중 값으로 받는다. 계산 결과를 이후에 바꾸지 않을 예정이라면 const가 적절하다.

std::uint64_t gross = 0;
std::cin >> gross;

// 고친 코드: 호출 전에 금액 범위도 확인한다.
if (gross <= max_gross) {
    const auto fee = calculate_fee(gross, 25);
    std::cout << fee << '\n';
}

이 수정은 constexpr 함수의 재사용 목적을 보여 준다. 상수 평가가 필요할 때와 실행 중 계산이 필요할 때 같은 산술식을 사용하되, 결과를 받는 위치의 요구는 구분한다.

2. consteval 매개변수를 static_assert에 바로 넣는다

함수 호출이 상수 평가된다는 사실만으로 일반 함수 매개변수가 함수 정의 안의 모든 상수식 요구를 만족하지는 않는다. 다음 static_assert는 호출마다 값을 받아 실행하는 검사가 아니다.

// 잘못된 코드
consteval unsigned checked_rate(unsigned rate) {
    static_assert(rate > 0, "rate must be positive");
    return rate;
}

일반 매개변수로 받은 값을 확인하려면 함수의 평가 경로에서 검사한다. 아래에서 0을 전달하는 호출은 예외를 던지는 경로에 도달하므로 상수 평가 요구를 만족하지 못한다.

// 고친 코드
consteval unsigned checked_rate(unsigned rate) {
    if (rate == 0) {
        throw "rate must be positive";
    }
    return rate;
}

constexpr auto rate = checked_rate(25);

반면 이미 만들어진 constexpr 결과의 성질은 함수 밖에서 static_assert로 확인할 수 있다. 호출 중 인수 검증과 결과에 대한 고정 가정은 서로 다른 위치에서 표현한다.

3. 일반 if로 없는 멤버 접근을 숨기려 한다

다음 함수에 TradeId를 전달하고 템플릿 인수로 false를 지정해도 컴파일할 수 없다. 일반 조건문 안의 멤버 접근 역시 인스턴스화 과정에서 유효해야 한다.

// 잘못된 코드
template <bool Detailed, typename Entry>
void print_quantity(const Entry& entry) {
    if (Detailed) {
        std::cout << entry.quantity;
    }
}

타입에 따라 해당 표현식 자체가 필요하지 않게 하려면 if constexpr를 사용한다. 이때 조건은 컴파일 타임에 결정할 수 있어야 한다.

// 고친 코드
template <bool Detailed, typename Entry>
void print_quantity(const Entry& entry) {
    if constexpr (Detailed) {
        std::cout << entry.quantity;
    }
}

이 수정은 간략 타입에 불필요한 멤버를 추가하지 않고도 출력 함수를 공유하게 한다. 다만 상세 형식을 선택했다면 상세 정보가 있는 타입을 전달해야 한다는 요구는 그대로 남는다.

4. 계산이 끝난 뒤에 범위를 검사한다

다음 코드는 범위 검사를 하기 전에 이미 곱셈을 수행한다. 부호 없는 정수의 연산 결과가 범위를 넘어 순환하면 작은 값처럼 보일 수 있으므로 뒤의 비교가 원래 금액을 검증하지 못한다.

// 잘못된 코드
const auto gross = order.unit_price * order.quantity;
if (gross > max_gross) {
    return;
}

먼저 각 입력이 허용 범위 안에 있는지 검사한다. 고정 상한끼리의 곱셈이 안전하다는 사실은 완성 코드의 static_assert가 확인한다.

// 고친 코드
if (order.quantity == 0 ||
    order.quantity > max_quantity ||
    order.unit_price == 0 ||
    order.unit_price > max_unit_price) {
    return;
}
const std::uint64_t gross =
    order.unit_price * order.quantity;

정적 검사는 실행 중 검증을 없애는 장치가 아니다. 실행 중 검증이 지킬 경계를 정하고, 그 경계 안에서 후속 연산이 유효하다는 근거를 제공한다.

한눈에 보기

각 도구가 보장하는 것과 별도로 확인할 것
도구이번 코드에서의 역할별도로 확인할 것
constexpr 함수수수료 식을 두 평가 상황에서 공유모든 호출이 상수 평가되는 것은 아님
constexpr 변수정책과 표를 상수식으로 초기화실행 중 저장 공간이 필요할 수 있음
consteval내장 표 생성의 상수 평가 요구외부 설정을 읽는 용도와 맞지 않음
if constexpr선택한 로그 형식의 멤버만 요구실행 중 조건은 사용할 수 없음
static_assert경계 원소와 산술 상한 확인실행 중 주문은 따로 검증해야 함

공식 규칙을 확인할 때는 ISO C++ 표준 안내에서 표준과 관련 자료의 경로를 찾을 수 있다. C++20을 기준으로 읽을 항목은 [dcl.constexpr], [expr.const], [stmt.if], [dcl.pre]다. 이후 표준에서 추가된 기능과 예제의 실행 환경을 구분해 읽는다.

연습 문제

  1. 정책을 수량 1부터 3까지 30bp, 4부터 8까지 15bp로 바꾸라. 경계 검사와 수수료 검사도 함께 수정하고, 기존 주문 세 개의 출력이 어떻게 달라지는지 계산하라.
  2. make_fee_table의 지정자를 constexpr로 바꾸되 fee_table 선언은 유지한다고 하자. 표 초기화의 상수 평가 요구가 유지되는지 설명하라. 이어서 함수 안에서 실행 중 입력으로 만든 정책을 전달하고 결과를 일반 auto 변수에 받으면 무엇이 달라지는지 설명하라.
  3. 표의 0번 원소가 0이고 나머지 원소가 모두 1부터 10,000 사이인지 검사하는 constexpr 함수를 작성하라. 완성 코드의 표에 대해 static_assert로 호출하라.
  4. 실행 중 결정되는 bool detailed 값으로 상세 로그와 간략 로그를 선택하는 함수를 작성하라. 기존 write_log 템플릿을 사용하고, 간략 경로에는 TradeId를 전달하라.

정답과 해설

1. 정책 변경과 경계 검사

정책의 초기값을 바꾸고, 이전 정책의 숫자를 기록한 단언도 함께 바꾼다. 검사는 정책 변경을 막는 것이 아니라 변경할 때 검토해야 할 지점을 드러낸다. 특히 첫 구간의 끝과 다음 구간의 시작을 함께 확인해야 경계 포함 여부를 놓치지 않는다.

constexpr FeePolicy fee_policy{3, 30, 15};

static_assert(fee_table[1] == 30 && fee_table[3] == 30,
              "small quantity rate mismatch");
static_assert(fee_table[4] == 15 && fee_table[8] == 15,
              "large quantity rate mismatch");
static_assert(calculate_fee(3'600, fee_table[3]) == 10,
              "fee calculation mismatch");
static_assert(calculate_fee(9'600, fee_table[8]) == 14,
              "fee rounding mismatch");

이 코드는 원래 위치의 선언과 대응하는 단언을 교체하는 부분이다. 표의 생성과 최대 요율 계산은 그대로 정책을 참조하므로 별도의 수작업이 필요 없다. 첫 수수료는 10.8에서 소수 부분을 버린 10이고, 두 번째는 14.4에서 소수 부분을 버린 14다.

trade id=101 qty=3 gross=3600 fee=10
trade id=101
trade id=102 qty=8 gross=9600 fee=14
trade id=102
reject id=103

2. 함수의 허용 범위와 호출 위치의 요구

fee_table이 여전히 constexpr 변수라면 초기화에 상수 표현식이 필요하다는 요구는 유지된다. 이 호출 하나만 보면 생성 함수가 constexpr여도 목적을 달성한다. 달라지는 부분은 그 함수를 다른 호출 위치에서 사용할 수 있는 범위다.

constexpr 함수는 실행 중 입력으로 만든 정책도 받을 수 있다. 결과를 일반 지역 변수에 받으면 실행 중에 표를 생성할 수 있다. 이 경우 잘못된 정책은 실행 중 예외를 던지며, 별도의 처리 없이 예외가 빠져나가면 정상적인 주문 처리를 이어 갈 수 없다. consteval을 유지하면 실행 중 정책을 전달하는 호출 자체가 컴파일되지 않는다.

3. 모든 원소의 범위 검사

반복문을 사용하는 검사 함수도 상수 평가할 수 있다. 구체적인 요율과 무관한 범위 검사는 다음처럼 작성한다. 이 함수는 완성 코드의 basis와 max_quantity 선언 이후에 두고, 단언은 fee_table이 정의된 뒤에 둔다.

constexpr bool valid_fee_table(
    const std::array<std::uint32_t, max_quantity + 1>& table) {
    if (table[0] != 0) {
        return false;
    }
    for (std::size_t q = 1; q < table.size(); ++q) {
        if (table[q] == 0 || table[q] > basis) {
            return false;
        }
    }
    return true;
}

static_assert(valid_fee_table(fee_table),
              "fee table contains an invalid rate");

이 검사는 누락되어 0으로 남은 원소나 허용 범위를 넘는 요율을 찾아낸다. 다만 모든 원소가 25여도 범위 검사에는 통과한다. 구간별 정책의 의미를 확인하는 경계 단언과 원소 범위 검사는 서로 다른 성질을 확인하므로 함께 둘 수 있다.

4. 실행 중 선택과 컴파일 타임 선택의 연결

실행 중 설정은 일반 if로 처리하고 각 경로에서 템플릿 인수를 명시한다. 이 함수를 사용하려면 기존 타입과 write_log 정의 다음에 배치한다.

void write_selected_log(const Trade& trade, bool detailed) {
    if (detailed) {
        write_log<true>(trade);
    } else {
        write_log<false>(TradeId{trade.id});
    }
}

두 호출에 필요한 템플릿 인스턴스는 컴파일할 때 만들어진다. 실행 중에는 어느 호출을 수행할지만 선택한다. write_log<detailed>처럼 실행 중 값을 템플릿 인수로 전달하는 것은 허용되지 않는다. 이렇게 선택 시점을 나누면 실행 중 설정을 지원하면서도 각 타입이 제공해야 할 정보는 컴파일 단계에서 확인할 수 있다.

댓글 0

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

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