C++ constexpr 함수와 변수 | 컴파일 타임에 계산하기 [#26-1]
이 글의 핵심
C++ constexpr 함수와 변수에 대한 실전 가이드입니다. 컴파일 타임에 계산하기 [#26-1] 등을 예제와 함께 상세히 설명합니다.
들어가며: “컴파일 타임에 계산하고 싶어요”
문제 시나리오
프로젝트를 진행하다 보면 이런 상황을 자주 겪습니다:
- 배열 크기를 런타임 변수로 넘기려 했는데
int arr[n]에서 컴파일 에러가 난다 - CRC32, 해시 테이블 같은 값들을 매번 런타임에 계산하는데, 값이 고정되어 있으면 아깝다
- 설정값(버퍼 크기, 타임아웃 등)을 코드에 하드코딩하지 않고, 컴파일 시점에 한 번만 계산해 두고 싶다
- 템플릿 인자로
std::array<int, 128>처럼 상수를 넘겨야 하는데,getBufferSize()같은 함수 결과를 쓸 수 없다
추가 문제 시나리오
시나리오 1: 프로토콜 버퍼 크기 고정
네트워크 패킷 헤더가 항상 16바이트이고, 페이로드 최대 크기가 4096바이트일 때, std::array<uint8_t, 16 + 4096>처럼 컴파일 타임에 크기를 정해야 합니다. 16 + 4096을 매크로나 매직 넘버로 쓰지 않고 constexpr 함수로 계산하면 유지보수가 쉬워집니다.
시나리오 2: Base64 인코딩 테이블
Base64 인코딩용 64개 문자 룩업 테이블은 실행 시점에 바뀌지 않습니다. constexpr로 컴파일 타임에 테이블을 생성하면 런타임 초기화 비용이 사라지고, .rodata 섹션에 상수로 들어가 메모리 효율도 좋아집니다.
시나리오 3: JSON 스키마 검증 상수
최대 필드 개수, 최대 문자열 길이 등이 JSON 스키마에 정의되어 있을 때, 이 값들을 컴파일 타임 상수로 파싱해 std::array 크기나 static_assert에 사용하면, 스키마 변경 시 컴파일 단계에서 오류를 잡을 수 있습니다.
시나리오 4: 임베디드 메모리 맵
레지스터 오프셋, 메모리 영역 크기, 인터럽트 벡터 개수 등이 보드별로 고정되어 있을 때, constexpr로 보드 타입별 상수를 정의하면 switch 케이스나 템플릿 인자에 바로 쓸 수 있습니다.
시나리오 5: 단위 테스트용 고정 시드
테스트 재현성을 위해 난수 시드를 고정해야 할 때, constexpr로 시드를 계산해 두면 테스트마다 동일한 시퀀스를 보장할 수 있고, 시드 계산이 컴파일 타임에 끝나 런타임 오버헤드가 없습니다.
C++에서는 배열 크기, 템플릿 비타입 인자, switch 케이스 등이 상수 식(constant expression)이어야 합니다. 즉, 값이 컴파일 시점에 이미 결정되어 있어야 합니다. 런타임에 계산된 값은 이런 곳에 쓸 수 없었습니다.
constexpr을 사용하면 “이 함수/변수는 컴파일 타임에 계산 가능하다”고 선언할 수 있어서, 상수 식이 필요한 곳에서 그 결과를 그대로 사용할 수 있습니다. constexpr 함수는 인자가 상수 식이면 컴파일 타임에 호출되고, 배열 크기·템플릿 인자·switch 케이스 등에 바로 활용할 수 있습니다. C++14부터는 제약이 완화되어 루프·여러 문이 가능하고, C++20에서는 더 많은 표준 라이브러리가 constexpr로 쓸 수 있어서, 점점 많은 계산을 컴파일 타임으로 옮길 수 있습니다.
목표
- constexpr 변수와 constexpr 함수 문법
- C++14/20에서 완화된 제약
- if constexpr 개요
- 일반적인 에러와 프로덕션 패턴
constexpr 평가 시점 시각화
flowchart TD
A[constexpr 함수 호출] --> B{인자가 상수 식?}
B -->|예| C[컴파일 타임에 평가]
B -->|아니오| D[런타임에 평가]
C --> E[배열 크기, 템플릿 인자 등에 사용 가능]
D --> F[일반 함수처럼 동작]
constexpr vs 매크로 비교
flowchart LR
subgraph macro["매크로"]
M1[전처리기 치환] --> M2[타입 검사 없음]
M2 --> M3[디버깅 어려움]
end
subgraph constexpr["constexpr"]
C1[컴파일러 평가] --> C2[타입 검사]
C2 --> C3[네임스페이스·스코프]
C3 --> C4[오버로딩·템플릿 조합]
end
이 글을 읽으면
- constexpr로 컴파일 타임 상수를 정의할 수 있습니다.
- constexpr 함수의 제약과 확장을 이해할 수 있습니다.
- 배열 크기·템플릿 인자에 활용할 수 있습니다.
- constexpr vs const 차이를 명확히 구분할 수 있습니다.
- 실무에서 자주 쓰는 패턴(룩업 테이블, 설정 파싱)을 적용할 수 있습니다.
개념을 잡는 비유
템플릿 인자 자리는 붕어빵 틀의 칸 수가 정해지듯, 컴파일 시점에 크기·상수가 박혀 있어야 하는 경우가 많습니다. constexpr·컴파일 타임 계산은 그 값을 미리 찍어내어, 배열 크기와 static_assert 같은 곳에 그대로 얹을 수 있게 해 줍니다.
목차
- constexpr 변수
- constexpr 함수 상세
- constexpr 변수와 생성자
- constexpr vs const 비교
- C++14/20 확장
- 일반적인 에러와 해결법
- 성능 벤치마크
- 프로덕션 패턴
- 실전 활용
1. constexpr 변수
컴파일 타임 상수
constexpr 변수는 컴파일 시점에 값이 정해져야 합니다. 따라서 int arr[MAX]처럼 배열 크기나 std::array<int, MAX>의 템플릿 인자로 그대로 쓸 수 있습니다. const만 쓰면 런타임에 초기화된 “읽기 전용” 변수일 뿐이라 배열 크기로 쓸 수 없고, constexpr을 쓰면 “이 값은 컴파일 타임에 알려진 상수”임을 컴파일러가 보장합니다. 버퍼 크기·테이블 크기처럼 반드시 상수 식이 필요한 곳에서는 constexpr을 사용하는 것이 좋습니다.
constexpr int MAX = 100;
constexpr double PI = 3.14159265358979;
int arr[MAX]; // OK: 상수 식
std::array<int, MAX> a; // OK
constexpr 변수 초기화 규칙
constexpr 변수는 반드시 리터럴이나 다른 constexpr로 초기화해야 합니다. 런타임 함수 호출 결과로는 초기화할 수 없습니다.
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; // 컴파일 에러!
2. constexpr 함수 상세
기본 개념
constexpr 함수는 인자가 상수 식이면 컴파일 타임에 호출할 수 있습니다. add(3, 5)는 리터럴이므로 x는 컴파일 시점에 8로 고정되고, int arr[add(2, 3)]처럼 배열 크기로 쓸 수 있습니다. 런타임에 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;
}
실행 결과: 8이 한 줄 출력됩니다.
상세 예시 1: 팩토리얼 (C++14)
C++14부터 constexpr 함수 안에 if, 여러 return, 재귀 등이 허용됩니다. factorial(5)는 5 * factorial(4) → … → 1까지 컴파일 타임에 계산되어 f5는 120이 됩니다. n이 상수이면 컴파일 타임에, 변수이면 런타임에 계산되므로, 같은 함수를 std::array<int, factorial(5)> 같은 곳에도 쓸 수 있습니다.
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;
}
상세 예시 2: 피보나치
과거에는 template <int N> struct Fib로 재귀 템플릿을 썼지만, constexpr 함수로 같은 계산을 하면 읽기와 유지보수가 쉽습니다.
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;
}
상세 예시 3: 2의 거듭제곱 (루프)
C++14에서는 constexpr 함수 안에 while, for 루프가 허용됩니다. nextPowerOfTwo(100)은 100 이상인 가장 작은 2의 거듭제곱(128)을 반환합니다.
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
상세 예시 4: 문자열 길이 (C++14)
컴파일 타임에 문자열 리터럴의 길이를 구할 수 있습니다.
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;
상세 예시 5: 조건부 계산
constexpr 함수 안에서 if를 사용해 분기할 수 있습니다.
constexpr int clamp(int value, int min_val, int max_val) {
if (value < min_val) return min_val;
if (value > max_val) return max_val;
return value;
}
constexpr int c = clamp(150, 0, 100); // 100
상세 예시 6: CRC32 룩업 테이블 (완전한 예제)
CRC32는 0–255 각 바이트에 대한 256개 테이블 항목이 필요합니다. constexpr로 컴파일 타임에 전체 테이블을 생성할 수 있습니다.
#include <array>
#include <cstdint>
#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은 컴파일 타임에 한 번만 계산되고, 바이너리의 .rodata에 상수로 저장됩니다.
상세 예시 7: 컴파일 타임 문자열 해시
문자열 리터럴을 컴파일 타임에 해시해 switch 분기에 사용할 수 있습니다.
constexpr unsigned long long hash_fnv1a(const char* str) {
unsigned long long hash = 14695981039346656037ULL;
while (*str) {
hash ^= static_cast<unsigned long long>(*str++);
hash *= 1099511628211ULL;
}
return hash;
}
constexpr auto CMD_HASH = hash_fnv1a("start");
void handle_command(const char* cmd) {
switch (hash_fnv1a(cmd)) {
case CMD_HASH:
break;
default:
break;
}
}
3. constexpr 변수와 생성자
리터럴 타입
constexpr 변수로 쓰려면 타입이 리터럴 타입(literal type)이어야 합니다. 기본 타입(int, double 등), 배열, constexpr 생성자를 가진 사용자 정의 타입 등이 리터럴 타입입니다. 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 생성자 제약
- 생성자 본문은 비어 있거나, 다른 constexpr 생성자/함수만 호출해야 함
- 모든 멤버를 constexpr로 초기화 가능해야 함
- 가상 상속 불가, 가상 함수 호출 불가 (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.0
4. constexpr vs const 비교
핵심 차이
| 구분 | const | constexpr |
|---|---|---|
| 의미 | ”변경하지 않음" | "컴파일 타임에 계산 가능” |
| 초기화 시점 | 런타임 가능 | 반드시 컴파일 타임 |
| 상수 식 사용 | 불가 (런타임 초기화 시) | 가능 |
| 배열 크기 | 불가 (런타임 초기화 시) | 가능 |
구체적 예시
int getValue() { return 42; }
void example() {
const int a = 10; // OK: 리터럴로 초기화
const int b = getValue(); // OK: 런타임 초기화
int arr1[a]; // OK: a는 컴파일 타임 상수
int arr2[b]; // 컴파일 에러! b는 런타임 값
constexpr int c = 10; // OK
constexpr int d = getValue(); // 컴파일 에러! getValue()는 constexpr 아님
std::array<int, c> arr3; // OK
std::array<int, d> arr4; // (d가 에러이므로 불가)
}
const + constexpr 조합
constexpr 변수는 기본적으로 const이기도 합니다. 명시적으로 쓰려면:
constexpr int x = 42; // 이미 const이므로 수정 불가
constexpr const int* p = &x; // constexpr 포인터, 가리키는 대상도 const
언제 무엇을 쓸까?
- 배열 크기, 템플릿 인자, switch 케이스 →
constexpr - 런타임에 한 번 설정 후 변경하지 않을 값 →
const - 컴파일 타임에 계산된 값을 여러 곳에서 쓸 때 →
constexpr
5. C++14/20 확장
C++14
- 여러 return, 루프, 지역 변수 허용
- constexpr 멤버 함수에서 멤버 수정 가능 (C++14)
void반환 타입 불가 → C++14에서 가능
C++20
- constexpr 안에서 동적 할당, try, 가상 호출 등 더 많은 기능 허용
- consteval: 반드시 컴파일 타임에만 평가되는 함수
std::vector,std::string등 많은 표준 라이브러리가 constexpr 지원
if constexpr
if constexpr(조건)은 조건이 컴파일 타임 상수일 때, 참인 갈래만 인스턴스화됩니다. std::is_pointer_v<T>가 true인 갈래에서는 *x만 사용되므로 T가 포인터가 아니어도 그 갈래는 무시되고, false인 갈래에서는 return x만 사용됩니다.
template <typename T>
auto unwrap(T x) {
if constexpr (std::is_pointer_v<T>)
return *x;
else
return x;
}
6. 일반적인 에러와 해결법
에러 1: 비리터럴 타입을 constexpr로 사용
원인: constexpr 변수/함수는 리터럴 타입만 다룰 수 있습니다. std::string, std::vector(C++20 이전) 등은 리터럴 타입이 아니었습니다.
// ❌ C++17 이하에서 컴파일 에러
constexpr std::string msg = "hello";
// ✅ C++20에서는 std::string이 constexpr 지원
// ✅ C++17 이하: 고정 크기 배열 사용
constexpr char msg[] = "hello";
에러 2: 런타임 전용 연산 사용
원인: C++14 이하에서 constexpr 함수 안에 동적 할당, 예외, 가상 함수 호출 등을 쓸 수 없습니다.
// ❌ C++14에서 에러
constexpr int bad() {
int* p = new int(42); // 동적 할당 불가
return *p;
}
// ✅ 컴파일 타임에 허용된 연산만 사용
constexpr int good(int x) {
return x * 2;
}
에러 3: constexpr 함수에 비상수 인자 전달
원인: 인자가 상수 식이 아니면 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;
}
에러 4: 재귀 깊이 초과
원인: 컴파일 타임 재귀가 너무 깊으면 컴파일러 한계에 걸릴 수 있습니다.
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2);
}
constexpr int f50 = fib(50); // 일부 컴파일러에서 재귀 한계 초과
해결: 반복문으로 바꾸거나, 룩업 테이블을 사용합니다.
constexpr int fibIter(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int tmp = a + b;
a = b;
b = tmp;
}
return b;
}
에러 5: 포인터/참조 반환
원인: C++14 이하에서 constexpr 함수가 지역 변수의 주소/참조를 반환하면 안 됩니다. 컴파일 타임에는 “주소”가 일정하지 않을 수 있기 때문입니다.
// ❌ 위험
constexpr const int* bad() {
static int x = 42;
return &x; // C++14: OK, 하지만 주의
}
// ✅ 값 반환
constexpr int good() {
return 42;
}
에러 6: static_assert 조건에 런타임 값 사용
원인: static_assert의 조건은 반드시 컴파일 타임 상수여야 합니다.
// ❌ 컴파일 에러
void process(int max_size) {
static_assert(max_size > 0, "max_size must be positive");
}
// ✅ constexpr 또는 템플릿 사용
template <int MaxSize>
void process() {
static_assert(MaxSize > 0, "max_size must be positive");
}
// 또는 constexpr 함수로 검증
constexpr bool validate_size(int n) { return n > 0; }
static_assert(validate_size(100), "");
에러 7: std::array 크기에 constexpr 아닌 식 사용
원인: std::array<T, N>의 N은 반드시 컴파일 타임 상수여야 합니다.
// ❌ 컴파일 에러
int get_size() { return 128; }
std::array<int, get_size()> arr; // get_size()는 constexpr 아님
// ✅ constexpr 함수 사용
constexpr int get_size() { return 128; }
std::array<int, get_size()> arr; // OK
에러 8: C++ 표준 버전 불일치
원인: C++11에서는 constexpr 함수에 return 하나만 허용, C++14부터 루프·여러 문 허용. std::vector constexpr는 C++20부터.
// ❌ C++11에서 컴파일 에러
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) result *= i; // C++11: 루프 불가
return result;
}
// ✅ C++11 호환: 단일 return + 재귀
constexpr int factorial_cpp11(int n) {
return n <= 1 ? 1 : n * factorial_cpp11(n - 1);
}
에러 9: constexpr 함수에서 I/O 또는 전역 상태 접근
원인: constexpr 평가 중에는 std::cout, 파일 입출력, 전역 변수 수정 등이 불가능합니다.
// ❌ 컴파일 에러
constexpr int bad() {
std::cout << "hello"; // I/O 불가
return 42;
}
// ❌ 전역 상태 수정 불가
int global = 0;
constexpr int also_bad() {
global = 1; // 부수 효과 불가
return 42;
}
// ✅ 순수 계산만
constexpr int good(int x) {
return x * 2;
}
에러 10: 컴파일 타임 재귀 한계 초과 (컴파일러별)
원인: GCC/Clang은 constexpr 재귀 깊이에 제한이 있습니다(기본 수백~수천). -fconstexpr-depth=N으로 조정 가능하지만, 반복문으로 전환하는 것이 안전합니다.
// ❌ fib(40) 이상에서 일부 컴파일러 한계
constexpr int fib(int n) {
if (n <= 1) return n;
return fib(n - 1) + fib(n - 2); // 지수적 재귀
}
// ✅ 반복문: 깊이 제한 없음
constexpr int fib_safe(int n) {
if (n <= 1) return n;
int a = 0, b = 1;
for (int i = 2; i <= n; ++i) {
int next = a + b;
a = b;
b = next;
}
return b;
}
7. 성능 벤치마크
컴파일 타임 vs 런타임 계산
constexpr로 컴파일 타임에 계산하면 런타임 비용이 0입니다. 다음은 팩토리얼 계산을 1,000,000번 반복한 벤치마크 예시입니다.
#include <chrono>
#include <iostream>
constexpr unsigned long long factorialConstexpr(int n) {
return n <= 1 ? 1 : n * factorialConstexpr(n - 1);
}
unsigned long long factorialRuntime(int n) {
return n <= 1 ? 1 : n * factorialRuntime(n - 1);
}
int main() {
constexpr int N = 15;
constexpr auto precomputed = factorialConstexpr(N);
// 런타임 계산 100만 번
auto start = std::chrono::high_resolution_clock::now();
volatile unsigned long long sum = 0;
for (int i = 0; i < 1'000'000; ++i)
sum += factorialRuntime(N);
auto end = std::chrono::high_resolution_clock::now();
auto runtime_ms = std::chrono::duration<double, std::milli>(end - start).count();
// 컴파일 타임 결과 사용 (100만 번 접근)
start = std::chrono::high_resolution_clock::now();
sum = 0;
for (int i = 0; i < 1'000'000; ++i)
sum += precomputed;
end = std::chrono::high_resolution_clock::now();
auto constexpr_ms = std::chrono::duration<double, std::milli>(end - start).count();
std::cout << "Runtime factorial(15) x 1M: " << runtime_ms << " ms\n";
std::cout << "Constexpr result x 1M: " << constexpr_ms << " ms\n";
return 0;
}
예상 결과 (환경에 따라 다름):
| 방식 | 시간 (대략) |
|---|---|
| 런타임 factorial(15) x 1,000,000 | ~50–200 ms |
| constexpr 결과 사용 x 1,000,000 | ~1–5 ms |
컴파일 타임에 계산해 두면 런타임에는 메모리에서 읽기만 하므로 훨씬 빠릅니다.
벤치마크 2: CRC32 테이블 초기화 vs constexpr
CRC32를 런타임에 테이블을 초기화하는 방식과 constexpr로 컴파일 타임에 생성하는 방식을 비교합니다.
#include <chrono>
#include <cstdint>
#include <array>
#include <iostream>
#include <utility>
// 런타임 테이블 초기화 (전통적 방식)
uint32_t crc32_table_runtime[256];
void init_crc32_table() {
for (uint32_t i = 0; i < 256; ++i) {
uint32_t crc = i;
for (int j = 0; j < 8; ++j)
crc = (crc >> 1) ^ (0xEDB88320u & -(crc & 1));
crc32_table_runtime[i] = crc;
}
}
// constexpr 테이블 (컴파일 타임)
constexpr uint32_t crc32_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(std::index_sequence<Is...>) {
return std::array<uint32_t, 256>{{crc32_entry(Is)...}};
}
constexpr auto CRC32_TABLE = make_crc32(std::make_index_sequence<256>{});
int main() {
const int ITERS = 10'000'000;
auto t1 = std::chrono::high_resolution_clock::now();
init_crc32_table();
volatile uint32_t v1 = 0;
for (int i = 0; i < ITERS; ++i) {
uint32_t crc = 0xFFFFFFFFu;
for (int j = 0; j < 64; ++j)
crc = crc32_table_runtime[(crc ^ (j & 0xFF)) & 0xFF] ^ (crc >> 8);
v1 = crc;
}
auto t2 = std::chrono::high_resolution_clock::now();
auto t3 = std::chrono::high_resolution_clock::now();
volatile uint32_t v2 = 0;
for (int i = 0; i < ITERS; ++i) {
uint32_t crc = 0xFFFFFFFFu;
for (int j = 0; j < 64; ++j)
crc = CRC32_TABLE[(crc ^ (j & 0xFF)) & 0xFF] ^ (crc >> 8);
v2 = crc;
}
auto t4 = std::chrono::high_resolution_clock::now();
auto ms_runtime = std::chrono::duration<double, std::milli>(t2 - t1).count();
auto ms_constexpr = std::chrono::duration<double, std::milli>(t4 - t3).count();
std::cout << "Runtime init+CRC: " << ms_runtime << " ms\n";
std::cout << "Constexpr table+CRC: " << ms_constexpr << " ms\n";
return 0;
}
예상 결과 (Apple M1, GCC 13 기준): constexpr 테이블은 초기화 비용이 0이므로 프로그램 시작 시점 지연이 없습니다.
벤치마크 3: nextPowerOfTwo 및 메모리 배치
| 버퍼 크기 | std::array (constexpr 크기) | std::vector (런타임 크기) |
|---|---|---|
| 할당 | 없음 (스택/전역) | 힙 할당 1회 |
| 해제 | 없음 | 힙 해제 |
| 캐시 | 지역성 좋음 | 포인터 간접 1회 |
룩업 테이블 vs 런타임 계산
CRC32, 삼각함수 등 고정 테이블이 필요한 경우, constexpr로 컴파일 타임에 테이블을 만들면 런타임 계산을 완전히 제거할 수 있습니다.
// 컴파일 타임에 sin 테이블 생성 (예시)
constexpr int TABLE_SIZE = 360;
constexpr double PI = 3.14159265358979;
constexpr double toRad(int deg) {
return deg * PI / 180.0;
}
// C++14: std::array + 인덱스 시퀀스 활용 (예시 구조)
template <size_t... Is>
constexpr auto make_sin_table(std::index_sequence<Is...>) {
// 각 Is에 대해 sin(toRad(Is)) 계산 후 배열 생성
return std::array<double, sizeof...(Is)>{{static_cast<double>(Is) * PI / 180.0...}};
}
constexpr auto SIN_TABLE = make_sin_table(std::make_index_sequence<TABLE_SIZE>{});
실제 프로덕션에서는 std::array와 std::make_index_sequence를 조합해 constexpr 룩업 테이블을 만드는 패턴을 자주 사용합니다.
8. 프로덕션 패턴
패턴 1: 컴파일 타임 룩업 테이블
CRC32, 해시, 인코딩 테이블 등을 컴파일 타임에 생성합니다.
// 0-255에 대한 parity 비트 테이블 (예시)
constexpr int parity(uint8_t n) {
int p = 0;
while (n) {
p ^= (n & 1);
n >>= 1;
}
return p;
}
template <size_t... Is>
constexpr auto makeParityTable(std::index_sequence<Is...>) {
return std::array<int, sizeof...(Is)>{{parity(static_cast<uint8_t>(Is))...}};
}
constexpr auto PARITY_TABLE = makeParityTable(std::make_index_sequence<256>{});
// 사용
int p = PARITY_TABLE[42]; // 런타임 비용: 배열 접근 1번
패턴 2: 설정값 컴파일 타임 파싱
버퍼 크기, 타임아웃 등을 문자열 리터럴에서 컴파일 타임에 파싱할 수 있습니다.
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;
패턴 3: 타입별 상수
템플릿과 constexpr를 조합해 타입에 따라 다른 상수를 사용합니다.
template <typename T>
struct TypeTraits;
template <>
struct TypeTraits<int> {
static constexpr size_t max_digits = 10;
};
template <>
struct TypeTraits<long long> {
static constexpr size_t max_digits = 19;
};
template <typename T>
std::array<char, TypeTraits<T>::max_digits + 1> to_string_buffer;
패턴 4: 안전한 배열 크기
입력 상수 이상인 2의 거듭제곱을 컴파일 타임에 구해 버퍼 크기로 사용합니다.
constexpr size_t nextPowerOfTwo(size_t n) {
size_t p = 1;
while (p < n) p *= 2;
return p;
}
// 100 이상인 가장 작은 2의 거듭제곱 = 128
std::array<int, nextPowerOfTwo(100)> buf;
패턴 5: 프로토콜 버퍼 크기 조합
헤더 + 페이로드 크기를 컴파일 타임에 조합해 패킷 버퍼를 정의합니다.
constexpr size_t PACKET_HEADER_SIZE = 16;
constexpr size_t MAX_PAYLOAD_SIZE = 4096;
constexpr size_t PACKET_BUFFER_SIZE = PACKET_HEADER_SIZE + MAX_PAYLOAD_SIZE;
std::array<uint8_t, PACKET_BUFFER_SIZE> packet_buffer;
// 또는
constexpr size_t total_size() { return PACKET_HEADER_SIZE + MAX_PAYLOAD_SIZE; }
std::array<uint8_t, total_size()> packet_buffer;
패턴 6: consteval로 강제 컴파일 타임 평가 (C++20)
consteval은 반드시 컴파일 타임에만 평가되는 함수입니다. constexpr는 상수 인자일 때 컴파일 타임, 그렇지 않으면 런타임인데, consteval은 런타임 호출 자체가 불가능합니다.
consteval int must_be_compile_time(int x) {
return x * 2;
}
constexpr int a = must_be_compile_time(21); // OK: 42
// int b = must_be_compile_time(rand()); // ❌ 컴파일 에러: rand()는 런타임
사용처: 암호화 키 파생, 보안 상수 등 “절대 런타임에 노출되면 안 되는” 값에 유용합니다.
패턴 7: 컴파일 타임 타입 ID
타입별 고유 ID를 컴파일 타임에 부여하는 패턴입니다.
template <typename T>
struct type_id {
static constexpr const char* name = __PRETTY_FUNCTION__; // GCC/Clang
// MSVC: __FUNCSIG__
};
// type_id<int>::name != type_id<double>::name
// 디버깅, 로깅, 직렬화 타입 식별에 활용
패턴 8: static_assert와 constexpr 검증
설정값이 유효한 범위인지 컴파일 타임에 검증합니다.
constexpr int MAX_CONNECTIONS = 1024;
constexpr int MIN_CONNECTIONS = 1;
constexpr bool valid_connections(int n) {
return n >= MIN_CONNECTIONS && n <= MAX_CONNECTIONS;
}
static_assert(valid_connections(MAX_CONNECTIONS), "MAX_CONNECTIONS must be valid");
static_assert(valid_connections(512), "512 should be valid");
// 잘못된 값이 들어오면 컴파일 시점에 에러
9. 실전 활용
배열 크기
nextPowerOfTwo(100)은 100 이상인 가장 작은 2의 거듭제곱(128)을 반환합니다. n이 상수이면 while도 컴파일 타임에 풀려서, std::array<int, nextPowerOfTwo(100)>의 크기가 128로 고정됩니다. 버퍼 크기를 “입력 상수 이상인 2의 거듭제곱”으로 맞출 때 이런 constexpr 함수를 쓰면, 템플릿 메타 없이도 컴파일 타임 상수를 얻을 수 있습니다.
constexpr size_t nextPowerOfTwo(size_t n) {
size_t p = 1;
while (p < n) p *= 2;
return p;
}
std::array<int, nextPowerOfTwo(100)> buf;
템플릿 메타 프로그래밍 대체
과거에는 template <int N> struct Fib로 재귀 템플릿을 썼지만, constexpr int fib(int n)으로 같은 계산을 함수로 쓰면 읽기와 유지보수가 쉽습니다. Fib<N>::value가 필요하면 fib(N)을 constexpr 변수에 담거나 std::integral_constant에 넣어 쓰면 됩니다. 값 메타프로그래밍은 constexpr 함수로 대체하는 편이 현대 C++에서는 일반적입니다.
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); };
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ constexpr 완벽 가이드 | 컴파일 타임 계산·if constexpr·consteval 실전
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++ constexpr 고급 가이드 | constexpr 컨테이너·알고리즘·문자열·new/delete 실전
이 글에서 다루는 키워드 (관련 검색어)
C++ constexpr, 컴파일 타임 상수, constexpr 함수, constexpr vs const, 리터럴 타입 등으로 검색하시면 이 글이 도움이 됩니다.
정리
| 항목 | 내용 |
|---|---|
| constexpr 변수 | 컴파일 타임 상수 |
| constexpr 함수 | 상수 식에서 호출 가능 (인자가 상수면 컴파일 타임) |
| constexpr 생성자 | 사용자 정의 타입을 리터럴 타입으로 만듦 |
| const vs constexpr | const는 “불변”, constexpr은 “컴파일 타임 계산 가능” |
| C++14 | 루프·여러 문장 허용 |
| C++20 | 동적 할당 등 더 많은 것 허용 |
| if constexpr | 컴파일 타임 분기 |
| 프로덕션 패턴 | 룩업 테이블, 설정 파싱, 타입별 상수 |
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 배열 크기, 버퍼 할당, CRC/해시 테이블, 설정값 파싱 등 “컴파일 시점에 값이 고정”인 경우 constexpr를 사용합니다. 런타임에 결정되는 값에는 const만 쓰면 됩니다.
Q. constexpr 함수가 느리지 않나요?
A. 인자가 상수 식이면 컴파일 타임에 계산되므로 런타임 비용은 0입니다. 인자가 변수면 일반 함수처럼 런타임에 실행됩니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference의 constexpr 관련 문서와 해당 라이브러리 공식 문서를 참고하세요.
Q. constexpr와 매크로의 차이는?
A. 매크로는 전처리 단계에서 텍스트 치환이고, constexpr는 타입 검사·네임스페이스·스코프를 갖는 실제 C++ 함수/변수입니다. 디버깅, 오버로딩, 템플릿과의 조합이 constexpr 쪽이 유리합니다.
Q. 컴파일 시간이 늘어나지 않나요?
A. constexpr 계산이 많으면 컴파일 시간이 증가할 수 있습니다. 특히 큰 룩업 테이블(수천 항목)이나 깊은 재귀는 영향을 줄 수 있으므로, 필요할 때만 사용하고 반복문으로 전환하는 것을 권장합니다.
constexpr 적용 체크리스트
실무에서 constexpr를 도입할 때 확인할 항목입니다.
- 배열/버퍼 크기가 컴파일 시점에 고정인가? →
constexpr변수 또는 함수 사용 - 룩업 테이블(CRC, 해시, 인코딩)이 고정인가? → constexpr +
std::make_index_sequence패턴 - 설정값이 문자열 리터럴에서 파싱 가능한가? →
parseSize("4096")같은 constexpr 파서 - C++ 표준 버전이 C++14 이상인가? (루프·여러 문 사용 시)
- 재귀 깊이가 컴파일러 한계를 넘지 않는가? → 반복문으로 전환 검토
- static_assert로 상수 검증이 필요한가? → constexpr 조건 함수 활용
한 줄 요약: constexpr로 상수·배열 크기·템플릿 인자를 컴파일 타임에 계산할 수 있습니다. 다음으로 컴파일 타임 프로그래밍(#26-2)를 읽어보면 좋습니다.
다음 글: [C++ 실전 가이드 #26-2] 컴파일 타임 프로그래밍 기법: 템플릿 메타와 constexpr 조합
이전 글: [C++ 실전 가이드 #25-3] 커스텀 Range 작성: range 개념을 만족하는 타입 만들기
관련 글
- C++ 컴파일 타임 프로그래밍 기법 | 런타임 오버헤드 제거와 constexpr·consteval 실전
- C++ 컴파일 타임 리플렉션 | C++26 Reflection·magic_enum·매크로 직렬화·검증
- C++26 리플렉션 기초 | ^^ 연산자·std::meta::info로 타입 정보 조회하기
- C++ Type Traits 완벽 가이드 | std::is_integral·std::enable_if
- C++ constexpr 완벽 가이드 | 컴파일 타임 계산·if constexpr·consteval 실전