constexpr 기초부터 C++26까지: constexpr 함수·생성자, const와의 차이, 확장된 규칙
들어가며
C++에서는 배열 크기, 템플릿 비타입 인자, switch의 case 레이블, static_assert 조건 같은 자리에 상수 식(constant expression)이 와야 합니다. 값이 컴파일 시점에 이미 결정되어 있어야 한다는 뜻입니다. std::array<int, getBufferSize()>처럼 함수 결과를 쓰려고 하면 그 함수가 컴파일 타임에 평가될 수 있어야 하고, 이를 표시하는 것이 constexpr입니다.
constexpr 함수는 상수 식이 필요한 자리에서 호출되면 컴파일 타임에 평가되고, 그 밖의 자리에서는 일반 함수처럼 런타임에 실행될 수 있습니다. C++11에서는 함수 본문에 return 문 하나만 둘 수 있었지만, C++14에서 루프와 지역 변수가 허용되었고, C++20·23·26을 거치며 동적 할당, 가상 함수, 예외까지 상수 평가 안으로 들어오고 있습니다. 이 글은 기본 문법부터 시작해 버전별 확장, 자주 만나는 컴파일 에러, 실무에서 쓰는 패턴을 차례로 다룹니다.
constexpr 함수가 컴파일 타임에 평가되는 조건
flowchart TD
A[constexpr 함수 호출] --> B{"상수 식이 필요한 문맥인가?<br/>(constexpr 변수 초기화, 템플릿 인자, static_assert 등)"}
B -->|예| C["컴파일 타임에 평가<br/>불가능하면 컴파일 에러"]
B -->|아니오| D["런타임 호출<br/>최적화로 미리 계산될 수도 있음"]
흔한 오해는 “인자가 상수면 constexpr 함수는 컴파일 타임에 계산된다”는 것입니다. 표준이 컴파일 타임 평가를 보장하는 것은 결과가 상수 식이어야 하는 문맥뿐입니다. int x = add(3, 5);처럼 평범한 변수를 초기화하는 경우에는 컴파일러가 최적화로 미리 계산할 수도 있고 런타임에 호출할 수도 있습니다. 컴파일 타임 평가를 확실히 하고 싶다면 결과를 constexpr 변수에 담거나, 뒤에서 볼 consteval을 씁니다.
constexpr 변수
컴파일 타임 상수
constexpr 변수는 컴파일 시점에 값이 정해져야 하고, 그래서 배열 크기나 std::array<int, MAX>의 템플릿 인자로 그대로 쓸 수 있습니다. const 변수도 상수 식으로 초기화된 정수형이라면 같은 자리에 쓸 수 있지만, const는 런타임 값으로 초기화되어도 문법상 허용되므로 “이 값은 컴파일 타임 상수다”라는 보장을 주지 못합니다. constexpr은 초기화식이 상수 식이 아니면 그 자리에서 컴파일 에러를 내 줍니다.
constexpr int MAX = 100;
constexpr double PI = 3.14159265358979;
int arr[MAX]; // OK: 상수 식
std::array<int, MAX> a; // OK
constexpr 변수 초기화 규칙
constexpr 변수의 초기화식은 상수 식이어야 합니다. 리터럴, 다른 constexpr 변수, 상수 인자로 호출한 constexpr 함수는 괜찮지만, 런타임 함수 호출 결과나 일반 변수는 쓸 수 없습니다.
constexpr int add(int a, int b) { return a + b; }
int getValue();
constexpr int a = 42; // OK: 리터럴
constexpr int b = a + 1; // OK: 다른 constexpr
constexpr int c = add(2, 3); // OK: add가 constexpr이고 인자가 상수
int runtime_val = getValue();
constexpr int d = runtime_val; // 컴파일 에러: runtime_val은 상수 식이 아님
constexpr 함수
기본 개념
constexpr 함수는 상수 식이 필요한 자리에서 호출할 수 있는 함수입니다. 아래 x는 constexpr 변수이므로 add(3, 5)가 컴파일 타임에 평가되어 8로 고정되고, 같은 함수를 add(i, j)처럼 런타임 값으로 호출하면 일반 함수처럼 동작합니다. 한 함수를 상수 식용과 런타임용으로 함께 쓸 수 있다는 것이 constexpr 함수의 장점입니다.
#include <iostream>
constexpr int add(int a, int b) {
return a + b;
}
int main() {
constexpr int x = add(3, 5); // 컴파일 타임에 8
std::cout << x << "\n";
return 0;
}
팩토리얼 (C++14)
C++14부터 constexpr 함수 안에 if, 여러 return, 지역 변수, 루프를 쓸 수 있습니다. 재귀는 C++11에서도 가능했습니다.
#include <array>
constexpr unsigned long long factorial(int n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
int main() {
constexpr auto f5 = factorial(5); // 120, 컴파일 타임
std::array<int, factorial(5)> arr; // 크기 120
return 0;
}
unsigned long long으로도 factorial(20)까지만 표현할 수 있습니다. factorial(21)을 constexpr 변수에 담으면 부호 없는 정수라 오버플로가 정의되어 있어(나머지 연산) 에러 없이 틀린 값이 나오고, 같은 계산을 long long으로 했다면 부호 있는 오버플로라서 상수 평가가 실패해 컴파일 에러가 납니다. 상수 평가는 미정의 동작을 만나면 거부하기 때문에, 오히려 런타임보다 버그를 일찍 드러내 줍니다.
피보나치와 템플릿 메타프로그래밍 대체
과거에는 template <int N> struct Fib처럼 재귀 템플릿으로 값을 계산했지만, constexpr 함수로 쓰면 읽기와 유지보수가 훨씬 쉽습니다. Fib<N>::value 같은 인터페이스가 필요하면 함수 결과를 정적 멤버에 담으면 됩니다.
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
template <int N>
struct Fib {
static constexpr int value = fib(N);
};
int main() {
constexpr int f10 = fib(10); // 55
constexpr int f20 = Fib<20>::value; // 6765
return 0;
}
이 재귀 버전은 호출 수가 지수적으로 늘어나므로 큰 n에는 쓰면 안 됩니다. 아래 “컴파일 에러” 절에서 다시 다룹니다.
2의 거듭제곱 (루프)
nextPowerOfTwo(100)은 100 이상인 가장 작은 2의 거듭제곱(128)을 반환합니다. 버퍼 크기를 2의 거듭제곱으로 맞춰야 할 때 템플릿 메타프로그래밍 없이 상수를 얻을 수 있습니다.
constexpr size_t nextPowerOfTwo(size_t n) {
size_t p = 1;
while (p < n) p *= 2;
return p;
}
std::array<int, nextPowerOfTwo(100)> buf; // 크기 128
문자열 길이
문자열 리터럴의 길이도 컴파일 타임에 구할 수 있습니다. 실무에서는 직접 만들기보다 std::char_traits<char>::length나 std::string_view{"hello"}.size()(둘 다 C++17부터 constexpr)를 쓰는 편이 간단합니다.
constexpr size_t strLen(const char* s) {
size_t len = 0;
while (s[len] != '\0') ++len;
return len;
}
constexpr size_t LEN = strLen("hello"); // 5
std::array<char, strLen("hello") + 1> buf;
CRC32 룩업 테이블
CRC32는 바이트 값 0~255마다 테이블 항목이 하나씩, 모두 256개가 필요합니다. std::make_index_sequence로 인덱스 묶음을 만들고 각 항목을 constexpr 함수로 계산하면 테이블 전체를 컴파일 타임에 만들 수 있습니다.
#include <array>
#include <cstdint>
#include <cstddef>
#include <utility>
constexpr uint32_t crc32_table_entry(uint32_t idx) {
uint32_t crc = idx;
for (int i = 0; i < 8; ++i) {
crc = (crc >> 1) ^ (0xEDB88320u & -(crc & 1));
}
return crc;
}
template <size_t... Is>
constexpr auto make_crc32_table(std::index_sequence<Is...>) {
return std::array<uint32_t, sizeof...(Is)>{{crc32_table_entry(static_cast<uint32_t>(Is))...}};
}
constexpr auto CRC32_TABLE = make_crc32_table(std::make_index_sequence<256>{});
uint32_t crc32(const uint8_t* data, size_t len) {
uint32_t crc = 0xFFFFFFFFu;
for (size_t i = 0; i < len; ++i) {
crc = CRC32_TABLE[(crc ^ data[i]) & 0xFF] ^ (crc >> 8);
}
return crc ^ 0xFFFFFFFFu;
}
CRC32_TABLE은 컴파일 타임에 계산되어 실행 파일의 읽기 전용 데이터 영역에 들어갑니다. C++17부터는 std::array의 operator[]도 constexpr이므로, index_sequence 없이 constexpr 함수 안에서 std::array를 만들고 for 루프로 채워 반환하는 방식이 더 읽기 쉽습니다.
컴파일 타임 문자열 해시와 switch
문자열 리터럴을 컴파일 타임에 해시해 case 레이블로 쓸 수 있습니다.
#include <cstring>
constexpr unsigned long long hash_fnv1a(const char* str) {
unsigned long long hash = 14695981039346656037ULL;
while (*str) {
hash ^= static_cast<unsigned char>(*str++);
hash *= 1099511628211ULL;
}
return hash;
}
void handle_command(const char* cmd) {
switch (hash_fnv1a(cmd)) {
case hash_fnv1a("start"):
if (std::strcmp(cmd, "start") == 0) { /* 시작 처리 */ }
break;
case hash_fnv1a("stop"):
if (std::strcmp(cmd, "stop") == 0) { /* 중지 처리 */ }
break;
default:
break;
}
}
*str을 unsigned char로 바꾸는 이유는 char가 부호 있는 타입인 플랫폼에서 0x80 이상의 바이트(UTF-8 한글 등)가 음수로 확장되어 표준 FNV-1a와 다른 해시가 나오기 때문입니다. 또 해시가 같다고 문자열이 같은 것은 아니므로, 외부 입력을 분기할 때는 case 안에서 문자열을 한 번 더 비교해야 충돌로 엉뚱한 분기를 타는 일을 막을 수 있습니다. 두 레이블의 해시가 우연히 같다면 duplicate case value 컴파일 에러로 바로 드러납니다.
constexpr 생성자와 리터럴 타입
리터럴 타입
constexpr 변수로 쓰려면 타입이 리터럴 타입(literal type)이어야 합니다. 스칼라 타입(int, double, 포인터 등), 참조, 리터럴 타입의 배열, 그리고 constexpr 생성자(또는 집합체 초기화)로 만들 수 있고 소멸자가 trivial이거나 C++20부터는 constexpr인 클래스가 여기에 속합니다.
constexpr 생성자 예시
struct Point {
int x, y;
constexpr Point(int x, int y) : x(x), y(y) {}
constexpr int sum() const { return x + y; }
};
constexpr Point p(1, 2);
constexpr int s = p.sum(); // 3
constexpr 생성자의 제약
C++11에서는 constexpr 생성자 본문이 사실상 비어 있어야 했지만, C++14부터는 constexpr 함수와 같은 규칙으로 본문에 문장을 쓸 수 있습니다. C++23까지는 가상 기반 클래스를 가진 클래스가 constexpr 생성자를 가질 수 없습니다. C++20 이전에는 모든 멤버를 반드시 초기화해야 했으나, C++20부터는 상수 평가 중에 초기화되지 않은 멤버를 읽지만 않으면 됩니다.
struct Vec3 {
float x, y, z;
constexpr Vec3(float x, float y, float z) : x(x), y(y), z(z) {}
constexpr float lengthSq() const {
return x * x + y * y + z * z;
}
};
constexpr Vec3 v(1.0f, 0.0f, 0.0f);
constexpr float lenSq = v.lengthSq(); // 1.0f
consteval (C++20): 반드시 컴파일 타임에
constexpr 함수는 컴파일 타임과 런타임 모두에서 쓸 수 있습니다. C++20의 consteval 함수(즉시 함수, immediate function)는 모든 호출이 상수 식으로 평가되어야 하고, 그럴 수 없으면 컴파일 에러입니다.
constexpr int add(int a, int b) { return a + b; }
consteval int mul(int a, int b) { return a * b; }
int main() {
int i = 1, j = 2;
int y = add(i, j); // OK: 런타임 호출
constexpr int z = mul(2, 3); // OK: 컴파일 타임
int w = mul(3, 4); // OK: 인자가 상수라 컴파일 타임에 평가되고 결과만 대입됨
// int v = mul(i, j); // 에러: i, j는 상수 식이 아님
}
int w = mul(3, 4);처럼 결과를 평범한 변수에 넣어도 인자가 상수면 문제없습니다. 문자열 리터럴도 상수 식이므로 consteval 해시 함수에 "secret_key"를 넘기는 것은 가능하고, 막히는 것은 런타임에 만들어진 문자열(std::string 변수, 입력값)입니다.
consteval이 맞는 곳은 런타임에 계산되면 곤란한 값입니다. 문자열 해시로 만든 switch 레이블, 포맷 문자열 검사(std::format이 C++20에서 포맷 문자열을 컴파일 타임에 검사하는 방식), 단위 변환 상수처럼 실수로 런타임 계산이 섞이는 것을 막고 싶을 때 씁니다. 반대로 흔한 오해가 “consteval로 만든 값은 바이너리에 노출되지 않는다”는 것입니다. 컴파일 타임에 계산된 결과는 상수로 실행 파일 안에 그대로 들어가므로, 암호화 키나 비밀값을 숨기는 수단이 되지 못합니다. 해시로 바꾼 값이라도 원본 문자열이 짧으면 역산이 가능하므로, 비밀값은 컴파일 타임 기법이 아니라 배포 시점의 설정·비밀 저장소로 다뤄야 합니다.
constinit (C++20)
constinit은 정적 저장 기간(전역, static) 또는 스레드 저장 기간 변수가 컴파일 타임에 초기화됨을 보장합니다. constexpr과 달리 변수를 const로 만들지 않으므로, 프로그램 실행 중에 값을 바꿀 수 있습니다. 다른 번역 단위의 전역 변수 초기화 순서에 따라 값이 달라지는 “정적 초기화 순서 문제(static initialization order fiasco)“를 피하는 데 쓰입니다.
constinit int counter = 0; // 컴파일 타임 초기화 보장, 이후 수정 가능
// constinit int bad = getValue(); // 에러: 초기화식이 상수 식이 아님
constexpr vs const 비교
핵심 차이
| 구분 | const | constexpr |
|---|---|---|
| 의미 | 초기화 후 변경하지 않음 | 컴파일 타임에 값이 정해짐 |
| 초기화 | 런타임 값도 허용 | 상수 식만 허용 |
| 배열 크기·템플릿 인자로 사용 | 상수 식으로 초기화된 정수형만 가능 | 가능 |
구체적 예시
#include <array>
int getValue() { return 42; }
void example() {
const int a = 10; // OK: 리터럴로 초기화
const int b = getValue(); // OK: 런타임 초기화
int arr1[a]; // OK: a는 상수 식으로 초기화된 const int
int arr2[b]; // 표준 C++에서는 에러 (GCC·Clang은 VLA 확장으로 허용하기도 함)
constexpr int c = 10; // OK
constexpr int d = getValue(); // 컴파일 에러: getValue()는 constexpr이 아님
std::array<int, c> arr3; // OK
}
int arr2[b]가 GCC에서 경고 없이 컴파일되는 경우가 있는데, 이는 C99의 가변 길이 배열(VLA)을 확장 기능으로 허용하기 때문입니다. -pedantic-errors를 주면 표준대로 에러가 나고, MSVC는 처음부터 에러를 냅니다. 이식성이 필요한 코드라면 이런 확장에 기대지 않는 것이 좋습니다.
const와 constexpr 조합
constexpr 변수는 암묵적으로 const입니다. 포인터에 constexpr을 붙이면 포인터 자체가 상수가 될 뿐 가리키는 대상까지 const가 되지는 않으므로, 대상도 const여야 한다면 따로 적어야 합니다.
constexpr int x = 42; // 네임스페이스 범위: 주소가 상수 식
constexpr const int* p = &x; // p는 constexpr 포인터, *p도 const
지역 변수(자동 저장 기간)의 주소는 상수 식이 아니므로, 위 코드를 함수 안에서 x를 일반 지역 변수로 두고 쓰면 p 선언이 에러가 납니다. 이때는 x를 static constexpr로 선언해야 합니다.
배열 크기, 템플릿 인자, case 레이블처럼 상수 식이 필요한 값은 constexpr로, 런타임에 한 번 정해진 뒤 바뀌지 않을 값은 const로 선언하는 것이 기본 원칙입니다.
버전별 확장: C++14, C++20, C++23, C++26
C++14
constexpr 함수 안에 지역 변수, 루프, 여러 return 문을 쓸 수 있게 되었습니다. void 반환 타입도 허용되었습니다. 또 C++11에서는 constexpr 멤버 함수가 암묵적으로 const였지만, C++14부터는 그렇지 않아서 상수 평가 중에 생성된 객체의 멤버를 수정할 수 있습니다.
C++20
상수 평가 안에서 가상 함수 호출, try 블록(예외를 실제로 던지는 것은 아직 불가), dynamic_cast와 typeid, union 활성 멤버 변경, 그리고 new/delete를 통한 동적 할당이 허용되었습니다. 덕분에 std::vector와 std::string도 constexpr 함수 안에서 쓸 수 있게 되었습니다. 단, 상수 평가 중에 할당한 메모리는 그 평가가 끝나기 전에 해제되어야 합니다(transient allocation). 그래서 constexpr std::vector<int> v = {1, 2, 3};처럼 할당된 메모리를 상수로 남기는 것은 허용되지 않고, 함수 안에서 벡터로 계산한 뒤 결과를 std::array로 옮겨 반환하는 식으로 써야 합니다. consteval과 constinit도 C++20에서 추가되었습니다.
C++23
if consteval이 추가되었습니다. constexpr 함수 안에서 지금 상수 평가 중인지에 따라 분기하는 문법으로, C++20의 std::is_constant_evaluated()를 대체합니다. if consteval 블록 안에서는 함수 매개변수를 넘겨 consteval 함수를 호출할 수 있습니다.
consteval int checked_div(int a, int b) {
if (b == 0) throw "division by zero"; // 상수 평가 중 throw는 컴파일 에러로 이어짐
return a / b;
}
constexpr int divide(int a, int b) {
if consteval {
return checked_div(a, b); // 컴파일 타임: 0으로 나누면 컴파일 에러
} else {
return b == 0 ? 0 : a / b; // 런타임: 다른 처리
}
}
C++23은 이 밖에도 constexpr 함수 안에 static constexpr 지역 변수를 허용하고, 상수 식을 만들 수 없는 constexpr 함수에 대한 제약을 완화했습니다. std::unique_ptr와 <cmath>의 일부 함수(std::abs, std::floor 등)도 constexpr이 되었습니다.
C++26
C++26에는 상수 평가 범위를 더 넓히는 제안들이 채택되었습니다. void*에서 원래 타입의 포인터로 static_cast하는 것(P2738)이 허용되어 std::any나 타입 소거를 쓰는 코드를 상수 평가 안에서 다룰 여지가 생겼고, placement new(P2747)로 미리 확보한 저장 공간에 객체를 생성할 수 있게 되었습니다. 상수 평가 중에 예외를 던지고 잡는 것(P3068)도 허용되어, 같은 함수 안에서 throw한 예외를 catch하기만 하면 컴파일 타임에도 정상적으로 동작합니다. std::sin 같은 <cmath> 함수 상당수도 C++26에서 constexpr이 됩니다.
가장 큰 변화는 정적 리플렉션(P2996)입니다. ^^T 연산자로 타입이나 멤버를 std::meta::info 값으로 얻고, std::meta::members_of 같은 함수로 정보를 조회한 뒤, [: r :] 스플라이스로 다시 코드에 끼워 넣습니다. 이 모든 작업이 consteval 함수 안에서 일어나므로 리플렉션은 사실상 constexpr의 연장선입니다. 기능마다 컴파일러 지원 시점이 다르므로, 실제로 쓰기 전에 cppreference의 컴파일러 지원 표를 확인하는 것이 좋습니다. 리플렉션 자체는 C++26 리플렉션 기초에서 따로 다룹니다.
if constexpr (C++17)
if constexpr (조건)은 조건이 컴파일 타임 상수일 때, 템플릿 안에서 선택되지 않은 갈래를 인스턴스화하지 않습니다. 아래에서 T가 포인터가 아니면 *x 갈래는 버려지므로, 포인터가 아닌 타입으로 인스턴스화해도 에러가 나지 않습니다.
#include <type_traits>
template <typename T>
auto unwrap(T x) {
if constexpr (std::is_pointer_v<T>)
return *x;
else
return x;
}
버려지는 것은 템플릿 안에서 인스턴스화에 의존하는 코드뿐입니다. 템플릿이 아닌 일반 함수 안의 if constexpr는 양쪽 갈래가 모두 문법·타입 검사를 받습니다.
constexpr에서 자주 만나는 컴파일 에러
에러 1: 할당된 메모리를 constexpr 변수로 남기기
#include <string>
#include <string_view>
// 에러: C++20에서도 std::string 값을 constexpr 변수로 남길 수 없음
// constexpr std::string msg = "hello";
// OK: 문자열 상수는 string_view나 문자 배열로
constexpr std::string_view msg = "hello";
constexpr char msg2[] = "hello";
// OK: C++20에서 std::string은 constexpr 함수 안에서 임시로 사용 가능
constexpr std::size_t len() {
std::string s = "hello";
s += " world";
return s.size();
}
static_assert(len() == 11);
C++20에서 std::string과 std::vector가 constexpr이 되었다는 말은 상수 평가 안에서 쓸 수 있다는 뜻이지, 그 값을 constexpr 변수에 담을 수 있다는 뜻이 아닙니다. 상수 평가 중 할당한 메모리는 평가가 끝나기 전에 해제되어야 하기 때문입니다. 표준 라이브러리 구현이 짧은 문자열을 내부 버퍼에 저장(SSO)하는 경우 일부 컴파일러에서 통과하기도 하지만, 이식성이 없으므로 기대지 않는 것이 좋습니다.
에러 2: 버전에서 허용되지 않는 연산
C++20 이전에는 constexpr 함수 안에서 new, try, 가상 함수 호출을 쓸 수 없었습니다. C++20 이후에도 상수 평가 중에는 reinterpret_cast, 미정의 동작, 할당 후 해제하지 않은 메모리 등이 허용되지 않습니다.
// C++17 이하에서 에러, C++20에서는 new/delete 짝이 맞으면 OK
constexpr int heap_value() {
int* p = new int(42);
int v = *p;
delete p; // 해제하지 않으면 상수 평가 실패
return v;
}
에러 3: 상수 식이 필요한 자리에 런타임 값
인자가 상수 식이 아니면 constexpr 함수도 런타임에 실행됩니다. 이것은 에러가 아니라 의도된 동작이지만, 그 결과를 상수 식이 필요한 자리에 넣으면 에러가 납니다.
constexpr int add(int a, int b) { return a + b; }
int main() {
int x = 3, y = 5;
// constexpr int z = add(x, y); // 에러: x, y는 상수 식이 아님
int w = add(x, y); // OK: 런타임 호출
constexpr int v = add(3, 5); // OK: 상수 인자
return 0;
}
x, y를 const int x = 3;처럼 상수 식으로 초기화된 const 정수로 바꾸면 add(x, y)도 상수 식이 됩니다.
에러 4: 재귀 깊이·평가 단계 한도와 오버플로
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
// constexpr int f50 = fib(50); // 에러
fib(50)이 실패하는 이유는 두 가지입니다. 첫째, 결과 12,586,269,025는 int 범위를 넘으므로 부호 있는 정수 오버플로(미정의 동작)가 일어나고, 상수 평가는 이를 에러로 거부합니다. 둘째, 이 재귀는 호출 횟수가 대략 1.6^n으로 늘어나서 컴파일러의 평가 한도에 걸립니다. 재귀 깊이 자체는 50 정도라 깊이 한도(GCC·Clang 기본 512, -fconstexpr-depth=N으로 조정)보다는 호출 횟수 쪽이 문제이고, Clang은 -fconstexpr-steps, GCC는 -fconstexpr-ops-limit로 이 한도를 정합니다. GCC는 같은 인자의 constexpr 호출 결과를 캐시하기 때문에 Clang보다 이런 재귀를 잘 견디지만, 컴파일러 동작에 기대는 것보다 반복문으로 바꾸고 타입을 넓히는 편이 확실합니다.
constexpr long long fib_iter(int n) {
if (n <= 1) return n;
long long a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
long long next = a + b;
a = b;
b = next;
}
return b;
}
static_assert(fib_iter(50) == 12586269025LL);
에러 5: 상수 식이 될 수 없는 포인터 반환
상수 식으로 쓰이는 포인터는 정적 저장 기간 객체(전역, static)를 가리켜야 합니다. 지역 변수의 주소를 반환하는 것은 런타임에서도 댕글링 포인터이고, 상수 평가에서는 에러입니다. 또 constexpr 함수 안에 static 지역 변수를 두는 것은 C++23 전까지 허용되지 않았습니다.
constexpr int g = 42;
constexpr const int* ok() { return &g; } // OK: 전역 객체의 주소
static_assert(*ok() == 42);
// constexpr const int* bad() {
// int local = 42;
// return &local; // 댕글링 포인터, 상수 평가 에러
// }
에러 6: static_assert 조건에 런타임 값
static_assert의 조건은 상수 식이어야 하므로 함수 매개변수를 쓸 수 없습니다. 값이 컴파일 타임에 정해져야 한다면 템플릿 인자로 받습니다.
// 에러
// void process(int max_size) {
// static_assert(max_size > 0, "max_size must be positive");
// }
template <int MaxSize>
void process() {
static_assert(MaxSize > 0, "MaxSize must be positive");
}
constexpr bool validate_size(int n) { return n > 0; }
static_assert(validate_size(100));
에러 7: C++ 표준 버전 불일치
C++11에서는 constexpr 함수 본문에 return 문 하나만 둘 수 있었습니다. 같은 코드가 -std=c++11에서만 실패한다면 이 차이를 의심합니다.
// C++11에서 에러, C++14부터 OK
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) result *= i;
return result;
}
// C++11 호환: 단일 return + 재귀
constexpr int factorial_cpp11(int n) {
return n <= 1 ? 1 : n * factorial_cpp11(n - 1);
}
에러 8: 상수 평가 중 I/O나 전역 상태 수정
상수 평가 중에는 std::cout 같은 입출력이나, 평가 밖에서 만들어진 전역 변수 수정이 불가능합니다. 이런 코드가 들어 있는 constexpr 함수도 런타임 호출은 가능하지만, 상수 식이 필요한 자리에서 호출하면 그 줄에서 에러가 납니다. C++23 전에는 어떤 인자로도 상수 식이 될 수 없는 constexpr 함수 자체가 잘못된 프로그램(진단 불필요)이었는데, C++23에서 이 규칙이 완화되었습니다.
#include <iostream>
int global = 0;
constexpr int log_and_double(int x) {
if (x < 0) {
std::cout << "negative\n"; // 이 경로를 상수 평가에서 타면 에러
}
return x * 2;
}
constexpr int a = log_and_double(21); // OK: cout 경로를 타지 않음
// constexpr int b = log_and_double(-1); // 에러: 상수 평가 중 I/O
컴파일 타임 계산은 런타임에서 무엇을 아끼나
constexpr로 미리 계산한 값은 실행 파일 안에 상수로 들어가므로, 런타임에는 그 값을 읽는 비용만 남습니다. 예를 들어 factorial(15)를 런타임에 재귀로 계산하면 호출 15번과 곱셈 14번이 필요하지만, constexpr 변수로 미리 계산해 두면 상수 하나를 읽는 것으로 끝납니다. 다만 이 차이가 의미 있으려면 같은 계산이 반복적으로 일어나야 합니다. 프로그램에서 한 번만 쓰는 상수라면 차이를 체감하기 어렵고, 최적화를 켠 컴파일러는 constexpr이 아닌 함수도 인자가 상수면 미리 계산해 버리는 경우가 많습니다. 그래서 둘을 비교하는 벤치마크를 만들 때 인자를 volatile 변수로 숨기지 않으면 두 방식이 같은 시간으로 나오기도 합니다.
CRC32 테이블도 마찬가지입니다. 런타임에 테이블을 초기화하면 256개 항목 × 8번의 비트 연산, 즉 약 2,000번의 연산이 프로그램 시작 시 한 번 일어납니다. 현대 CPU에서는 무시할 만한 비용입니다. constexpr 테이블의 실질적인 이점은 속도보다 다른 데 있습니다. 초기화 함수를 호출하는 것을 잊을 일이 없고, 다른 전역 객체의 생성자가 테이블을 먼저 쓰려다 초기화 전의 0을 읽는 정적 초기화 순서 문제가 생기지 않으며, 테이블이 읽기 전용 영역에 놓여 실수로 덮어쓸 수도 없습니다. 잘못된 값이 static_assert로 컴파일 단계에서 드러난다는 점도 큽니다.
버퍼 크기를 constexpr로 정해 std::array를 쓰면 힙 할당 없이 스택이나 정적 영역에 버퍼가 놓입니다. 런타임 크기의 std::vector와 비교하면 할당·해제 비용과 포인터를 한 번 거치는 간접 참조가 없어집니다. 대신 스택에 큰 std::array를 두면 스택 오버플로 위험이 있으므로, 수십 KB 이상이라면 static이나 전역으로 두는 편이 안전합니다.
반대로 컴파일 시간은 늘어납니다. 큰 테이블을 헤더에서 constexpr로 만들면 그 헤더를 포함하는 모든 번역 단위에서 다시 계산될 수 있으므로, 테이블이 크다면 한 .cpp 파일에서만 정의하고 extern으로 노출하거나 C++17의 inline constexpr 변수로 두는 방식을 검토합니다. 빌드가 느려졌다면 Clang의 -ftime-trace로 어떤 상수 평가가 오래 걸리는지 확인할 수 있습니다.
룩업 테이블, 설정 파싱, 타입 이름: constexpr가 실무에 들어가는 자리
컴파일 타임 룩업 테이블
CRC32뿐 아니라 패리티, 비트 카운트, 인코딩 테이블도 같은 방식으로 만듭니다. 아래는 C++17 이후라면 더 읽기 쉬운 루프 방식입니다.
#include <array>
#include <cstdint>
constexpr int parity(uint8_t n) {
int p = 0;
while (n) {
p ^= (n & 1);
n >>= 1;
}
return p;
}
constexpr auto make_parity_table() {
std::array<int, 256> t{};
for (int i = 0; i < 256; ++i) t[i] = parity(static_cast<uint8_t>(i));
return t;
}
constexpr auto PARITY_TABLE = make_parity_table();
static_assert(PARITY_TABLE[3] == 0 && PARITY_TABLE[7] == 1);
삼각함수 테이블도 같은 방식으로 만들고 싶지만, std::sin은 C++26 전까지 표준상 constexpr이 아닙니다. GCC는 내장 함수로 상수 평가를 허용하기도 하지만 Clang·MSVC에서는 에러가 나므로, 그 전까지는 테일러 급수 같은 근사식을 직접 constexpr로 구현하거나 테이블을 외부 스크립트로 생성해 소스에 넣는 방법을 씁니다.
설정값 컴파일 타임 파싱
문자열 리터럴로 된 설정값을 컴파일 타임에 숫자로 바꿀 수 있습니다.
constexpr int parseSize(const char* s) {
int result = 0;
while (*s >= '0' && *s <= '9') {
result = result * 10 + (*s - '0');
++s;
}
return result;
}
constexpr int BUF_SIZE = parseSize("4096"); // 4096
std::array<char, BUF_SIZE> buffer;
이 파서는 숫자가 아닌 문자를 만나면 조용히 멈추므로, parseSize("4k")는 4가 됩니다. 컴파일 타임 파서는 잘못된 입력에서 throw(상수 평가에서는 컴파일 에러가 됨)하도록 만들어 두면 설정 실수를 빌드 단계에서 잡을 수 있습니다.
타입별 상수
템플릿 특수화와 constexpr 정적 멤버를 조합하면 타입마다 다른 상수를 줄 수 있습니다. 숫자를 문자열로 바꿀 버퍼 크기라면 직접 숫자를 적기보다 std::numeric_limits에서 계산하는 편이 플랫폼 차이에 안전합니다.
#include <array>
#include <limits>
template <typename T>
constexpr std::size_t max_decimal_chars =
std::numeric_limits<T>::digits10 + 1 // 최대 자릿수
+ (std::numeric_limits<T>::is_signed ? 1 : 0) // 부호
+ 1; // 널 문자
std::array<char, max_decimal_chars<int>> int_buf; // 32비트 int: 10 + 1 + 1 = 12
std::array<char, max_decimal_chars<long long>> ll_buf; // 19 + 1 + 1 = 21
digits10은 “손실 없이 표현할 수 있는 십진 자릿수”라서 32비트 int는 9입니다. 실제 최대 자릿수(2147483647은 10자리)를 얻으려면 1을 더해야 하고, 음수 부호와 널 문자 자리도 따로 더해야 합니다.
프로토콜 버퍼 크기 조합
헤더와 페이로드 크기를 따로 정의하고 합계를 상수로 두면, 한쪽을 바꿨을 때 버퍼 크기가 자동으로 따라갑니다.
#include <array>
#include <cstdint>
constexpr std::size_t PACKET_HEADER_SIZE = 16;
constexpr std::size_t MAX_PAYLOAD_SIZE = 4096;
constexpr std::size_t PACKET_BUFFER_SIZE = PACKET_HEADER_SIZE + MAX_PAYLOAD_SIZE;
std::array<std::uint8_t, PACKET_BUFFER_SIZE> packet_buffer;
static_assert(PACKET_BUFFER_SIZE <= 65507, "IPv4 UDP 페이로드 최대 크기(65,507바이트) 초과");
컴파일 타임 타입 이름
GCC·Clang의 __PRETTY_FUNCTION__, MSVC의 __FUNCSIG__는 함수 시그니처를 담은 문자열이라, 템플릿 함수 안에서 쓰면 템플릿 인자 이름이 들어갑니다. 이 식별자는 함수 본문 안에서만 정의되므로 클래스 멤버 초기화식이 아니라 함수 안에서 써야 합니다.
#include <string_view>
template <typename T>
constexpr std::string_view raw_type_name() {
#if defined(_MSC_VER)
return __FUNCSIG__;
#else
return __PRETTY_FUNCTION__;
#endif
}
// raw_type_name<int>()에는 "... [with T = int]" 같은 문자열이 들어 있음
반환되는 문자열 형식은 컴파일러와 버전마다 달라서, 실제 타입 이름만 잘라 내려면 컴파일러별 접두사·접미사 처리가 필요합니다. magic_enum 같은 라이브러리가 이 기법을 쓰며, C++26 리플렉션이 지원되면 std::meta::display_string_of(^^T)처럼 표준 방법으로 대체할 수 있습니다. 로그나 디버깅에는 유용하지만, 컴파일러를 바꾸면 값이 달라지므로 직렬화 포맷의 식별자로 저장하는 것은 피해야 합니다.
static_assert로 설정값 검증
설정값이 유효한 범위인지 컴파일 타임에 확인해 두면, 누군가 값을 잘못 바꿨을 때 빌드가 바로 실패합니다.
constexpr int MAX_CONNECTIONS = 1024;
constexpr int MIN_CONNECTIONS = 1;
constexpr bool valid_connections(int n) {
return n >= MIN_CONNECTIONS && n <= MAX_CONNECTIONS;
}
constexpr int DEFAULT_CONNECTIONS = 512;
static_assert(valid_connections(DEFAULT_CONNECTIONS), "DEFAULT_CONNECTIONS out of range");
참고 자료
같이 보면 좋은 글
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
- C++ type traits 활용
다음 글: [C++ 실전 가이드 #26-2] 컴파일 타임 프로그래밍 기법: 템플릿 메타와 constexpr 조합 이전 글: [C++ 실전 가이드 #25-3] 커스텀 Range 작성: range 개념을 만족하는 타입 만들기