constexpr이란
constexpr
은 C++11부터 지원되기 시작한 컴파일 상수이다. 11부터 시작해서 23에 이르기까지 constexpr
은 그 지원 범위를 점차 늘려왔고, 많은 C++ 프로그래머로부터 각광을 받았다고 한다. 이 constexpr
은 무엇이고, const
와 무엇이 다른 것일까.
constexpr
, 혹은 컴파일 상수는, '컴파일 시간에 값을 결정하는 상수'라는 의미를 갖고 있다.
2, 3.0과 같은 상수를 생각해보자. 이 상수들은 런타임 도중 그 값이 변경되지 않는다.
C 시절 #define NUMBER 5
와 같이 작성하면, 이는 매크로라 하여서, 컴파일러가 자동적으로 NUMBER
를 5로 바꾸어준다. 이때 NUMBER
는 컴파일 도중에 그 값이 결정되지, 런타임 도중에 값이 5로 결정되는 게 아니다.
constexpr
은 단순 매크로보다 더 많은 것들을 컴파일 시간에 결정할 수 있게 해주는 기능이다.
constexpr의 예시
말로만 들으면 잘 감이 오지 않는다. 다음 예시를 몇 가지 살펴보자.
여기 피보나치 함수가 있다.
int fibonacci(const int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
물론 피보나치는 Dynamic Programming을 이용하면 O(n)에 답을 구할 수 있지만, 여기서는 설명을 위해 재귀적으로 구현하였다.
int main()
{
const auto N = 5;
const auto result = fibonacci(5);
return 0;
}
위 메인 함수의 Assembly를 확인해보면 다음과 같이 된다.
fibonacci(int):
push rbp
mov rbp, rsp
push rbx
sub rsp, 24
mov DWORD PTR [rbp-20], edi
cmp DWORD PTR [rbp-20], 1
jg .L2
mov eax, DWORD PTR [rbp-20]
jmp .L3
.L2:
mov eax, DWORD PTR [rbp-20]
sub eax, 1
mov edi, eax
call fibonacci(int)
mov ebx, eax
mov eax, DWORD PTR [rbp-20]
sub eax, 2
mov edi, eax
call fibonacci(int)
add eax, ebx
.L3:
mov rbx, QWORD PTR [rbp-8]
leave
ret
main:
push rbp
mov rbp, rsp
sub rsp, 16
mov DWORD PTR [rbp-4], 5
mov edi, 5
call fibonacci(int)
mov DWORD PTR [rbp-8], eax
mov eax, 0
leave
ret
위와 같이 함수가 내부적으로 호출되고 있고, 이를 계산하기 위해서 많은 연산이 뒤에서 이루어지고 있음을 알 수 있다.
어셈블리를 이해할 필요는 전혀 없다. 나도 모른다. 그러나 어셈블리가 다소 길고 복잡하다는 것에서, 우리는 그 뒤에 많은 연산이 이루어지는구나만 확인하면 된다.
그런데 여기서 fibonacci의 앞에다가 constexpr
을 붙이고, N 앞에도 constexpr
을 붙여보자.
constexpr int fibonacci(const int n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
int main()
{
constexpr auto N = 5;
const auto result = fibonacci(5);
return 0;
}
이를 다시 어셈블리로 변환해보면
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-4], 5
mov DWORD PTR [rbp-8], 5
mov eax, 0
pop rbp
ret
보이나? 이게 전부이다. 못 믿겠으면 실제로 Compiler Explorer에 가서 확인해보라. 혹은 직접 컴파일러를 이용해서 컴파일해봐도 같은 결과가 나올 것이다.
위에서 fibonacci 함수를 호출하기 위해 긴 과정이 들어갔지만, 여기서는 fibonacci 함수가 존재하지 않는다. 대신 결과에 바로 5가 어셈블리에서 상수로 들어가는 것을 확인할 수 있다.
fibonacci(5)
의 결과를 런타임 중에 O(1)에 알 수 있게 됐다.
const auto result = fibonacci(5); // 함수의 호출 없이 바로 5가 대입됨
즉 위에 주석처럼, 위 코드는 실제 fibonacci
의 호출없이 바로 컴파일 과정에서 상수 5가 result에 대입된 상태이다.
이게 가능한 이유는, 함수 앞에 constexpr
이 붙었기 때문이다. constexpr
이 붙은 함수는 가능할 경우 컴파일러가 직접 컴파일 시간에 모든 값을 미리 계산한다. 그래서 컴파일러는 fibonacci(5)
를 런타임 중이 아닌 컴파일 시간에 계산해서, 상수로 이를 직접 대입하였다.
더 많은 예시를 보자.
#include <array>
#include <numeric>
int main()
{
constexpr std::array<int, 5> arr = { 1, 2, 3, 4, 5 };
auto result = std::accumulate(arr.begin(), arr.end(), 0);
return 0;
}
위 코드는 arr의 모든 원소를 더해 result에 저장하는 코드이다. 이 코드를 컴파일하면 다음과 같은 어셈블리가 나온다.
main:
push rbp
mov rbp, rsp
push rbx
sub rsp, 40
mov DWORD PTR [rbp-48], 1
mov DWORD PTR [rbp-44], 2
mov DWORD PTR [rbp-40], 3
mov DWORD PTR [rbp-36], 4
mov DWORD PTR [rbp-32], 5
lea rax, [rbp-48]
mov rdi, rax
call std::array<int, 5ul>::end() const
mov rbx, rax
lea rax, [rbp-48]
mov rdi, rax
call std::array<int, 5ul>::begin() const
mov edx, 0
mov rsi, rbx
mov rdi, rax
call int std::accumulate<int const*, int>(int const*, int const*, int)
mov DWORD PTR [rbp-20], eax
mov eax, 0
mov rbx, QWORD PTR [rbp-8]
leave
ret
살짝 복잡하다. 그 이유는 여기서는 constexpr
이 작동하지 않았기 때문이다.
실제로 cppreference std::accumulate를 보면, C++20 전까지는 std::accumulate
는 constexpr
을 지원하지 않는 것을 알 수 있다.
위 사진에서 보면 std::accumulate
는 C++20부터 constexpr
을 지원하는 것을 알 수 있다.
때문에 컴파일러 옵션에 -std=c++20
을 붙이면, 다음과 같이 컴파일된다.
main:
push rbp
mov rbp, rsp
mov DWORD PTR [rbp-32], 1
mov DWORD PTR [rbp-28], 2
mov DWORD PTR [rbp-24], 3
mov DWORD PTR [rbp-20], 4
mov DWORD PTR [rbp-16], 5
mov DWORD PTR [rbp-4], 15
mov eax, 0
pop rbp
ret
이전과 같이 복잡한 연산이 호출되지 않고, 마지막에 간결하게 15가 상수 형태로 들어가는 것을 확인할 수 있다. 즉 여기서도 std::accumulate
를 런타임 도중에 호출하지 않고, 컴파일 시간에 계산한 결과를 상수로 대입하였다.
auto result = std::accumulate(arr.begin(), arr.end(), 0); // 함수의 호출 없이 바로 15가 대입됨
위 상황은 바로 위 주석과 마찬가지로, 별다른 함수의 호출없이 바로 컴파일 과정에서 상수 15가 result에 대입됐다.
constexpr의 활용
constexpr
은 함수에만 붙일 수 있는 것이 아니다. 위에서 확인했듯이 변수에도 붙일 수 있다.
constexpr int N = 5;
이렇게 하면, N
은 컴파일 시간에 결정되는 상수가 된다. 즉 N
을 사용하는 모든 곳에서 N
이 상수로 취급된다.
또한, 다음과 같이 작성할 경우 조금 특별한 의미를 갖는다.
int main()
{
constexpr auto result = fibonacci(5); // fibonacci 함수는 constexpr
return 0;
}
result에 constexpr
을 붙일 경우, 이 의미는 fibonacci에서 반환되는 결과를 무조건 컴파일 시간에 결정하겠다는 의미이다. 만약 컴파일러가 컴파일 시간에 값을 결정할 수 없는 경우 컴파일 오류를 낸다.
함수 앞에 constexpr
을 붙여도, 컴파일 상수가 작동하지 않는 경우가 있다.
- const가 아닌, 런타임 도중에 값이 변할 수 있는 변수를 사용하는 경우
- constexpr로 계산하기에 너무 복잡한 경우
컴파일러는 이런 경우를 적당히 판단하여, 미리 컴파일 시간에 계산을 할 지 아니면 그냥 런타임 계산으로 넘겨버릴 지 결정한다.
문제는 컴파일 상수의 결과와 런타임 계산의 결과가 달라질 경우, 혹은 성능 상의 문제로 반드시 그 값을 컴파일 시간에 계산해야 하는 경우, 혹은 프로그래머의 실수로 내부에서 값이 변경되는 경우를 방지하기 위해서, 변수 앞에 constexpr
을 붙인다.
이러면 만약에 constexpr
이 작동하지 않는 경우 컴파일 에러를 뱉어낸다.
constexpr이 필요한 이유
constexpr
은 왜 필요할까? 런타임이 아닌 컴파일 도중에 그 값을 결정한다는 데에서 힌트를 얻을 수 있겠지만, 당연히 성능 최적화를 위해서이다.
C++에서는 기본적으로, '컴파일 시간에 결정가능한 것들은 모두 컴파일 시간에 결정하라'는 철학을 갖고 있다. 즉 컴파일 시간에 미리 값을 알 수 있다면 런타임 도중에 자원을 써서 계산하지 말고, 미리 컴파일 시간에 값을 결정하여 런타임 중 활용하라는 의미이다.
사실 constexpr
이 도입되기 전부터 C++ 개발자들은, constexpr
의 기능을 흉내내기 위해서 편법들을 사용해왔다. 그 중 하나가 바로 TMP, 템플릿 메타 프로그래밍이다.
본 포스팅에서 템플릿 메타 프로그래밍에 대해서 소개할 것은 아니지만, 템플릿 메타 프로그래밍은 C++ 계에서 가장 첨예하게 논쟁이 일어나는 개발 방법이고, 이를 사용해도 되는지 안 되는지에 대해서 많은 개발자들은 서로 다른 의견과 견해를 가지고 있다. 개인적으로 나는 TMP을 사용한 적도 없고, 앞으로 사용할 예정도 없어서 TMP가 어떻다 말할 자격은 없다.
그러나 분명한 것은 constexpr
이 등장하면서 TMP을 대체할 수 있게 되었고, 디버깅과 개발의 편의성을 높일 수 있다.
constexpr, 언제 사용할까?
답은 간단하다. 사용할 수 있는 모든 환경에서 사용하라.
미리 계산하고, 함수 호출을 최소화하여 성능 최적화를 극대화할 수 있다. 사용방법이 const
의 사용과 크게 다르지도 않다. constexpr
이 많다고 해서 컴파일 시간이 극단적으로 늘어나는 것도 아니다. 사용상의 제약조건이 크게 있는 것도 아니고, 컴파일러가 알아서 컴파일 상수로 대체하기 힘들다고 판단되면 런타임 계산으로 넘겨버린다. 사용하지 않을 이유가 무엇이겠는가? 물론 백준과 같이 거의 대부분의 환경에서 입력값이 동적으로 변하는 경우에는 사용해도 효과를 보는 게 거의 불가능하겠지만, 만약 실제 환경에서 입력값, 혹은 결과값이 그리 동적으로 변경되는 환경이 아니라면 적극적으로 constexpr
을 사용해도 될 것이다.