[모던 C++] 컴파일 상수 constexpr

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::accumulateconstexpr을 지원하지 않는 것을 알 수 있다.

위 사진에서 보면 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을 붙여도, 컴파일 상수가 작동하지 않는 경우가 있다.

  1. const가 아닌, 런타임 도중에 값이 변할 수 있는 변수를 사용하는 경우
  2. constexpr로 계산하기에 너무 복잡한 경우

컴파일러는 이런 경우를 적당히 판단하여, 미리 컴파일 시간에 계산을 할 지 아니면 그냥 런타임 계산으로 넘겨버릴 지 결정한다.

문제는 컴파일 상수의 결과와 런타임 계산의 결과가 달라질 경우, 혹은 성능 상의 문제로 반드시 그 값을 컴파일 시간에 계산해야 하는 경우, 혹은 프로그래머의 실수로 내부에서 값이 변경되는 경우를 방지하기 위해서, 변수 앞에 constexpr을 붙인다.

이러면 만약에 constexpr이 작동하지 않는 경우 컴파일 에러를 뱉어낸다.

constexpr이 필요한 이유

constexpr은 왜 필요할까? 런타임이 아닌 컴파일 도중에 그 값을 결정한다는 데에서 힌트를 얻을 수 있겠지만, 당연히 성능 최적화를 위해서이다.

C++에서는 기본적으로, '컴파일 시간에 결정가능한 것들은 모두 컴파일 시간에 결정하라'는 철학을 갖고 있다. 즉 컴파일 시간에 미리 값을 알 수 있다면 런타임 도중에 자원을 써서 계산하지 말고, 미리 컴파일 시간에 값을 결정하여 런타임 중 활용하라는 의미이다.

사실 constexpr이 도입되기 전부터 C++ 개발자들은, constexpr의 기능을 흉내내기 위해서 편법들을 사용해왔다. 그 중 하나가 바로 TMP, 템플릿 메타 프로그래밍이다.

본 포스팅에서 템플릿 메타 프로그래밍에 대해서 소개할 것은 아니지만, 템플릿 메타 프로그래밍은 C++ 계에서 가장 첨예하게 논쟁이 일어나는 개발 방법이고, 이를 사용해도 되는지 안 되는지에 대해서 많은 개발자들은 서로 다른 의견과 견해를 가지고 있다. 개인적으로 나는 TMP을 사용한 적도 없고, 앞으로 사용할 예정도 없어서 TMP가 어떻다 말할 자격은 없다.

그러나 분명한 것은 constexpr이 등장하면서 TMP을 대체할 수 있게 되었고, 디버깅과 개발의 편의성을 높일 수 있다.

constexpr, 언제 사용할까?

답은 간단하다. 사용할 수 있는 모든 환경에서 사용하라.

미리 계산하고, 함수 호출을 최소화하여 성능 최적화를 극대화할 수 있다. 사용방법이 const의 사용과 크게 다르지도 않다. constexpr이 많다고 해서 컴파일 시간이 극단적으로 늘어나는 것도 아니다. 사용상의 제약조건이 크게 있는 것도 아니고, 컴파일러가 알아서 컴파일 상수로 대체하기 힘들다고 판단되면 런타임 계산으로 넘겨버린다. 사용하지 않을 이유가 무엇이겠는가? 물론 백준과 같이 거의 대부분의 환경에서 입력값이 동적으로 변하는 경우에는 사용해도 효과를 보는 게 거의 불가능하겠지만, 만약 실제 환경에서 입력값, 혹은 결과값이 그리 동적으로 변경되는 환경이 아니라면 적극적으로 constexpr을 사용해도 될 것이다.