C++20 constexpr 컨테이너와 알고리즘: constexpr vector·string·new/delete의 규칙과 한계
들어가며
constexpr 기초에서 다룬 constexpr 함수는 C++17까지 고정 크기 데이터만 다룰 수 있었습니다. std::vector와 std::string은 동적 할당을 하기 때문에 상수 평가에 쓸 수 없었고, 컴파일 타임에 문자열을 파싱하거나 크기를 미리 모르는 목록을 만들려면 std::array에 충분히 큰 크기를 잡아 두고 직접 개수를 관리해야 했습니다.
C++20은 상수 평가 중의 동적 할당(constexpr new/delete)을 허용하고, 이를 바탕으로 std::vector와 std::string, 그리고 대부분의 표준 알고리즘을 constexpr로 만들었습니다. 이제 "64,128,256,512" 같은 문자열을 컴파일 타임에 파싱해 정렬하고 static_assert로 검증하는 코드를 평범한 C++로 쓸 수 있습니다.
다만 중요한 제약이 하나 있습니다. 상수 평가 중에 할당한 메모리는 그 평가가 끝나기 전에 해제되어야 합니다. 그래서 constexpr std::vector<int> v = {1, 2, 3};처럼 vector 자체를 constexpr 변수로 남기는 것은 C++20에서도 허용되지 않습니다. 이 글의 예제는 모두 이 규칙을 지키도록 작성했고, 계산 결과를 프로그램에 남기는 방법도 함께 설명합니다.
flowchart TD
A[C++20 constexpr 확장] --> B["std::vector"]
A --> C["std::string"]
A --> D["알고리즘: sort, find, lower_bound..."]
A --> E["new/delete (transient)"]
B --> F[평가 안에서만 쓰는 동적 컨테이너]
C --> F
D --> G[컴파일 타임 정렬·검색]
E --> F
F --> H["결과는 std::array·스칼라로 옮겨 보관"]
컴파일 타임으로 옮기고 싶은 계산
설정 문자열 파싱과 검증
버퍼 크기 목록 "64,128,256,512"를 파싱해, 모두 2의 거듭제곱이고 중복이 없는지 컴파일 타임에 검증하는 예입니다. C++17에서는 파싱 결과를 담을 동적 컨테이너가 없어 고정 크기 배열로 우회하거나 런타임 검증만 할 수 있었습니다.
// C++20: g++ -std=c++20 -o parse parse.cpp
#include <algorithm>
#include <cstddef>
#include <vector>
constexpr std::vector<std::size_t> parse_sizes(const char* s) {
std::vector<std::size_t> result;
std::size_t val = 0;
bool has_digit = false;
while (*s) {
if (*s >= '0' && *s <= '9') {
val = val * 10 + static_cast<std::size_t>(*s - '0');
has_digit = true;
} else if (*s == ',') {
result.push_back(val);
val = 0;
has_digit = false;
}
++s;
}
if (has_digit) result.push_back(val);
return result;
}
constexpr bool is_power_of_two(std::size_t n) {
return n > 0 && (n & (n - 1)) == 0;
}
constexpr bool validate_sizes(std::vector<std::size_t> v) {
std::sort(v.begin(), v.end());
for (std::size_t i = 0; i < v.size(); ++i) {
if (!is_power_of_two(v[i])) return false;
if (i > 0 && v[i] == v[i - 1]) return false; // 중복
}
return true;
}
// vector는 이 static_assert의 상수 평가 안에서 생성되고 해제됩니다.
static_assert(validate_sizes(parse_sizes("64,128,256,512")));
static_assert(!validate_sizes(parse_sizes("64,100")));
int main() { return 0; }
parse_sizes가 돌려준 vector는 static_assert 식 하나를 평가하는 동안만 존재하고, 식이 끝날 때 소멸하면서 메모리를 해제합니다. 그래서 이 코드는 transient allocation 규칙을 만족합니다. 반면 constexpr auto sizes = parse_sizes("...");처럼 결과 vector를 constexpr 변수에 담으면, 할당된 메모리가 평가 밖으로 살아남게 되어 컴파일 에러가 납니다. 마지막 원소 뒤에 쉼표가 없는 경우를 처리하려고 val != 0이 아니라 has_digit을 검사한 것도 눈여겨볼 부분입니다. val != 0으로 검사하면 마지막 값이 0일 때 빠집니다.
명령어 해시 테이블
"START", "STOP", "PAUSE", "RESUME"의 해시와 인덱스를 컴파일 타임에 계산해 정렬해 두고, 런타임에는 입력 문자열의 해시만 계산해 이진 검색으로 명령을 찾는 예입니다.
// C++20
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <utility>
constexpr std::uint64_t hash_fnv1a(std::string_view str) {
std::uint64_t hash = 14695981039346656037ULL;
for (char c : str) {
hash ^= static_cast<unsigned char>(c);
hash *= 1099511628211ULL;
}
return hash;
}
template <std::size_t N>
constexpr auto make_command_table(const std::string_view (&commands)[N]) {
std::array<std::pair<std::uint64_t, std::size_t>, N> table{};
for (std::size_t i = 0; i < N; ++i) {
table[i] = {hash_fnv1a(commands[i]), i};
}
std::sort(table.begin(), table.end());
return table;
}
constexpr std::string_view COMMANDS[] = {"START", "STOP", "PAUSE", "RESUME"};
constexpr auto CMD_TABLE = make_command_table(COMMANDS);
int dispatch(std::string_view cmd) {
const auto h = hash_fnv1a(cmd);
auto it = std::lower_bound(CMD_TABLE.begin(), CMD_TABLE.end(), h,
[](const auto& entry, std::uint64_t key) { return entry.first < key; });
if (it != CMD_TABLE.end() && it->first == h && COMMANDS[it->second] == cmd)
return static_cast<int>(it->second);
return -1;
}
여기서는 vector 없이 std::array만 쓰므로 CMD_TABLE을 constexpr 변수로 남길 수 있습니다. 조회는 정렬된 배열에 대한 이진 검색이므로 O(log N)입니다. 해시가 같다고 같은 문자열이라는 보장은 없으므로, 마지막에 원래 문자열과 한 번 더 비교해 충돌을 걸러 냅니다. 해시 함수에 const char* 대신 std::string_view를 받게 한 것도 의도된 선택입니다. std::string_view::data()는 널 종료를 보장하지 않으므로, 널 문자까지 읽는 함수에 넘기면 범위를 넘어 읽을 수 있습니다.
피보나치 시퀀스 생성
// C++20
#include <vector>
constexpr std::vector<int> fib_sequence(int n) {
std::vector<int> result;
if (n >= 1) result.push_back(1);
if (n >= 2) result.push_back(1);
for (int i = 2; i < n; ++i) {
result.push_back(result[i - 1] + result[i - 2]);
}
return result;
}
static_assert(fib_sequence(8).size() == 8);
static_assert(fib_sequence(8).back() == 21); // {1, 1, 2, 3, 5, 8, 13, 21}
이렇게 결과를 검증하는 데 쓰는 것은 문제가 없지만, 테스트 픽스처처럼 결과 자체를 프로그램에서 쓰려면 다음 절의 방법으로 std::array에 옮겨야 합니다.
constexpr 컨테이너
std::array
std::array는 할당을 하지 않으므로 constexpr 변수로 남길 수 있는 가장 기본적인 컨테이너입니다. C++17에서 비-const operator[], begin/end 등이 constexpr가 되어 constexpr 함수 안에서 값을 채울 수 있게 되었고, C++20에서 std::sort 같은 알고리즘이 constexpr가 되면서 정렬까지 컴파일 타임에 할 수 있습니다.
// C++20 (std::sort가 constexpr인 버전 필요)
#include <algorithm>
#include <array>
constexpr std::array<int, 5> make_sorted() {
std::array<int, 5> a = {5, 2, 8, 1, 9};
std::sort(a.begin(), a.end());
return a;
}
constexpr auto SORTED = make_sorted();
static_assert(SORTED[0] == 1);
static_assert(SORTED[4] == 9);
std::vector (C++20)
C++20에서 std::vector의 생성자, push_back, resize, 소멸자 등이 constexpr가 되었습니다.
// C++20
#include <vector>
constexpr std::vector<int> make_vector() {
std::vector<int> v;
v.push_back(1);
v.push_back(2);
v.push_back(3);
return v;
}
static_assert(make_vector().size() == 3);
static_assert(make_vector()[0] == 1 && make_vector()[2] == 3);
// ❌ 컴파일 에러: 할당된 메모리가 상수 평가 밖으로 살아남음
// constexpr auto V = make_vector();
constexpr 평가 중 할당된 메모리는 자동으로 해제되지 않습니다. 평가가 끝나기 전에 vector가 소멸하면서 직접 해제해야 하고, 그렇지 않으면 상수 식이 되지 못합니다. static_assert(make_vector().size() == 3)에서는 임시 vector가 식이 끝날 때 소멸하므로 규칙을 지킵니다. constexpr auto V = make_vector();는 V가 프로그램이 끝날 때까지 살아 있으므로 할당이 평가 밖으로 빠져나가 에러가 됩니다. GCC는 이 경우 “is not a constant expression because it refers to a result of ‘operator new‘“와 같은 메시지를 냅니다.
계산 결과를 std::array로 옮기기
vector로 계산한 결과를 프로그램에 남기려면 두 단계로 나눕니다. 먼저 크기만 계산해 상수로 만들고, 그 크기로 std::array를 만든 뒤 내용을 복사합니다. vector는 두 번 생성되지만 둘 다 각자의 상수 평가 안에서 해제됩니다.
// C++20
#include <algorithm>
#include <array>
#include <cstddef>
#include <vector>
constexpr std::vector<int> fib_sequence(int n); // 앞의 정의
constexpr std::size_t FIB_N = fib_sequence(8).size();
constexpr auto FIB8 = [] {
std::array<int, FIB_N> arr{};
auto v = fib_sequence(8);
std::copy(v.begin(), v.end(), arr.begin());
return arr;
}();
static_assert(FIB8[7] == 21);
FIB8은 할당이 없는 std::array이므로 constexpr 변수로 남고, 런타임에는 읽기 전용 데이터로 존재합니다. 같은 계산을 두 번 하는 비용은 컴파일 시간에만 들고, 데이터가 크지 않다면 무시할 만합니다.
크기가 정해지지 않은 테이블 만들기
// C++20: 컴파일 타임에 n개 항목 테이블 생성
#include <cstddef>
#include <vector>
template <typename Func>
constexpr auto make_table(std::size_t n, Func f) {
std::vector<decltype(f(0))> result;
result.reserve(n);
for (std::size_t i = 0; i < n; ++i) {
result.push_back(f(static_cast<int>(i)));
}
return result;
}
constexpr int square(int x) { return x * x; }
static_assert(make_table(10, square).size() == 10);
static_assert(make_table(10, square)[5] == 25);
constexpr 컨테이너 비교
| 항목 | std::array | std::vector (C++20) |
|---|---|---|
| constexpr 함수 안에서 사용 | 가능 | 가능 |
| constexpr 변수로 보관 | 가능 | 불가 (할당이 평가 밖으로 나감) |
| push_back / resize | 없음 (고정 크기) | 가능 |
| 할당자 | 해당 없음 | allocate/deallocate가 constexpr인 할당자 (std::allocator 등) |
| 표준 라이브러리 지원 | C++17 | libstdc++ 12, libc++ 15, MSVC STL VS 2019 16.10 이상 |
constexpr 알고리즘
C++20에서 constexpr가 된 알고리즘
C++20에서 <algorithm>과 <numeric>의 대부분이 constexpr가 되었습니다. 대표적인 것은 다음과 같습니다.
- 정렬과 분할:
std::sort,std::partial_sort,std::nth_element,std::partition - 검색:
std::find,std::find_if,std::lower_bound,std::upper_bound,std::binary_search - 변경:
std::copy,std::copy_if,std::fill,std::reverse,std::rotate,std::unique - 판정:
std::all_of,std::any_of,std::none_of,std::is_sorted - 수치:
std::accumulate,std::iota
std::min_element, std::max_element는 C++17부터 constexpr였습니다. 반대로 임시 버퍼를 할당할 수 있는 std::stable_sort, std::stable_partition, std::inplace_merge는 C++20에서 제외되었고 C++26에서 constexpr가 됩니다.
정렬된 배열에서 이진 검색
// C++20
#include <algorithm>
#include <array>
constexpr std::array<int, 5> SORTED_KEYS = {1, 3, 5, 7, 9};
constexpr bool contains(int key) {
return std::binary_search(SORTED_KEYS.begin(), SORTED_KEYS.end(), key);
}
static_assert(contains(5));
static_assert(!contains(4));
vector와 sort 조합
// C++20
#include <algorithm>
#include <vector>
constexpr std::vector<int> sorted_vector() {
std::vector<int> v = {30, 10, 50, 20, 40};
std::sort(v.begin(), v.end());
return v;
}
static_assert(sorted_vector()[0] == 10);
static_assert(sorted_vector()[4] == 50);
all_of와 람다
// C++20
#include <algorithm>
#include <array>
constexpr std::array<int, 4> EVENS = {2, 4, 6, 8};
constexpr bool all_even() {
return std::all_of(EVENS.begin(), EVENS.end(),
[](int x) { return x % 2 == 0; });
}
static_assert(all_even());
C++17부터 람다는 조건을 만족하면 암시적으로 constexpr이므로, 이렇게 알고리즘의 술어로 그대로 넘길 수 있습니다.
constexpr 문자열
std::string (C++20)
C++20에서 std::string의 생성자, operator+=, push_back, substr, find, replace 등이 constexpr가 되었습니다. vector와 마찬가지로 할당한 메모리는 평가 안에서 해제되어야 합니다.
// C++20
#include <string>
constexpr std::string make_greeting(const char* name) {
std::string result = "Hello, ";
result += name;
result += "!";
return result;
}
static_assert(make_greeting("World") == "Hello, World!");
static_assert(make_greeting("World").size() == 13);
짧은 문자열은 구현의 SSO(Small String Optimization) 버퍼에 들어가 할당이 일어나지 않을 수 있어서, 어떤 구현에서는 constexpr std::string s = "short";가 컴파일되기도 합니다. 하지만 이는 구현과 문자열 길이에 따라 달라지므로 기대하지 않는 것이 좋습니다. 문자열 결과를 남기려면 std::array<char, N>으로 옮기거나, 리터럴이면 처음부터 std::string_view를 씁니다.
”key=value” 파싱
// C++20
#include <string>
#include <utility>
constexpr std::pair<std::string, std::string> parse_key_value(const char* s) {
std::string key, value;
while (*s && *s != '=') {
key += *s++;
}
if (*s == '=') ++s;
while (*s) {
value += *s++;
}
return {key, value};
}
static_assert(parse_key_value("size=4096").first == "size");
static_assert(parse_key_value("size=4096").second == "4096");
검색과 치환
// C++20
#include <string>
constexpr std::string replace_first(const std::string& s,
const std::string& from, const std::string& to) {
std::string result = s;
auto pos = result.find(from);
if (pos != std::string::npos) {
result.replace(pos, from.size(), to);
}
return result;
}
static_assert(replace_first("foo_bar_baz", "_", "-") == "foo-bar_baz");
std::string_view
std::string_view는 할당을 하지 않으므로 constexpr 변수로 남길 수 있습니다. 생성자와 substr, find, remove_prefix 같은 멤버는 C++17부터 constexpr였고, C++20에서는 starts_with, ends_with가 추가되었습니다.
// C++17 이상
#include <string_view>
constexpr std::string_view trim_leading(std::string_view sv) {
while (!sv.empty() && sv[0] == ' ') {
sv.remove_prefix(1);
}
return sv;
}
constexpr auto TRIMMED = trim_leading(" hello");
static_assert(TRIMMED == "hello");
입력이 리터럴이고 결과가 그 일부분이라면 std::string 대신 std::string_view로 처리하는 편이 간단합니다. 할당이 없으므로 결과를 constexpr 변수에 그대로 둘 수 있습니다.
constexpr new/delete (C++20)
기본 규칙
C++20부터 상수 평가 중에 new와 delete를 쓸 수 있습니다. 조건은 같은 상수 평가가 끝나기 전에 할당한 메모리를 모두 해제하는 것입니다. 이를 transient allocation이라고 부르며, 해제되지 않은 할당이 남으면 그 식은 상수 식이 아니게 되어 컴파일 에러가 납니다.
// C++20
constexpr int dynamic_sum(int a, int b) {
int* p = new int(a + b);
int result = *p;
delete p;
return result;
}
static_assert(dynamic_sum(3, 5) == 8);
몇 가지 세부 규칙이 더 있습니다. 상수 평가에서 쓸 수 있는 것은 대체 가능한 전역 operator new를 부르는 new 표현식과 std::allocator<T>::allocate이고, 클래스별 operator new를 정의한 타입은 쓸 수 없습니다. placement new는 C++20 상수 평가에서 허용되지 않으며, 할당한 메모리에 객체를 만들 때는 std::construct_at을, 파괴할 때는 std::destroy_at을 씁니다. std::vector의 constexpr 구현도 이 두 함수로 되어 있습니다. placement new는 C++26에서 상수 평가에 허용됩니다.
동적 배열
// C++20: 컴파일 타임에 동적 크기 배열 사용
#include <cstddef>
constexpr int square_at(std::size_t n) {
int* p = new int[n + 1];
for (std::size_t i = 0; i <= n; ++i) {
p[i] = static_cast<int>(i * i);
}
int result = p[n];
delete[] p;
return result;
}
static_assert(square_at(5) == 25);
new[]로 할당했다면 반드시 delete[]로 해제해야 합니다. 런타임에서는 형식이 맞지 않는 해제가 미정의 동작이라 조용히 넘어갈 수도 있지만, 상수 평가에서는 컴파일러가 이를 검사해 에러로 알려 줍니다. 범위를 벗어난 접근, 해제한 메모리 사용, 이중 해제도 마찬가지입니다. 이 점 때문에 constexpr로 평가해 보는 것 자체가 미정의 동작을 찾는 도구가 되기도 합니다.
제약 요약
| 항목 | C++20 상수 평가에서 |
|---|---|
new T, new T[n] | 허용 (평가 안에서 해제해야 함) |
delete p, delete[] p | 허용 |
std::allocator<T>::allocate/deallocate | 허용 |
std::construct_at, std::destroy_at | 허용 |
| placement new | 불가 (C++26부터 허용) |
클래스별 operator new | 불가 |
| 평가 종료 시 남은 할당 | 상수 식이 아님 (컴파일 에러) |
자주 만나는 컴파일 에러
C++17로 컴파일
std::vector와 std::string의 constexpr 지원은 C++20부터이고, 표준 라이브러리 구현도 그 기능을 갖춰야 합니다. -std=c++20을 지정해도 libstdc++ 11 이하나 libc++ 14 이하에서는 “call to non-constexpr function” 에러가 납니다. C++17이 필수라면 std::array와 직접 관리하는 개수 변수로 대체합니다.
vector나 string을 constexpr 변수에 담음
가장 흔한 실수입니다.
// ❌ C++20에서도 컴파일 에러
// constexpr std::vector<int> v = {1, 2, 3};
// ✅ 고정 크기 데이터는 std::array
constexpr std::array<int, 3> a = {1, 2, 3};
// ✅ vector는 상수 평가 안에서만 쓰고 결과만 남김
constexpr std::size_t n = [] {
std::vector<int> v = {1, 2, 3};
return v.size();
}();
constexpr 함수 안의 throw
constexpr 함수 안에 throw를 쓰는 것은 C++20에서 허용됩니다. 상수 평가 중에 그 throw에 실제로 도달하면 상수 식이 아니게 되어 컴파일 에러가 나고, 런타임 호출에서는 평범하게 예외를 던집니다. 그래서 throw는 컴파일 타임 입력 검증에 쓰는 관용적인 방법입니다.
#include <stdexcept>
constexpr int checked_positive(int x) {
if (x < 0) throw std::invalid_argument("negative");
return x;
}
constexpr int ok = checked_positive(5); // ✅
// constexpr int bad = checked_positive(-1); // ❌ 컴파일 에러: throw에 도달
C++20에서는 상수 평가 중에 예외를 던지고 잡는 것은 불가능하며, C++26에서 constexpr 예외가 허용됩니다.
해제하지 않은 new
// ❌ 이 함수를 상수 평가하면 에러: 할당이 해제되지 않음
// constexpr int* leak() { return new int(42); }
// constexpr int* p = leak();
// ✅ 같은 평가 안에서 해제
constexpr int no_leak() {
int* p = new int(42);
int r = *p;
delete p;
return r;
}
I/O와 전역 상태
상수 평가 중에는 std::cout 같은 입출력이나, 평가 밖에서 수명이 시작된 변수의 수정을 할 수 없습니다. constexpr 함수가 이런 코드를 포함하더라도 상수 평가에서 그 경로에 도달하지 않으면 괜찮지만, 도달하면 컴파일 에러입니다.
런타임 값을 넘긴 호출 결과를 상수 식 자리에 사용
constexpr 함수는 인자가 상수 식이 아니면 그냥 런타임 함수로 동작합니다. 그 결과를 static_assert나 constexpr 변수처럼 상수 식이 필요한 곳에 쓰면 에러가 납니다.
#include <string>
constexpr std::string concat(const char* a, const char* b) {
std::string s = a;
s += b;
return s;
}
int main() {
const char* rt = "runtime"; // constexpr가 아닌 포인터
// static_assert(concat(rt, "x").size() > 0); // ❌ rt는 상수 식이 아님
auto s = concat(rt, "world"); // ✅ 런타임 호출
static_assert(concat("a", "b") == "ab"); // ✅ 컴파일 타임, 임시 string은 식 안에서 해제
return static_cast<int>(s.size());
}
컴파일러 한계
상수 평가에는 재귀 깊이와 평가 단계 수의 한계가 있습니다. GCC와 Clang 모두 재귀 깊이 기본값은 512이며 -fconstexpr-depth로 바꿀 수 있습니다. 평가 단계 수는 GCC가 -fconstexpr-ops-limit와 -fconstexpr-loop-limit, Clang이 -fconstexpr-steps로 조정합니다. MSVC는 /constexpr:depth, /constexpr:steps를 씁니다. 기본값과 옵션은 컴파일러 버전에 따라 바뀔 수 있으므로, 한계에 걸리면 옵션을 늘리기 전에 계산 자체를 줄이는 편이 이식성에 좋습니다.
사용자 정의 타입 정렬
std::sort를 상수 평가에서 쓰려면 비교 연산도 constexpr여야 합니다. 그리고 정렬할 배열은 수정 가능해야 하므로 constexpr 함수 안의 지역 변수여야 합니다. 네임스페이스 범위의 constexpr 배열은 const이므로 정렬할 수 없습니다.
#include <algorithm>
#include <array>
struct Point {
int x, y;
constexpr bool operator<(const Point& o) const {
return x < o.x || (x == o.x && y < o.y);
}
};
constexpr auto sorted_points() {
std::array<Point, 3> pts = {{{3, 1}, {1, 2}, {1, 1}}};
std::sort(pts.begin(), pts.end());
return pts;
}
static_assert(sorted_points()[0].x == 1 && sorted_points()[0].y == 1);
사용자 정의 할당자
constexpr std::vector는 할당자의 allocate와 deallocate가 constexpr일 때만 상수 평가에서 쓸 수 있습니다. std::allocator는 이 조건을 만족하고, 직접 만든 할당자도 같은 조건을 갖추면 됩니다. 메모리 풀처럼 전역 상태를 쓰는 할당자는 상수 평가에서 쓸 수 없습니다.
설계 시 고려할 점
constexpr new보다 표준 컨테이너
constexpr new/delete를 직접 쓰기보다 std::vector를 쓰는 편이 해제를 빠뜨릴 위험이 없습니다. 직접 할당은 컨테이너를 구현하는 경우가 아니라면 필요한 일이 드뭅니다.
검증은 static_assert로
constexpr로 계산한 결과는 static_assert로 검증해, 잘못된 설정이면 컴파일 단계에서 막습니다.
constexpr int parse_buffer_size(const char* s) {
int r = 0;
while (*s >= '0' && *s <= '9') {
r = r * 10 + (*s - '0');
++s;
}
return r;
}
constexpr int BUF = parse_buffer_size("4096");
static_assert(BUF >= 64 && BUF <= 65536, "buffer size out of range");
깊은 재귀보다 반복문
C++14부터 constexpr 함수에서 반복문을 쓸 수 있으므로, 재귀 깊이 한계에 걸리기 쉬운 재귀 대신 반복문으로 작성하는 편이 안전합니다.
constexpr int sum_loop(int n) {
int s = 0;
for (int i = 0; i <= n; ++i) s += i;
return s;
}
컴파일 타임 전용이면 consteval
반드시 컴파일 타임에만 평가되어야 하는 함수는 C++20 consteval로 선언하면 실수로 런타임에 호출하는 것을 막을 수 있습니다. 앞의 checked_positive를 consteval로 바꾸면 런타임 인자로 호출하는 코드 자체가 컴파일되지 않습니다.
consteval int must_be_compile_time(int x) {
return x * 2;
}
실전 패턴
설정 문자열 파싱
// C++20: "key1=val1,key2=val2" 파싱 후 검증
#include <string>
#include <utility>
#include <vector>
constexpr std::vector<std::pair<std::string, std::string>>
parse_config(const char* s) {
std::vector<std::pair<std::string, std::string>> result;
std::string key, value;
bool in_value = false;
while (*s) {
if (*s == '=') {
in_value = true;
} else if (*s == ',') {
result.push_back({key, value});
key.clear();
value.clear();
in_value = false;
} else if (in_value) {
value += *s;
} else {
key += *s;
}
++s;
}
if (!key.empty()) result.push_back({key, value});
return result;
}
static_assert(parse_config("size=4096,timeout=30").size() == 2);
static_assert(parse_config("size=4096,timeout=30")[1].first == "timeout");
문자열 키로 정렬된 명령어 테이블
// C++20: 명령어 문자열 → 인덱스 매핑 (정렬 후 이진 검색)
#include <algorithm>
#include <array>
#include <cstddef>
#include <string_view>
#include <utility>
template <std::size_t N>
constexpr auto make_sorted_command_table(const std::string_view (&cmds)[N]) {
std::array<std::pair<std::string_view, std::size_t>, N> table{};
for (std::size_t i = 0; i < N; ++i) {
table[i] = {cmds[i], i};
}
std::sort(table.begin(), table.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
return table;
}
constexpr std::string_view COMMAND_NAMES[] = {"STOP", "START", "RESUME", "PAUSE"};
constexpr auto SORTED_CMDS = make_sorted_command_table(COMMAND_NAMES);
static_assert(SORTED_CMDS[0].first == "PAUSE");
해시 테이블 버전과 달리 문자열 자체를 키로 쓰므로 충돌 처리가 필요 없습니다. std::string_view는 할당이 없어 결과 배열을 constexpr 변수로 남길 수 있습니다.
Base64 디코딩 결과를 배열로 보관
// C++20
#include <algorithm>
#include <array>
#include <cstddef>
#include <cstdint>
#include <string_view>
#include <vector>
constexpr int b64_index(char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
constexpr std::vector<std::uint8_t> b64_decode(std::string_view sv) {
std::vector<std::uint8_t> out;
int val = 0, bits = 0;
for (char c : sv) {
if (c == '=') break;
int idx = b64_index(c);
if (idx < 0) continue;
val = (val << 6) | idx;
bits += 6;
if (bits >= 8) {
bits -= 8;
out.push_back(static_cast<std::uint8_t>((val >> bits) & 0xFF));
val &= (1 << bits) - 1;
}
}
return out;
}
constexpr std::string_view ENCODED = "SGVsbG8=";
constexpr std::size_t DECODED_SIZE = b64_decode(ENCODED).size();
constexpr auto DECODED = [] {
std::array<std::uint8_t, DECODED_SIZE> arr{};
auto v = b64_decode(ENCODED);
std::copy(v.begin(), v.end(), arr.begin());
return arr;
}();
static_assert(DECODED.size() == 5);
static_assert(DECODED[0] == 'H' && DECODED[4] == 'o');
앞에서 본 “크기 계산 → 배열로 복사” 방법을 그대로 적용했습니다. 런타임에는 디코딩 코드가 실행되지 않고 DECODED 배열만 읽기 전용 데이터로 남습니다.
레지스터 맵 검증
// C++20: 레지스터 오프셋이 4바이트 간격으로 정렬되어 있는지 검증
#include <array>
#include <cstddef>
#include <cstdint>
constexpr std::array<std::uint32_t, 4> REG_OFFSETS = {0x00, 0x04, 0x08, 0x0C};
constexpr bool validate_reg_map() {
for (std::size_t i = 1; i < REG_OFFSETS.size(); ++i) {
if (REG_OFFSETS[i] != REG_OFFSETS[i - 1] + 4) return false;
}
return true;
}
static_assert(validate_reg_map(), "register offsets must be contiguous 32-bit registers");
하드웨어 레지스터 맵처럼 사람이 손으로 적는 표는 오타가 나기 쉬운데, 이렇게 검증해 두면 잘못된 맵이 컴파일 단계에서 걸립니다.
런타임 비용과 컴파일 시간 비용
컴파일 타임에 계산되어 constexpr 변수에 저장된 값은 런타임에 다시 계산되지 않습니다. FIB8이나 DECODED 같은 배열은 실행 파일의 읽기 전용 데이터로 들어가고, 프로그램 시작 시 초기화 코드도 실행되지 않습니다.
대신 그 계산 비용은 컴파일 시간으로 옮겨 갑니다. 상수 평가는 컴파일러가 코드를 해석하며 실행하는 방식이라 같은 계산을 기계어로 실행할 때보다 훨씬 느리고, 할당과 해제, 범위 검사까지 하나하나 추적합니다. 큰 문자열을 파싱하거나 수천 개 원소를 정렬하는 constexpr 코드가 헤더에 있으면 그 헤더를 포함하는 모든 번역 단위에서 계산이 반복될 수 있습니다. 데이터가 크다면 런타임 초기화나 빌드 단계의 코드 생성이 더 나은 선택일 수 있습니다.
참고 자료
같이 보면 좋은 글
- constexpr 기초부터 C++26까지: constexpr 함수·생성자, const와의 차이, 확장된 규칙
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++ 컴파일 타임 최적화 | constexpr·PCH·모듈·ccache·Unity 빌드 [#15-3]
자주 묻는 질문 (FAQ)
C++20 constexpr std::vector를 만들었는데 constexpr 변수에 담으면 에러가 나는 이유는 무엇인가요?
C++20의 constexpr 동적 할당은 할당한 메모리를 같은 상수 평가 안에서 모두 해제해야 한다는 규칙이 있어서, new로 잡은 메모리가 런타임까지 살아남을 수 없습니다. 따라서 constexpr std::vector는 constexpr 함수 안에서 만들고 계산에 쓴 뒤, 결과를 std::array 같은 고정 크기 타입으로 옮겨 반환하는 방식으로 사용합니다. constexpr new 후 delete를 빠뜨려도 같은 이유로 컴파일 에러가 나므로, 에러 메시지에 할당이 해제되지 않았다는 내용이 보이면 이 규칙부터 확인하면 됩니다.