constexpr, consteval, constinit 키워드 & Static Initialization Order Fiasco
참고로 C++ 표준 버전에 따라 기능의 변동이 있으니 자세한 내용은 cppreference를 참고할 것.
constexpr 란?
컴파일러에게 다음의 것들이 상수식(constant expression)임을 명시하는 키워드이다.
- 변수(variable)
- 식(expression)
- 함수(function)
변수 선언 시 constexpr 키워드
constexpr 키워드가 붙을 경우 해당 타입은 컴파일 타임에 리터럴 타입(literal-type)임을 보장해야한다.
그러지 못하면 컴파일 에러가 발생한다.
즉, 컴파일 타임에 해당 변수에 들어갈 내용이 뭔지 알아야 된다는 뜻이다.
변수에 constexpr 키워드를 붙일 수 있는 조건
- Literal Type이어야 한다.
- 즉시, 초기화될 수 있어야 한다.
- 초기화에 대한 전체 expression이(implicit conversions, constructors calls 포함) constant expression이어야 한다.
- 반드시 constant destructor를 가진 타입이어야 한다. ( Since C++20 )
- class나 class 배열이 아니거나
- class 또는 (다차원 일 수도 있는)class 배열 중
그 class 타입이 constexpr destructor와
오직 그 객체를 소멸시키는 동작만 하는 가상의 표현식 e를 가지는 경우에
만약 객체의 lifetime과 non-mutable subobjects가 e 내에서 시작되는 것으로 간주 된다면
core constant expression이 될 수 있다. - 만약 constexpr 변수가 translation-unit-local이 아닌 경우
constant expressions 에서 사용가능한 tanslation-unit-local entity를 가리키는/참조하는
subobject를 가리키거나/참조하거나/가지고 있어서는 안된다. ( Since C++20 ) - 클래스 유형 또는 (다차원적으로) 배열이며,
class type은 constexpr 소멸자를 가지고 있으며,
객체를 파괴하는 효과만 있는 가상의 표현식 e의 경우,
객체의 수명과 상호 교환할 수 없는 하위 객체(변환 가능한 하위 객체는 제외)가 e 내에서 시작되는 것으로 간주된다면 e는 핵심 상수 표현식이 될 것이다.
변수에 constexpr 적용 예시
constexpr float x = 42.0;
constexpr float y{108};
constexpr float z = exp(5, 3);
constexpr int i; // Error! Not initialized
int j = 0;
constexpr int k = j + 1; //Error! j not a constant expression
함수에 붙는 constexpr 키워드
함수에 constexpr이 붙기 위해서는 어떤 조건이 필요할까?
핵심은 반환 값을 컴파일 타임에 계산할 수 있어야 한다는 것이다.
즉, constexpr 함수를 호출 코드를 봤을 때 그것이 상수 리터럴 타입이어야 한다.
만약 계산할 수 없는 객체나 포인터 같은 것이 오면 컴파일 에러가 발생한다.
함수에 constexpr 키워드를 붙일 수 있는 조건
- virtual이 아니어야 한다. ( Until c++20 )
- coroutine이 아니어야 한다. ( Since C++20 )
- Literal Type을 반환해야한다.
- 함수의 인자들은 Literal Type이어야 한다.
- 생성자(or 소멸자 <Since C++20> )는 virtual base classes가 없어야 한다.
- 너무 많아서 추후 기재 예정................................
함수에 constexpr 적용 예시
#include <iostream>
using namespace std;
// Pass by value
constexpr float exp(float x, int n)
{
return n == 0 ? 1 :
n % 2 == 0 ? exp(x * x, n / 2) :
exp(x * x, (n - 1) / 2) * x;
}
// Pass by reference
constexpr float exp2(const float& x, const int& n)
{
return n == 0 ? 1 :
n % 2 == 0 ? exp2(x * x, n / 2) :
exp2(x * x, (n - 1) / 2) * x;
}
// Compile-time computation of array length
template<typename T, int N>
constexpr int length(const T(&)[N])
{
return N;
}
// Recursive constexpr function
constexpr int fac(int n)
{
return n == 1 ? 1 : n * fac(n - 1);
}
// User-defined type
class Foo
{
public:
constexpr explicit Foo(int i) : _i(i) {}
constexpr int GetValue() const
{
return _i;
}
private:
int _i;
};
// compile-time loop computation ( factorial )
template<int N>
constexpr size_t Factorial()
{
auto n = N;
for (size_t i = 2; i < N; i++)
{
n *= i;
}
return n;
}
int main()
{
// foo is const:
constexpr Foo foo(5);
// foo = Foo(6); //Error!
// Compile time:
constexpr float x = exp(5, 3);
constexpr float y { exp(2, 5) };
constexpr int val = foo.GetValue();
constexpr int f5 = fac(5);
const int nums[] { 1, 2, 3, 4 };
const int nums2[length(nums) * 2] { 1, 2, 3, 4, 5, 6, 7, 8 };
constexpr auto num = Factorial<6>(); // 720
// Run time:
cout << "The value of foo is " << foo.GetValue() << endl;
}
TMP 방식으로 constexpr 함수 적용 예시
template <int N>
struct Factorial {
static const int value = N * Factorial<N - 1>::value;
};
// template <> 는 explicit template specialization
template <>
struct Factorial<0> {
static const int value = 1;
};
int main() {
constexpr int num = Factorial<6>::value;
std::cout << num; // 720
}
자세한 내용은 참고 - https://docs.microsoft.com/en-us/cpp/cpp/constexpr-cpp?view=msvc-160
constexpr (C++)
Guide to the C++ language constexpr keyword.
docs.microsoft.com
https://m.blog.naver.com/PostView.naver?isHttpsRedirect=true&blogId=kmc7468&logNo=221705880457
consteval
consteval은 constexpr의 더 강력한 버전이라고 생각하면 된다.
consteval은 Immediate Function를 생성한다.
Immediate Function은 하나의 컴파일 상수로 평가되는 함수이며, 컴파일 시점에 실행된다.
Immediate Function의 조건은 다음과 같다.
- 함수내에서 소멸자를 호출하지 않아야 한다.
- 메모리를 할당, 재할당하지 않아야 한다.
- constexpr 함수의 요구 조건을 만족해야 한다.
constexpr과 consteval의 차이점
함수 타입 | 함수의 성격 |
constexpr | 문맥 또는 최적화에 따라서 컴파일 시점 또는 런타임 시점에도 실행 가능 |
consteval | 반드시 컴파일 시점에만 실행 가능 |
constinit
constinit 키워드는 저장 기간(storage duration)이 정적이거나 스레드인 변수에 적용 할 수 있다.
주의 점은 지역 변수에 적용 할 수 없다.
constinit를 변수에 적용하면 컴파일 시점에 초기화 된다.
주의 할 점은 상수(const) 형식으로 지정하지 않는다.
const char* g() { return "dynamic initialization"; }
constexpr const char* f(bool p) { return p ? "constant initializer" : g(); }
constinit const char* c = f(true); // OK
// constinit const char* d = f(false); // error E0028 : 식에 상수 값이 있어야 한다.
변수 초기화시 const, constexpr, constinit의 차이점
변수의 타입 | 변수 초기화 성격 |
const | 상수, 런타임 시점까지 초기화 지연 가능 |
constexpr | 상수, 컴파일 시점 초기화 |
constinit | 비상수, 컴파일 시점 초기화, 지역 변수 선언 불가 |
// 상수
constexpr int constexprVal = 1000;
// 비상수
constinit int constinitVal = 1000;
int main()
{
const auto constVal = 1000;
cout << "constVal: " << constVal << '\n';
//cout << "++constVal: " << ++constVal << '\n'; // 에러
//cout << "++constexprVal: " << ++constexprVal << '\n'; // 에러
cout << "++constinitVal: " << ++constinitVal << '\n'; // OK
constexpr auto localConstexpr = 1000; // OK
// constinit auto localConstexpr = 1000; // 에러 : 지멱 변수가 될 수 없습니다.
}
Static Initialization Order Fiasco(정적 변수 초기화 실패)
코드의 서로 다른 번역 단위(다른 cpp 파일)에 있는 정적 저장기간의 변수들이 순서에 의존적인 문제를 말한다.
우선 정적 변수의 초기화 방식은 다음과 같다.

