C++ constexpr 함수: 컴파일 타임 평가 조건과 C++11·14·17 제약 변화
이 글의 핵심
constexpr 함수가 '항상 컴파일 타임에 실행된다'는 흔한 오해를 바로잡고, 실제 평가 규칙과 C++11/C++14 차이, const와의 차이, 자주 만나는 컴파일 오류의 원인을 실전 코드로 설명합니다.
constexpr 함수란?
constexpr 함수는 “컴파일 타임에 계산될 수도 있고, 런타임에 일반 함수처럼 실행될 수도 있는” 함수입니다. 여기서 실무자들이 가장 많이 오해하는 지점이 있습니다. constexpr을 붙였다고 해서 그 함수 호출이 무조건 컴파일 타임에 평가되는 것은 아니라는 점입니다.
컴파일러는 그 함수의 결과가 컴파일 타임 상수가 반드시 필요한 문맥(배열 크기, 템플릿 비타입 인자, constexpr 변수 초기화, static_assert 등)에서 호출될 때만 상수 평가를 시도합니다. 그 외의 평범한 문맥에서는 최적화 여부와 상관없이 그냥 런타임 함수 호출처럼 컴파일될 수 있습니다. 즉 constexpr은 “컴파일 타임에 계산해도 되는 자격을 준다”는 선언이지, “항상 그렇게 하라”는 강제가 아닙니다. 이 차이를 모르면 왜 같은 함수가 어떤 곳에서는 컴파일 오류를 내고 어떤 곳에서는 멀쩡히 런타임에 돌아가는지 이해할 수 없습니다.
constexpr int square(int x) {
return x * x;
}
int main() {
// 컴파일 타임 문맥 — 상수 평가가 강제됨
constexpr int a = square(5); // 25, 컴파일 시 계산
// 런타임 문맥 — 굳이 컴파일 타임에 계산할 필요가 없음
int x = 10;
int b = square(x); // 인자 x가 런타임 변수이므로 런타임 계산
}
a는 constexpr로 선언되었기 때문에 컴파일러가 반드시 상수 평가를 해내야 하고, 실패하면 컴파일 오류가 납니다. 반면 b는 그냥 int이므로 컴파일러가 원하면 최적화 차원에서 상수 폴딩(constant folding)을 할 수도 있지만, 그럴 의무는 없습니다. x가 런타임에만 정해지는 값이라면 애초에 상수 평가 자체가 불가능하므로 일반 함수 호출로 컴파일됩니다.
C++11 vs C++14 vs C++17
C++11에서 constexpr 함수는 사실상 “본문이 return 표현식 하나뿐인 함수”로 제한되었습니다. 반복문, 지역 변수 선언, if/switch 같은 제어문을 쓸 수 없었기 때문에 삼항 연산자와 재귀로 로직을 표현해야 했습니다. 아래 factorial11이 그 전형적인 예입니다. C++14부터는 이 제약이 크게 풀려서 일반 함수처럼 지역 변수와 for/while 루프를 자유롭게 쓸 수 있게 되었습니다.
그림: constexpr 함수 본문은 C++11에서 return 표현식 하나로 제한되었고, C++14에서 지역 변수와 반복문이 허용되었으며, C++17에서 if constexpr가 추가되었다. 반복문 버전을 -std=c++11 -pedantic-errors로 컴파일하면 오류, c++14에서는 통과함을 이 맥의 clang++로 확인했다.
이 변화는 단순한 문법적 편의가 아닙니다. C++11 스타일의 재귀 기반 constexpr 함수는 컴파일러의 상수 평가기(constant evaluator)가 재귀 호출을 얼마나 깊게 따라갈 수 있는지에 대한 제약(대부분의 컴파일러는 -fconstexpr-depth 같은 옵션으로 기본 512회 정도 제한)에 부딪히기 쉽습니다. 즉 같은 로직이라도 재귀로 짜면 입력값이 커질 때 “constexpr 평가 깊이 초과” 오류가 날 수 있고, C++14 스타일의 반복문으로 짜면 그런 제약에서 훨씬 자유롭습니다.
// C++11: 단일 return문만 (재귀로 표현해야 함)
constexpr int factorial11(int n) {
return n <= 1 ? 1 : n * factorial11(n - 1);
}
// C++14: 일반 함수처럼 지역 변수와 루프 사용 가능
constexpr int factorial14(int n) {
int result = 1;
for (int i = 2; i <= n; i++) {
result *= i;
}
return result;
}
// C++17: if constexpr 추가 — 템플릿 분기를 컴파일 타임에 완전히 제거
template<typename T>
constexpr auto process(T value) {
if constexpr (std::is_integral_v<T>) {
return value * 2;
} else {
return value;
}
}
if constexpr는 이름은 비슷하지만 목적이 다릅니다. 일반 constexpr 함수가 “이 함수 자체를 컴파일 타임에 평가할 수 있게 만드는 것”이라면, if constexpr는 “템플릿 인스턴스화 시점에 조건에 맞지 않는 분기를 아예 컴파일 대상에서 제외시키는 것”입니다. 이 둘을 헷갈리면 템플릿 코드에서 타입이 맞지 않는 분기 때문에 컴파일 오류가 나는 이유를 찾기 어려워집니다.
기본 사용법
// 간단한 계산
constexpr int add(int a, int b) {
return a + b;
}
constexpr int multiply(int a, int b) {
return a * b;
}
// 배열 크기로 사용
constexpr int SIZE = add(10, 20);
int arr[SIZE]; // 컴파일 타임 크기
배열 크기, std::array의 템플릿 인자, enum 값처럼 언어 규격상 반드시 컴파일 타임 상수여야 하는 자리에는 일반 함수의 반환값을 넣을 수 없습니다. 이런 자리에 계산된 값을 넣고 싶을 때가 constexpr 함수의 가장 확실한 실용적 용도입니다. 매직 넘버를 하드코딩하는 대신 의미 있는 계산식으로 표현하면서도 런타임 비용은 전혀 들지 않습니다.
실전 예시
예시 1: 수학 함수
#include <iostream>
constexpr int fibonacci(int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
constexpr int power(int base, int exp) {
int result = 1;
for (int i = 0; i < exp; i++) {
result *= base;
}
return result;
}
constexpr bool isPrime(int n) {
if (n <= 1) return false;
if (n == 2) return true;
if (n % 2 == 0) return false;
for (int i = 3; i * i <= n; i += 2) {
if (n % i == 0) return false;
}
return true;
}
int main() {
constexpr int fib10 = fibonacci(10); // 55 (컴파일 타임)
constexpr int pow = power(2, 10); // 1024
constexpr bool prime = isPrime(17); // true
std::cout << fib10 << std::endl;
std::cout << pow << std::endl;
std::cout << prime << std::endl;
}
여기서 눈여겨볼 부분은 fibonacci가 여전히 재귀로 짜여 있다는 점입니다. n이 작을 때는 문제없지만, 이 함수를 그대로 두고 fibonacci(40)처럼 상수 문맥에서 호출하면 컴파일러가 지수적으로 늘어나는 재귀 호출 트리를 전부 상수 평가해야 합니다. 실행은 되지만 컴파일 시간이 눈에 띄게 늘어나거나, 컴파일러가 정한 평가 스텝 한도(-fconstexpr-steps, MSVC의 /constexpr:steps)를 넘겨 “constexpr evaluation exceeded step limit” 같은 오류를 낼 수 있습니다. 실무에서 constexpr 함수를 짤 때는 “이 함수가 상수 평가 문맥에서 호출될 최대 입력 크기가 얼마나 되는가”를 항상 염두에 두어야 합니다.
직접 확인한 한계: constexpr int fib(int n) { return n < 2 ? n : fib(n-1) + fib(n-2); }를 static_assert(fib(40) == 102334155);로 상수 평가시키면, Ubuntu 24.04의 g++ 13.3(-std=c++20)은 약 2초 뒤 'constexpr' evaluation operation count exceeds limit of 33554432 (use '-fconstexpr-ops-limit=' to increase the limit)로 멈췄고, 같은 환경의 clang도 constexpr evaluation hit maximum step limit 노트와 함께 실패했습니다. 반대로 fib(27)은 g++에서 0.05초 안에 통과했습니다. 재귀 깊이(-fconstexpr-depth, g++ 기본 512)와 연산 횟수 한도는 컴파일러·버전·플래그마다 다르므로, 컴파일 타임에 평가될 함수는 처음부터 반복문으로 작성하고 최대 입력에서 직접 빌드해 보는 것이 안전합니다.
예시 2: 문자열 처리
constexpr size_t strlen_constexpr(const char* str) {
size_t len = 0;
while (str[len] != '\0') {
len++;
}
return len;
}
constexpr bool startsWith(const char* str, const char* prefix) {
while (*prefix != '\0') {
if (*str != *prefix) {
return false;
}
str++;
prefix++;
}
return true;
}
int main() {
constexpr size_t len = strlen_constexpr("Hello"); // 5
constexpr bool starts = startsWith("Hello World", "Hello"); // true
char buffer[len + 1]; // 컴파일 타임 크기
}
문자열 리터럴은 프로그램 전체에서 정적 저장 기간을 가지는 배열이기 때문에 constexpr 문맥에서 포인터 연산을 해도 안전합니다. 이 패턴은 컴파일 타임에 문자열 길이를 알아야 버퍼 크기를 정할 수 있는 코드에서 유용합니다. 다만 strlen_constexpr에 런타임에만 만들어지는 char*(예: std::string::c_str()가 가리키는 힙 메모리)를 넘기면 애초에 상수 평가 자체가 불가능하므로 컴파일 타임 문맥에서는 사용할 수 없고, 자동으로 런타임 함수로 동작합니다.
예시 3: 해시 함수
#include <iostream>
constexpr unsigned int hash(const char* str) {
unsigned int hash = 5381;
while (*str) {
hash = ((hash << 5) + hash) + *str;
str++;
}
return hash;
}
int main() {
// 컴파일 타임 해시
constexpr unsigned int h1 = hash("apple");
constexpr unsigned int h2 = hash("banana");
// switch문에 사용
const char* fruit = "apple";
switch (hash(fruit)) {
case hash("apple"):
std::cout << "사과" << std::endl;
break;
case hash("banana"):
std::cout << "바나나" << std::endl;
break;
}
}
switch의 case 라벨은 언어 규격상 반드시 컴파일 타임 상수여야 하므로, 문자열을 switch로 분기하고 싶을 때 이런 컴파일 타임 해시 함수가 흔히 쓰입니다. 문자열 리터럴을 직접 비교하는 if-else 체인보다 가독성이 좋아지는 대신, 해시 충돌 가능성을 감수해야 한다는 트레이드오프가 있습니다. djb2 계열의 이 해시 함수는 충돌 확률이 낮지만 0은 아니므로, 입력 후보군이 많아질수록(예: 수백 개의 명령어 문자열을 분기) 충돌 검증을 별도로 해두는 편이 안전합니다.
예시 4: 배열 초기화
#include <array>
#include <iostream>
constexpr int generateValue(int index) {
return index * index;
}
template<size_t N>
constexpr std::array<int, N> generateArray() {
std::array<int, N> arr{};
for (size_t i = 0; i < N; i++) {
arr[i] = generateValue(i);
}
return arr;
}
int main() {
constexpr auto arr = generateArray<10>();
// arr = {0, 1, 4, 9, 16, 25, 36, 49, 64, 81}
for (int x : arr) {
std::cout << x << " ";
}
}
이 패턴은 룩업 테이블을 프로그램 실행 시점이 아니라 컴파일 시점에 통째로 채워 넣고 싶을 때 씁니다. 삼각함수 테이블, CRC 테이블, 상태 전이 테이블처럼 값이 고정되어 있고 계산 비용이 있는 데이터를 이렇게 만들면 런타임 초기화 비용이 완전히 사라지고, 결과 배열이 바이너리에 상수 데이터로 박혀 들어갑니다. 다만 N이 매우 커지면(수만 단위) 컴파일 시간과 바이너리 크기가 함께 늘어나므로, “정말 컴파일 타임에 필요한 데이터인지” 먼저 따져봐야 합니다.
constexpr 클래스
class Point {
private:
int x, y;
public:
constexpr Point(int x, int y) : x(x), y(y) {}
constexpr int getX() const { return x; }
constexpr int getY() const { return y; }
constexpr int distanceSquared() const {
return x * x + y * y;
}
};
int main() {
constexpr Point p(3, 4);
constexpr int dist = p.distanceSquared(); // 25
int arr[dist]; // 컴파일 타임 크기
}
생성자와 멤버 함수에도 constexpr을 붙일 수 있다는 사실은 처음 접하면 낯설게 느껴집니다. 여기서 핵심은 클래스 자체가 리터럴 타입(literal type) 조건을 만족해야 한다는 점입니다. 가상 함수가 없어야 하고(C++20 이전), 모든 비정적 멤버가 리터럴 타입이어야 하며, constexpr 소멸자를 가져야 합니다(C++20부터는 자동으로 조건이 완화되긴 했지만). 이 조건 중 하나라도 깨지면 아무리 생성자에 constexpr을 붙여도 클래스 인스턴스를 상수 문맥에서 만들 수 없다는 컴파일 오류를 만나게 됩니다.
자주 발생하는 문제
문제 1: 비constexpr 함수 호출
int nonConstexpr(int x) {
return x * 2;
}
// ❌ 비constexpr 함수 호출
constexpr int func(int x) {
return nonConstexpr(x); // 상수 평가 시도 시 에러
}
// ✅ constexpr 함수만 호출
constexpr int constexprFunc(int x) {
return x * 2;
}
constexpr int func(int x) {
return constexprFunc(x); // OK
}
주의할 점은 첫 번째 func가 항상 컴파일 오류를 내는 것은 아니라는 사실입니다. func를 런타임 문맥에서만 호출한다면(예: int r = func(5);) 컴파일러는 nonConstexpr 호출을 그냥 평범한 함수 호출로 처리하고 넘어갑니다. 오류는 constexpr int r = func(5);처럼 상수 평가를 강제하는 문맥에서 호출할 때만 발생합니다. 이 비대칭성 때문에 “어제까지 잘 컴파일되던 함수가 오늘 갑자기 에러가 난다”는 상황이 자주 생깁니다. 누군가 그 함수를 상수 문맥에서 처음 호출하는 순간 비로소 본문 전체가 상수 평가 가능한지 검증되기 때문입니다.
이 규칙은 예외 처리에서도 똑같이 나타납니다. constexpr 함수 본문에 throw가 있어도 그 자체로는 컴파일 오류가 아닙니다. 다만 그 함수가 실제로 상수 평가되는 경로에서 throw에 도달하면 그 시점에 컴파일 오류로 보고되고, 런타임 호출 경로에서 같은 throw에 도달하면 평범하게 예외가 던져집니다. 즉 같은 코드 한 줄이 어떤 문맥에서 호출되느냐에 따라 컴파일 오류가 되기도 하고 런타임 예외가 되기도 합니다.
직접 확인한 동작: constexpr int sq(int x) { assert(x >= 0); return x * x; }는 g++ 13.3(-std=c++17)에서 constexpr int a = sq(3);과 int arr[sq(4)];처럼 조건이 참인 상수 평가는 그대로 통과했습니다. constexpr int a = sq(-3);처럼 조건이 거짓이 되는 경로에서만 call to non-'constexpr' function 'void __assert_fail(...)' 오류가 났습니다. 즉 assert를 넣었다고 상수 평가가 막히는 것이 아니라, 실패하는 입력이 상수 평가에 들어올 때 컴파일 오류로 드러납니다. 실패 시 동작을 컴파일 타임과 런타임에서 다르게 하고 싶다면 C++23의 if consteval로 분기할 수 있습니다.
문제 2: 정적 변수
// ❌ 정적 변수 (C++11~C++20 모두 에러)
constexpr int func() {
static int count = 0; // 에러
return count++;
}
// C++14에서도 에러, C++23부터 정의 자체는 허용
constexpr int func14() {
static int count = 0; // -std=c++23 이상에서만 컴파일됨
return count++;
}
static 지역 변수는 C++11뿐 아니라 C++14·C++17·C++20에서도 constexpr 함수 안에 둘 수 없습니다. Ubuntu 24.04 g++ 13.3으로 -std=c++11, c++14, c++20을 각각 시험했을 때 모두 'c' defined 'static' in 'constexpr' function only available with '-std=c++2b' 오류가 났습니다. 이 제한은 C++23에서 풀려 -std=c++23에서는 함수 정의가 컴파일되고 런타임 호출(printf("%d\n", f()))도 됩니다. 다만 그 함수를 constexpr int r = f();처럼 상수 평가하면 C++23에서도 'c' defined 'static' in 'constexpr' context 오류가 납니다. 상수 평가는 호출 사이에 남는 상태를 허용하지 않기 때문입니다.
문제 3: 가상 함수
class Base {
public:
// C++17 이전에는 virtual + constexpr 조합이 금지되었음
virtual constexpr int getValue() const {
return 42;
}
};
// C++20부터 허용 (가상 함수도 constexpr 문맥에서 평가 가능)
class Base20 {
public:
virtual constexpr int getValue() const {
return 42;
}
};
가상 함수와 constexpr의 조합이 오랫동안 막혀 있었던 이유는 동적 디스패치(런타임에 실제 타입을 확인해 어떤 오버라이드를 호출할지 결정하는 메커니즘) 자체가 컴파일 타임 평가 모델과 충돌했기 때문입니다. C++20은 상수 평가기 안에서도 실제 객체의 정적 타입을 추적할 수 있도록 규칙을 정비해서 이 제약을 풀었습니다. 다만 여전히 컴파일러 지원 상황이 갈릴 수 있으므로, 이 기능에 의존하는 코드는 실제 타겟 컴파일러의 C++20 지원 수준을 먼저 확인하는 것이 안전합니다.
문제 4: 런타임 값
#include <iostream>
constexpr int square(int x) {
return x * x;
}
int main() {
int runtime_value;
std::cin >> runtime_value;
// ❌ constexpr 변수에 런타임 값
// constexpr int result = square(runtime_value); // 에러: runtime_value가 상수식이 아님
// ✅ 일반 변수
int result = square(runtime_value); // OK (런타임 계산)
}
여기서 근본적인 규칙 하나를 짚고 넘어갈 필요가 있습니다. constexpr 함수의 파라미터 자체는 constexpr일 수 없습니다. 파라미터는 호출할 때마다 다른 값이 들어올 수 있는 자리이기 때문에, 함수 정의 시점에는 그 값이 컴파일 타임 상수인지 런타임 값인지 알 수 없습니다. 대신 컴파일러는 “이 함수가 상수 평가 가능한 인자로 호출되면 상수로 평가하고, 그렇지 않으면 런타임 함수로 취급한다”는 이원적인 전략을 씁니다. square(runtime_value)는 인자가 런타임에만 정해지므로 애초에 상수 평가 후보에서 제외되고, 그 결과를 담는 그릇이 constexpr 변수일 때만 컴파일 오류로 드러납니다.
constexpr vs const
// const: 런타임에 초기화될 수도 있는 상수
const int x = getValue(); // 런타임 결정, 이후 변경 불가
// constexpr: 반드시 컴파일 타임에 알려져야 하는 상수
constexpr int y = 42; // 컴파일 타임 결정
// constexpr 함수 — 조건이 맞으면 상수 평가 가능
constexpr int square(int x) {
return x * x;
}
const와 constexpr은 종종 “둘 다 상수니까 비슷하다”고 오해받지만 의미하는 바가 다릅니다. const는 “이 변수는 초기화된 이후 값이 바뀌지 않는다”는 뜻일 뿐, 그 초기값이 컴파일 타임에 알려져 있어야 한다는 뜻은 아닙니다. 위 예제의 getValue()가 런타임에 파일을 읽거나 사용자 입력을 받아 값을 반환하는 함수라도 const int x = getValue();는 완전히 유효합니다. 반면 constexpr은 “이 값은 컴파일 타임에 반드시 알려져 있어야 한다”는 훨씬 강한 요구를 겁니다. 배열 크기나 템플릿 인자처럼 언어가 컴파일 타임 상수를 요구하는 자리에는 const만으로는 충분하지 않고 constexpr(또는 컴파일 타임에 알려진 리터럴)이 필요합니다. 실무에서 “왜 const int SIZE = ...로는 배열 크기를 못 정하지?”라는 질문을 받으면, 초기화식이 실제로 컴파일 타임에 계산 가능한지부터 확인해 보는 것이 첫 단계입니다. 대부분의 컴파일러는 초기화식이 상수식이면 const 변수도 배열 크기에 쓸 수 있게 허용하지만, 이는 컴파일러의 편의 확장이지 언어가 보장하는 규칙은 아닙니다.
constexpr과 최적화
// 컴파일 타임 계산
constexpr int factorial(int n) {
return n <= 1 ? 1 : n * factorial(n - 1);
}
int main() {
// 컴파일 타임에 계산됨 (런타임 비용 0)
constexpr int fact10 = factorial(10);
// 최적화된 어셈블리 예시:
// mov eax, 3628800 (계산 없이 값이 직접 삽입됨)
}
constexpr이 주는 실질적인 이득은 “런타임 CPU 사이클을 0으로 만든다”는 것입니다. 다만 이 이득은 공짜가 아니라 컴파일 시간과 맞바꾸는 것입니다. 상수 평가는 컴파일러 내부의 별도 인터프리터가 담당하는데, 재귀나 반복 횟수가 늘어날수록 그만큼 컴파일 시간이 늘어납니다. 빌드 시간에 민감한 대규모 프로젝트에서는 constexpr 계산을 헤더에 남발하면 증분 빌드 속도가 눈에 띄게 느려질 수 있으므로, “이 값이 정말 여러 번, 여러 파일에서 컴파일 타임에 필요한가”를 먼저 따져보는 편이 좋습니다.
constexpr 오류 메시지를 직접 확인하기
constexpr 함수는 “상수 표현식에서 쓸 수 있다”는 것이지 항상 컴파일 타임에 계산된다는 뜻이 아닙니다. 상수 표현식이 필요한 자리에서 조건을 못 맞추면 컴파일 오류가 납니다. 환경은 Ubuntu 24.04, g++ 13.3, -std=c++20입니다.
런타임 값을 넘기면 오류
int runtime_value() { return 4; }
constexpr int sq(int x) { return x * x; }
int main() { constexpr int a = sq(runtime_value()); }
error: call to non-'constexpr' function 'int runtime_value()'
같은 sq를 int n; std::cin >> n; int a = sq(n);처럼 상수가 필요 없는 자리에서 호출하면 일반 함수처럼 동작합니다(입력 7에 49가 출력됨).
상수 평가 중 UB가 되면 오류
constexpr int div(int a, int b) { return a / b; }
constexpr int r = div(1, 0);
error: '(1 / 0)' is not a constant expression
런타임이었다면 정의되지 않은 동작이었을 코드가 컴파일 타임에서는 오류로 드러납니다. UB를 컴파일러가 잡아 주는 드문 경우입니다.
재귀 깊이 한계
constexpr int depth(int n) { return n == 0 ? 0 : 1 + depth(n - 1); }
static_assert(depth(100) == 100); // 통과
static_assert(depth(1000) == 1000); // 오류
error: 'constexpr' evaluation depth exceeds maximum of 512 (use '-fconstexpr-depth=' to increase the maximum)
depth(100)은 통과하고 depth(1000)에서 GCC의 기본 한계 512에 걸렸습니다. 깊은 재귀가 필요하면 -fconstexpr-depth=로 늘릴 수 있지만, 반복문으로 바꾸는 편이 컴파일 시간에도 낫습니다(C++14부터 constexpr 함수 안에서 반복문을 쓸 수 있습니다).
참고 자료
FAQ
constexpr 함수는 언제 사용?
- 배열 크기, 템플릿 비타입 인자처럼 언어가 컴파일 타임 상수를 요구하는 자리에 계산된 값을 넣고 싶을 때
- 룩업 테이블처럼 값이 고정되어 있고 런타임 초기화 비용을 없애고 싶을 때
static_assert로 컴파일 타임에 조건을 검증하고 싶을 때
직접 확인한 값입니다. Ubuntu 24.04 aarch64 컨테이너의 g++ 13.3과 clang++ 18.1.3에서 constexpr int sq(int x) { return x * x; }를 상수 인자로 불러 일반 변수(int a = sq(5);)에 넣어 -O0으로 컴파일해 어셈블리를 보니, 이 호출은 main 안에 남아 있지 않았고 컴파일 타임에 계산되어 있었습니다(main 안의 call은 아래의 g(int) 하나뿐). 최적화를 끈 빌드에서도 GCC가 상수 인자의 constexpr 호출을 접었다는 뜻이지만, 일반 변수 초기화는 접는 것이 표준이 요구하는 동작이 아니므로 확실히 컴파일 타임에 계산하려면 constexpr 변수나 static_assert, consteval을 써야 합니다.
런타임에도 사용 가능?
네. 인자가 컴파일 타임 상수가 아니면 컴파일러가 자동으로 런타임 함수 호출로 처리합니다. 같은 함수 정의를 상수 문맥과 런타임 문맥 양쪽에서 재사용할 수 있다는 점이 constexpr의 장점입니다.
같은 환경에서 int g(int n) { return sq(n); }처럼 런타임 인자로 부르면 -O0 어셈블리에 sq(int)로 가는 bl(호출)이 그대로 남았고, 프로그램은 정상 실행됐습니다(a=25, b=36, g(1)=1을 더해 종료 코드 62). 반대로 constexpr int c = sq(argc);처럼 상수가 아닌 값을 상수 문맥에 쓰면 g++은 'argc' is not a constant expression, constexpr variable 'c' must be initialized by a constant expression 오류를 냈습니다.
std::is_constant_evaluated()를 쓰면 같은 함수가 두 문맥에서 다르게 동작하게 만들 수 있습니다. f(2)를 constexpr 변수에 넣은 값은 x * 2 경로(4), 런타임 인자로 부른 f(argc)는 x * 3 경로(3)였습니다. 컴파일 타임 평가를 강제하는 consteval 함수에 런타임 인자를 넘기면 'argc' is not a constant expression으로 컴파일에 실패했습니다.
constexpr vs inline?
- constexpr: 조건이 맞으면 컴파일 타임에 값을 계산해 버리는 것 — 계산 자체를 없앰
- inline: 함수 호출 오버헤드를 없애기 위해 호출부에 함수 본문을 그대로 삽입하는 것 — 계산은 여전히 런타임에 일어남
둘은 독립적인 개념이라 constexpr inline 조합도 가능하지만, 대부분의 컴파일러가 constexpr 함수를 정의와 동시에 암묵적으로 inline 링크 속성을 부여하므로 실무에서 굳이 같이 쓸 일은 많지 않습니다.
모든 함수를 constexpr로?
아니요. 함수 본문에 I/O, 동적 메모리 할당(일부 예외 제외), 비constexpr 함수 호출이 섞여 있으면 애초에 상수 평가가 불가능합니다. 게다가 상수 평가가 불필요한 함수까지 constexpr로 표시하면 컴파일 시간만 늘어나고 실익이 없는 경우도 많습니다.
같은 환경에서 std::cout << x를 포함한 constexpr int loud(int x)를 만들어 보면 g++·clang++ 모두 -std=c++17, c++20, c++23에서 정의 자체는 오류 없이 컴파일했고(런타임 호출 loud(3)도 정상), 상수 문맥에서 constexpr int z = loud(3);처럼 쓸 때만 g++은 'constexpr int loud(int)' called in a constant expression, clang++은 constexpr variable 'z' must be initialized by a constant expression 오류를 냈습니다. 즉 I/O가 들어간 함수를 constexpr로 표시해도 컴파일러가 정의를 막지는 않지만 상수 평가에는 쓸 수 없고, 표시만으로 얻는 것이 없습니다.
C++11 vs C++14 차이?
C++11은 본문이 단일 return문으로 제한되어 재귀로만 로직을 표현해야 했고, C++14부터는 지역 변수·반복문·다중 문장을 일반 함수처럼 쓸 수 있게 완화되었습니다.