정적 단계(컴파일 시점)에서 상수 표현식을 초기화 할 수 있는가?
Yes -> 정적 변수를 해당 상수 값으로 초기화
No -> 0으로 셋팅합니다.
동적 단계(런타임 시점)에서는 초기화 되지 않은 정적 변수를 실행 시점의 값으로 초기화 한다.
아래의 예제 코드를 보면 staticA와 staticB가 서로 다른 번역 단위에서 있으면서 의존 관계로 묶여 있다.
즉, staticB의 값을 초기화 하려면 staticA 값이 필요한 경우이다.
staticA값은 컴파일 단계에서 초기화 할 수 없기 때문에 기본 값인 0으로 셋팅 된 후 런타임 시 초기화 된다.
이런 경우 번역 단위의 초기화 순서에 따라서 staticB의 값이 달라진다.
// SIOF1.cpp
int sum(int l, int r){
return l + r;
}
auto staticA = sum(1, 2);
// main.cpp
#include <iostream>
using namespace std;
extern int staticA;
auto staticB = staticA; // staticA를 먼저 초기화하지 못한 경우 staticB는 일단 0으로 초기화 된다.
int main() {
cout << "staticB : " << staticB << "\n";
}
이러한 문제점을 명확하게 보여주기 위해서는 cl 컴파일러를 통해서 빌드를 진행하였다.
# cpp 파일들의 obj 생성 작업
cl.exe /c main.cpp SIOF1.cpp
# SIOF1.obj 먼저 초기화되는 링크 순서
cl.exe /Fe:test1.exe SIOF1.obj main.obj # test1.exe 파일 생성
test1.exe # staticB : 3 출력
# main.obj 먼저 초기화되는 링크 순서
cl.exe /Fe:test2.exe main.obj SIOF1.obj # test2.exe 파일 생성
test2.exe # staticB : 0 출력
SIOF.cpp가 main.cpp보다 먼저 링크되면
staticA가 staticB보다 먼저 초기화 되기 때문에 staticB는 3이 된다.
그러나 SIOF.cpp가 main.cpp보다 늦게 링크되면
staticB가 먼저 초기화 되기 때문에 staticA의 값을 받아오지 못하므로
staticB는 기본 값인 0이 된다.
c++20 이전의 해결 방법
c++20 이전에는 이 문제를 해결하기 위해서 지역 범위 정적 변수의 지연 초기화를 사용 하였습니다.
지역 범위에 있는 정적 변수는 처음 사용 될 때 생성된 다는 규칙을 통해서 순서의 의존성을 해결 합니다.
// SIOF1.cpp
int sum(int l, int r){
return l + r;
}
int & staticA() {
// 지역 범위 정적 변수는 처음 사용 될 때 생성 됩니다.
static auto staticA = sum(1, 2);
return staticA;
}
// main.cpp
#include <iostream>
using namespace std;
int & staticA();
// 링크 순서와 상관 없이 이곳에서 처음 사용 되기에 staticB은 일정한 값을 가집니다.
auto staticB = staticA();
// 생략
c++20에서 해결 방법
c++20에서는 constinit 키워드를 사용하면
반드시 컴파일 시점에 초기화 되는 것을 보장 할 수 있게 된다.
// SIOF1.cpp
// constexpr이므로 컴파일 시점에 평가 할 수 있다면 컴파일 시점 평가 함수가 됨
constexpr int sum(int l, int r){
return l + r;
}
// staticA는 반드시 컴파일 시점에 초기화 된다
constinit auto staticA = sum(1, 2);
// main.cpp
#include <iostream>
using namespace std;
// extern이지만 constinit으로 명시했으므로 staticA는 미리 초기화 되어있다고 가정한다
extern constinit int staticA;
auto staticB = staticA; // staticA의 값인 3이 staticB에 대입된다
// 생략