mir.pe (일반/어두운 화면)
최근 수정 시각 : 2024-11-03 15:55:39

C++/문법/상수 표현식

파일:관련 문서 아이콘.svg   관련 문서: C++
, C++/문법/클래스
, 컴파일러
, Zig
,
,

파일:상위 문서 아이콘.svg   상위 문서: C++/문법
프로그래밍 언어 문법
{{{#!wiki style="margin: -16px -11px; word-break: keep-all" <colbgcolor=#0095c7><colcolor=#fff,#000> 언어 문법 C( 포인터 · 구조체 · size_t) · C++( 자료형 · 클래스 · 이름공간 · 상수 표현식 · 특성) · C# · Java · Python( 함수 · 모듈) · Kotlin · MATLAB · SQL · PHP · JavaScript · Haskell( 모나드)
마크업 문법 HTML · CSS
개념과 용어 함수( 인라인 함수 · 고차 함수 · 람다식) · 리터럴 · 상속 · 예외 · 조건문 · 참조에 의한 호출 · eval
기타 == · === · deprecated · NaN · null · undefined · 배커스-나우르 표기법
프로그래밍 언어 예제 · 목록 · 분류 }}}

1. 개요2. 자명성3. 변수
3.1. constexpr 상수3.2. constinit 초기화
4. 함수
4.1. constexpr 함수4.2. consteval 함수
5. 컴파일 문맥
5.1. 표준 라이브러리: is_constant_evaluated()5.2. if consteval
6. 상수 진리값
6.1. static_assert6.2. 조건적 noexcept6.3. if constexpr6.4. 조건적 explicit
7. 클래스
7.1. 정적 데이터 멤버7.2. 비정적 멤버 함수7.3. 정적 멤버 함수
8. 상수 표현이 가능한 경우
8.1. 암시적 형변환
8.1.1. 참조8.1.2. 포인터
9. 불가능한 경우
9.1. 형변환

1. 개요

Constant Expressions
C++11 부터 도입된 상수 표현식, 또는 상수식은 실제 코드가 실행되는 사용자 시점(런타임)이 아니라 컴파일 시점으로 코드의 평가를 앞당길 수 있는 획기적인 기능이다. C++의 킬러 요소라고 말할 수 있는 핵심 기능이다 [1]. 상수 표현식의 의미는 해당 코드는 결정론적인 동작을 하며, 초기 조건이 주어지면 유한한 절차의 알고리즘을 통해 무조건 결과를 알 수 있는 표현식이란 뜻이다. 그리고 결정론적인 코드란 로컬 머신안에서, 사용자의 코드에서 결정할 수 있는 코드다. 바이너리가 생성되는 컴파일 시점에 실행 결과가 결정되기 때문에 아무런 평가 과정도 컴파일 이후에 남지 않는다. 곧 프로그램 이용자의 실행 시점에는 코드 실행 시간이 0이 되도록 최적화된다. 예를 들어 상수 시간에 실행되는 함수는 실행 시점(런타임)이 아니라 컴파일 시점(컴파일 타임), 심지어는 IDE에서 바로 값을 볼 수도 있다. 이토록 강력한 기능이지만 상수 표현식을 사용할 수 있는 조건은 제한되어 있으며 작성하기 까다로운 부분이 존재한다. 그래도 조건은 계속 완화되고 있다. C++14까지는 일부 생성자와 getter 함수나 지원했지만 C++23에 오면 IO, 동시성 프로그램 말고는 죄다 상수식으로 취급한다고 보면 된다.

2. 자명성

컴파일 시점에 결과를 결정하는 기능을 추가할 수 있었던 건 클래스 문서에서도 설명한 자명한 자료형의 덕이 컸다. 자명하다는 것은 방정식에 명백한 해가 존재한다는 뜻이다. 자명한 자료형은 어떠한 외부 의존성없이 스스로 존재하고 스스로 값을 결정하고 스스로 파괴될 수 있다. 이것을 결정론적인 동작을 한다고 부른다. 결정론적인 코드는 초기값이 들어오면 반드시 상수 시간에 결과를 알 수 있다. 다시 말하면 프로그램에 필요한 메모리를 컴파일 시간에 알 수 있고, 어떤 동작을 할 지 컴파일 시간에 알 수 있고, 마지막에 어떻게 소멸하는지 컴파일 시간에 알 수 있다. 하지만 자명함을 만족시키려면 조건이 따른다. 무조건 사용자의 코드 안에서 모든 것을 결정할 수 있어야 한다. 만약 자료형에 외부 의존성이 있으면 평가 과정이 유한할지 무한할지, 성공할지 실패할 지 알 수가 없다. 여기서 외부 의존성이란 외부 연결을 통해 동적으로 가져온 함수 또는 운영체제 호출을 말한다. 운영체제 호출 때문에 당장 실패할지 성공할지 모르는 코드는 상수식이 아니다. 외부 라이브러리의 경우 같이 제공되는 헤더 파일에 완전히 정의된 클래스나 함수가 제공되면 상수식으로 만들 수 있다. 그렇지만 보통 동적 라이브러리의 경우 라이브러리의 초기화, 정리 및 함수 호출에도 운영체제의 도움이 필요하다. 그리고 정적 라이브러리는 컴파일러의 도움을 받아도 사용자 단에서는 정적 라이브러리 파일에서 가져온 변수의 값 또는 함수의 정의를 알 수 없다.

3. 변수

3.1. constexpr 상수

constexpr 자료형 변수 식별자;
constexpr 자료형 변수 식별자{};
constexpr 자료형 변수 식별자 = 상수 값;
constexpr 자료형 변수 식별자{ 상수 값 };
inline constexpr 자료형 변수 식별자 = 상수 값;
constexprC++11
자료형 앞에 평가 지시자 constexpr를 덧붙여 컴파일 시점에 값이 초기화되는 상수임을 나타낼 수 있다. 또한 선택적으로 static, extern, thread_local, inline 등의 연결성 지시자도 붙일 수 있다. constexpr 변수는 무조건 상수(const)이며, 컴파일 시점에 값이 정해진다. 그렇기 때문에 실행 시점에는 변수의 생성 실패, 메모리 오버헤드 등 어떠한 문제도 발생하지 않는다. 정확하게는 문제가 발생해도 컴파일 시점에 알 수 있다.

constexpr 상수의 자료형은 자명해야(Trivial) 한다. 가령 C++의 모든 원시 자료형(Primitive) 또는 원시 자료형의 별칭 자료형(Alias)은 자명한 자료형이다. 모든 종류의 열거형은 원시 자료형에서 상속받으므로 마찬가지로 자명한 자료형이다. 사용자 정의 클래스의 경우는 조금 복잡하다. 클래스에서 자명한 클래스의 조건을 다시 보면, 오로지 단일한 default 생성자, default 소멸자가 있어야 하며, 복사 혹은 이동 생성자를 갖고 있으면 default여야 하고, 복사 혹은 이동 대입 연산자를 갖고 있으면 default여야 한다. virtual 소멸자를 갖고 있으면 안되고, 가상 클래스를 상속받아도 안되고, 추상 클래스이면 안되고, 비정적 참조형 데이터 멤버를 가질 수 없다. 또한 모든 비정적 데이터 멤버도 상기한 조건을 만족해야 하며, 마찬가지로 상속 구조에서 모든 부모 클래스도 상기한 조건을 만족해야 한다. 사실 가상 클래스나 운영체제 파생 클래스를 안쓰면 그럭저럭 만족시킬 수 있지만 default 생성자와 default 소멸자가 활용에 제약을 많이 줘서 잘 쓰이진 않는다. 마지막으로 어떤 자료형의 것이든 간에 포인터도 const T*, T *const 상관없이 자명한 자료형이다. 모든 종류의 포인터라는 건 미완성된 클래스 혹은 운영체제 객체의 포인터도 포함된다. 이를 이용해서 자명한 클래스의 조건을 조금 우회할 수 있다. 하지만 포인터에서 객체를 얻어내는 순간 컴파일 문맥에서 탈출하므로 메모리 할당 외에는 용도가 제한된다.

inline
한편 inline을 붙이면 아예 컴파일 시점에 모든 변수가 값으로 바뀌게 컴파일러를 유도할 수 있다. 이름공간 안에 있는 inlineconst 상수는 외부 연결이 적용된다. 마찬가지로 이름공간 안에 있는 inline constexpr 상수도 외부 연결이 적용되어 선언만 있고 값을 즉시 할당하지 않아도 괜찮다. 코드 어딘가에 그 inline constexpr 상수에 값을 할당하는 구문이 있으면 반드시 컴파일 시점에 값을 할당한다. 하지만 이름이 같은데 자료형이 다른 inline constexpr 상수가 있으면 컴파일 오류가 발생하므로 유의해야 한다.

한편 정적인 static constexpr 상수는 반드시 inline이며 곧 static inline constexpr을 암시한다.

3.2. constinit 초기화

constinit 자료형 변수 식별자;
constinit 자료형 변수 식별자{};
constinit 자료형 변수 식별자 = 상수 값;
constinit 자료형 변수 식별자{ 상수 값 };
static constinit 자료형 변수 식별자 = 상수 값;
constinitC++20
자료형 앞에 평가 지시자 constinit을 덧붙여 컴파일 시점에 값이 초기화되는 변수임을 나타낼 수 있다. 또한 선택적으로 static, extern, thread_local, inline 등의 연결성 지시자도 붙일 수 있다. constinit는 오직 변수의 상수 시간 초기화를 위해 추가된 기능이다. constexpr에서 초기화 기능만 이용하는 셈이다. 그래서 constinit 변수는 constexpr 변수와는 다르게 상수가 아니다. 할 이유는 없지만 constinit const 상수는 constexpr과 같은 동작을 한다.

4. 함수

4.1. constexpr 함수

[[특성]]
constexpr 반환 자료형 함수 식별자(매개변수1-자료형 매개변수1, 매개변수2-자료형 매개변수2, ...)
{
...
}
constexprC++11
해당 함수가 상수 표현식임을 나타낸다. constexpr 함수는 무조건 inline이어야 한다. 따라서 함수의 선언과 정의를 같이 해야 한다. 자료형 앞에 constexprC++11 이나 constevalC++20 평가 지시자를 덧붙여 컴파일 시점에 결과가 정해질 수 있는 함수임을 나타낼 수 있다. 또한 선택적으로 static, extern, inline 등의 연결성 지시자도 붙일 수 있다.

상수 표현식 함수 사용에는 커다란 제약이 있다. 평가가 컴파일 시점에 이루어져야 하기 때문에 반환 자료형이 자명해야 한다. 또한 함수의 지역변수도 자명한 자료형이어야 한다. 때문에 사용할 수 있는 자료형의 제한이 매우 크다. 다행히 매개변수의 경우 포인터나 참조형이라도 문제가 없다.

<C++ 예제 보기>
#!syntax cpp
class MyClass
{
public:
    // C++11
    constexpr int GetValue() const noexcept
    {
        return myValue;
    }

    // C++11
    constexpr void SetValue(const int value) noexcept
    {
        myValue = value;
    }

    // C++14
    constexpr int CompareTo(const int value) const noexcept
    {
        if (myValue < value)
        {
            return -1;
        }
        else if (value < myValue)
        {
            return 1;
        }
        else
        {
            return 0;
        }
    }

    // C++17
    // #include <initializer_list>
    static constexpr long long SumUp(std::initializer_list<MyClass> list) noexcept
    {
        long long result{};
        
        for (auto& inst : list)
        {
            result += inst.myValue;
        }

        return result;
    }

    // C++17
    constexpr void Serialize(int* const memory) const noexcept
    {
        if (nullptr != memory)
        {
            *memory = myValue;
        }
    }

    // C++17
    constexpr void Deserialize(const std::byte* memory)
    {
        if (nullptr != memory)
        {
            myValue = static_cast<int>
            (
                (static_cast<std::uint64_t>(static_cast<std::uint8_t>(memory + 3)) << 24U) |
                (static_cast<std::uint64_t>(static_cast<std::uint8_t>(memory + 2)) << 16U) |
                (static_cast<std::uint64_t>(static_cast<std::uint8_t>(memory + 1)) << 8U) |
                (static_cast<std::uint64_t>(static_cast<std::uint8_t>(memory)))
            );
        }
    }

    // C++20
    // import <cstddef>;
    // import <cstdint>;
    // import <concepts>;
    template <std::integral T>
    [[nodiscard]]
    static constexpr std::uint8_t BitShift(const T& value, const std::size_t& times) noexcept
    {
        return static_cast<std::uint8_t>(value >> (times * 8ULL)) & 0XFFU;
    }

    // C++20
    // import <bit>;
    constexpr void Serialize(std::byte* const memory) const
    {
        if (nullptr != memory)
        {
            const std::uint64_t longer = static_cast<std::uint64_t>(myValue);
            memory[0] = std::bit_cast<std::byte>(BitShift(longer, 0));
            memory[1] = std::bit_cast<std::byte>(BitShift(longer, 1));
            memory[2] = std::bit_cast<std::byte>(BitShift(longer, 2));
            memory[3] = std::bit_cast<std::byte>(BitShift(longer, 3));
        }
    }

    // C++23
    // import <memory>;
    constexpr std::unique_ptr<std::byte[]> Serialize() const
    {
        if (nullptr != memory)
        {
            auto result = std::make_unique<std::byte[]>(sizeof(myValue));

            Serialize(result.get());

            return std::move(result);
        }
    }

    int myValue;
};
constexpr 함수의 명세는 C++ 판본이 올라갈 때 마다 급격히 변화했다. 상수 표현식은 C++의 킬러 기능으로 자리 잡아가면서 점점 포괄적인 기능이 되어간다. C++의 최종 목표는 최대한 사용자 단에서의 실행 시간 단축을 목표로 하고 있고 상수 표현식은 그 목적에 잘 부합한다고 말할 수 있다. C++11에서는 단 한줄의 코드 혹은 복사/이동이 모두 가능한 자료형만 사용할 수 있었다. C++14에서는 간단한 조건문, 반복문 정도는 쓸 수 있게 되었다. C++17에선 어셈블리 구문, 동적 메모리 할당, 예외 처리 [2], 운영체제 호출 정도를 빼면 전부 가능하게 되었다.

그 결과 C++11에서는 진짜 O(1)인 함수였는데 C++17에서는 꼭 그렇지는 않게 되었다. C++20에선 동적 메모리 할당 조차 컴파일 시점에 실행될 수 있다. 이러면 할당 위치만 힙이고 작동은 스택처럼 구현되어 힙의 내용이 컴파일 시점에 결정된다. 이런 식으로 동적 배열 클래스 std::vector, 문자열 클래스 std::stringconstexpr 생성자, 소멸자와 메서드를 얻었다. 상수 표현식의 내용이 포괄적으로 바뀐 셈인데, 좋을 것만 같지만 이러다 보니 컴파일 시간이 너무 오래 걸리고, 키워드의 의미가 배보다 배꼽이 더 커지는 일이 일어났다. 때문에 매크로도 대체할 겸 진짜 의도를 되살리기 위해 C++20에서 후술할 consteval 키워드가 도입되었다. C++23에 와서는 진짜로 컴파일 시점에 평가될 수도 있는 함수가 되었다. 그리고 아예 constexpr 함수 안에서 constexpr가 아닌 함수를 호출할 수 있게 됐다. 왜냐하면 함수가 실행되면 그저 컴파일 문맥이 아닐 뿐 constexpr의 의미를 해치는 건 아니기 때문이다. 컴파일 문맥에 대해서는 후술.

4.2. consteval 함수

[[특성]]
consteval 반환 자료형 함수 식별자(매개변수1-자료형 매개변수1, 매개변수2-자료형 매개변수2, ...)
{
...
}
constevalC++20
해당 함수가 상수 표현식이고, 문맥 상으로도 반드시 컴파일 시점에 평가 되어야함을 나타낸다. 보면 알겠지만 constexpr의 처음 도입조건보다 더 엄격한 조건을 갖고 있다. 함수 내부와 매개 변수에서도 정적이고 컴파일 시점에 결정되는 자료형 또는 객체가 아니면 참조할 수 없다. 컴파일 시점에 값이 결정된다는 것은, 사용자 입장에서는 언제나 고정된 값으로 보인다는 뜻이다. 때문에 consteval 함수는 즉발 함수(Immediate Function)라고 불리며 어떤 부작용(Side Effect) 없이 독립적으로 실행되는 함수다[3]. 그래서 consteval 함수는 코드 상에서만 함수로 보이는, 사실상 상수라고 봐야 한다.

이 지시자의 의의는 컴파일러 전용 함수를 C++에 구현한다는 것에 있다. constexpr 상수[4]는 C의 전처리기 키워드를 대체할 수 있었다. consteval은 이제 위험한 매크로의 일부를 대체할 수 있다. 예를 들어서 전처리기 분기를 통해[5] 어떤 상수를 선언한다면, 예전에는 전처리기와 매크로 지옥에서 빠져나오지 못했다. 이런 전처리기 구문들은 프로그램 헤더 구조의 저 멀리 최상단에 놓이게 될텐데 때문에 중복되는 헤더 삽입, 전처리기 키워드 중복 문제가 발생한다. 그러나 이젠 consteval에서 C++ 코드를 통해 밖으로 보이지 않고 처리가 가능하다.

5. 컴파일 문맥

그래서 클래스 문서에서 상수 표현식의 근간을 알아보았고, 이 문서에선 앞서 상수 표현식이 어떻게 C++에서 나타내지는지 알아보았다. 그러면 상수 표현식이 어떻게 실행되는지 원리를 알아볼 차례다. 상수 표현식은 어떻게 컴파일 시점에 평가되는 걸까? 이 컴파일 시점이 정말 컴파일할 때 constexpr인 함수와 변수가 모조리 미리 처리된다는 뜻일까? 안타깝게도 그렇지 않다. 상수 표현식인 함수의 매개변수들이 컴파일 시점에 결정될 수 있어야 상수 함수들도 컴파일 시점에 평가될 수 있다. 그래서 컴파일 시점에 실행되는 constexpr, consteval 함수의 정확한 개형은 다음과 같다.
constexpr 반환 자료형 함수 식별자(constexpr 매개변수1-자료형 매개변수1, constexpr 매개변수2-자료형 매개변수2, ...)
{
...
}
consteval 반환 자료형 함수 식별자(constexpr 매개변수1-자료형 매개변수1, constexpr 매개변수2-자료형 매개변수2, ...)
{
...
}
그러니까 C++의 명세에 기재되어있지 않을 뿐 함수의 매개변수에 constexpr 평가 지시자가 붙어야 하는 것이다. 그렇지만 정말 이렇게 명세가 정해졌다면 상수 표현식 함수들의 응용폭이 매우 좁아졌을 것이다. 또한 동일한 기능을 하더라도 상수 표현식이 아닌 함수, 상수 표현식인 함수를 둘 다 만들어야 한다. 그래서 constexpr 함수는 컴파일 시점에 평가될 수도 있는 함수로 축소되었으며 constexpr 매개변수는 추가되지 않았다 [6].

결론적으로 constexpr 함수에는 constexpr 상수가 들어오고, constexpr 멤버 함수는 실행하는 클래스의 인스턴스가 자명하게 컴파일 시점에 존재해야 컴파일 시점에 평가될 수 있다는 뜻이다. 그리고 constexpr 인스턴스가 존재하고, constexpr 매개변수가 존재하는 시점을 컴파일 문맥이라고 칭한다. 곧 프로그램의 어느 시점에 constexpr 매개변수가 들어오지 않으면 컴파일 문맥에서 나간 것이다. 유념할 점은 constexpr 매개변수라고는 했지만 정말 constexpr 상수를 선언해서 넣으라는 뜻이 아니라, 평가 과정에서 상수 시간에 값을 결정할 수 있는 매개변수를 뜻한다. 다시 말하자면, 컴파일 문맥은 컴파일러가 프로그램을 번역하는 모든 과정을 말하는 게 아니라 상수 시간에 코드와 값을 평가할 수 있는 때 까지가 컴파일 문맥이다.

상수란 그동안 써왔던 불변하지 않는 변수와는 조금 의미가 다르다. 여기서 상수는 함수형 언어에서도 나오는 자명하게 정의된 어떤 불변하지 않는 이다. 프로그래밍을 처음 배웠을 때로 돌아가보자. 변수, 내지는 필드는 어떤 물건을 담는 항아리에 빗대어 배운 적이 있는가? 우리는 그 물건을 다양하게 담고, 어떤 물건을 담을지( 자료형) 관심이 있었지만 담는 과정에는 관심이 없었다. "값" 항아리(Memory)와 알고리즘을 넘어 값 자체에 대해 의미( Meta)를 부여하고 어떻게 움직이는지 흐름을 추적하는 과정이 C++의 본질이다. 하물며 함수에 값이 어떻게 전달되고 언제 전달되는 것까지 말이다. 자료형 문서에서 값 범주(Value Category)론에 대해 봤다면 사실 거의 아무도 관심은 없겠지만, 그게 C++이 어떻게 돌아가는지 명료하게 표현한 방법론이다. 값의 흐름을 알아내는 것은 C++이 할 수 있는 첫번째 일이다. constexpr, consteval 함수는 이 과정을 최종사용자에게 방해가 되지 않는 때로 앞당겨 성능을 지대하게 향상시킬 수 있다. C++은 계속 메타 프로그래밍을 도입하고 있으며 실제로 이것이 개발자에게도, 최종 사용자에게도 도움이 되어주고 있다. 함수의 결과물 조차 어떤 값이며, 프로그램 자체가 값을 넣으면 어떤 값이 나오는 거대한 함수나 다름없으니까. C언어의 그늘에 가려져 빛을 보지 못하고 있긴 하지만 언어 표준은 명백히 이 흐름을 추종하고 있다. 할 수 있는 최소한의 메모리, 최소한의 실행속도로 코드의 평가 결과를 도출해내는 것이 C++의 궁극적 목표다.

<C++ 예제 보기>
#!syntax cpp
import <limits>;
import <optional>;

constexpr std::optional<float> Power(const float value, int times) noexcept
{
    constexpr auto inf = std::numeric_limits<float>::infinity();
    constexpr auto low = -inf;

    if (inf <= value or value <= low)
    {
        return std::nullopt;
    }

    float result = value;

    if (0 < times)
    {
        for (int i = 1; i < times; ++i)
        {
            result *= value;
        }
    }
    else
    {
        do
        {
            result /= value;
        }
        while (0 < (--times));
    }

    return result;
}

constexpr float fun_0(int exp)
{
    return Power(20.4f, exp).value_or(FP_NAN);
}

constexpr float fun_1(float init, int count)
{
    for (int i = 0; i < count; ++i)
    {
        init = init + (init + 1);
    }

    return init;
}

// C++23
constexpr auto fun_2() noexcept
{
    try
    {
        throw "Ok?";
    }
    catch (...)
    {
        return 0;
    }

    return 1;
}

int aaa()
{
    constexpr auto value_0 = Power(3, 5);

    constexpr auto value_1 = Power(value_0.value(), 5);

    constexpr auto value_2 = Power(value_1.value(), -3);

    const auto value_3 = Power(value_2.value(), 2);

    const auto value_4 = Power(10.0f, 4);

    constexpr auto value_5 = fun_2();

    const auto value_6 = fun_2();

    const auto value_7 = fun_0(6);

    const auto value_8 = fun_1(1e2, 5);

    const auto value_9 = fun_1(3e-6, -10);
}
상기한 예제에서 어디까지가 상수로 평가되는지 추적해보자.
Phases of Translation
컴파일 과정의 9번째 단계 이후 제일 먼저 상수 표현식을 실행한다.

5.1. 표준 라이브러리: is_constant_evaluated()

[[nodiscard]] constexpr bool is_constant_evaluated();
std::is_constant_evaluated()C++11
표준에서는 현재 문맥이 컴파일 문맥인지 아닌지 판별하는 함수를 제공한다. 이 자체도 constexpr 함수이며 constexpr bool 변수에 담아 이용할 수도 있다. 그럼 이런 함수가 왜 존재할까? 바로 상수 표현식이라고 무조건 성능에 이점이 있지는 않기 때문이다. 아니 앞에서 C++의 궁극적 표상같은 서술을 하고 나서는 이게 무슨 소리일까? C++의 킬러 기능이 사실 아무것도 아닌 거라는 말은 아니다. 상수 표현식의 한계점은 컴파일러가 최종 사용자의 코드와 함께, 같이 평가되어야 한다. C++ 안에서는 인라인(inline) 함수여야 한다. constexpr 변수도 마찬가지로 컴파일 시점에 값이 들어와야 한다. 그러니까 외부 의존성 같은건 꿈도 못꾼다. 운영체제 호출은 물론 컴파일러가 내부적으로 제공하는 함수까지 못 쓴다! 예를 들어 std::memcpy, jmalloc 같은 고성능 메모리 기능은 사용할 수 없다. 심지어 C언어의 표준 라이브러리 함수들 조차 constexpr가 아니라서 못 쓴다 [7]. 그러니까 지금까지 외부에서 가져다 썼던 모든 기능들을 다시 처음부터 직접 구현해야 한다. 순수하게 C++안에서 말이다. C++이 고성능 언어라 다행인 점은 차치하고, C언어 라이브러리와 운영체제 호출은 어떻게 해결할 건가? 결정적으로 메모리 연산은 어떻게 해결할 것인가? C++에서 메모리 연산은 reinterpret_cast<char*>로 변환하고 1바이트씩 한땀한땀 건드리는 방법 뿐이 없는데 사실 이건 정말 정말 느리다.

두번째 이유로는 전술했듯이 상수 표현식인 함수가 실제로 컴파일 시점에 평가되려면 들어오는 값, 즉 매개변수를 비롯하여 함수의 의존성을 포함하는 값들이 컴파일 시점에 존재하고, 실제 값이 평가되어야 함수도 따라서 평가될 수 있기 때문이다. 간단히 말해서 함수의 매개변수들을 컴파일 시점에 알 수 있으면 상수 표현식인데, constexpr 매개변수가 C++11 당시 탈락하면서 진짜 컴파일 시점인지 아닌지 알 방법이 없었다.

그렇기 때문에 컴파일 문맥인지 아닌지에 따라 처리 방법을 달리 해줘야 하는 것이다. 이렇게 하면 스레드, 코루틴, 어셈블리어 구문, 네트워크, 파일 시스템 접근을 제외한 갖가지 처리를 컴파일 시점에서 처리할 수 있다. 어셈블리어를 빼면 애초에 프로세스 외부로 나가는 인터페이스이기에 필연적으로 외부 의존성이 생겨서 C++ 단일 프로그램 안에서는 처리할 수도 없다. 실제로 C++20에서 동적 메모리 할당(new, std::allocator, std::construct_at 등)과 해제까지 상수 표현식으로 포함되면서 저것들만 남았다. C++23에선 void*에서 형변환까지 상수 표현식으로 포함되었다. 한동안 상수 표현식의 범주는 변하지 않을 것으로 사료된다.

<C++ 예제 보기>
#!syntax cpp
constexpr std::optional<float> Power(const float value, int times) noexcept
{
    constexpr auto inf = std::numeric_limits<float>::infinity();
    constexpr auto low = -inf;

    if (inf <= value or value <= low)
    {
        return std::nullopt;
    }

    if (std::is_constant_evaluated())
    {
        float result = value;

        if (0 < times)
        {
            for (int i = 1; i < times; ++i)
            {
                result *= value;
            }
        }
        else
        {
            do
            {
                result /= value;
            }
            while (0 < (--times));
        }

        return result;
    }
    else // IF (consteval)
    {
        return std::pow(value, times);
    } // IF not (consteval)
}
앞선 예제에서 봤던 제곱함수 Power는 사실 런타임 시점에서 더 느려진다. 실수의 계산은 실행 시점에 CPU 클럭에 기대는 것보다는 컴파일러가 실수 연산에 하드웨어의 도움을 받도록 힌트를 주는 게 좋다. 보통 C언어 표준 라이브러리 헤더인 <math.h> 혹은 C++에서 <cmath> 모듈의 std::powf, std::pow, std::powl를 사용하면 런타임에 더 효율적 연산이 가능하다.

한편 이 함수는 inline constexpr 함수인데 구현을 컴파일러에게 맡겨서 구현 내용을 알 수 없었던 특징이 있었다. 사실 이 함수에는 C++20에 추가된 consteval이 더 어울리며, 표준에서도 if consteval을 이용해 새롭게 제정되었다.

5.2. if consteval

if constevalC++20
C++20에 추가된 상수 표현식을 판별할 수 있는 수단이다. 평소 if 문을 쓰던 방식 그대로 조건 자리에 consteval을 적으면 컴파일 문맥인지 아닌지 즉시, 바로 알 수 있다. 심지어 이 판별 과정은 실행 시점(런타임)이 아니라 컴파일 시점에 바로 가려지기 때문에 판별 결과에 따라 탈락된 코드는 아예 컴파일 과정에서 빠져버린다. 이는 아예 코드가 처음부터 없었던 걸로 처리되는 것이라 디버그 모드에서도 없어진 코드를 볼 수 없다.

6. 상수 진리값

6.1. static_assert

6.2. 조건적 noexcept

noexcept(bool-condition)

6.3. if constexpr

Constexpr IfC++17

6.4. 조건적 explicit

explicit(bool-condition)C++20

7. 클래스

7.1. 정적 데이터 멤버

메타 데이터 (Meta Data)

7.2. 비정적 멤버 함수

7.3. 정적 멤버 함수

8. 상수 표현이 가능한 경우

8.1. 암시적 형변환

  1. 원시 자료형의 static_cast<T>
  2. constexpr 형변환 연산자가 지원되는 클래스의 static_cast<T>
  3. static_cast<T>으로 호환이 가능한 C 방식의 형변환 (T), T()
  4. static_cast<T>으로 호환이 가능한 const_cast<T>
  5. decltype(auto)
  6. decltype(expr)

8.1.1. 참조

  1. 상수 문맥 안의 this
  2. 컴파일 문맥 동안 이루어지는 임시 객체를 만들지 않는 참조 변수 선언 [8]

8.1.2. 포인터

  1. 함수의 포인터로의 형변환
  2. 클래스 멤버 함수의 포인터로의 형변환
  3. 오버로딩된 클래스 멤버 함수를 찾기 위해 정확한 함수 포인터로의 형변환 [9]
  4. nullptrstd::nullptr_t로의 형변환
  5. 배열의 포인터로의 형변환 [10]

9. 불가능한 경우

  1. 어셈블리
  2. C언어의 가변 인자: vs_arg
  3. 상수 문맥 밖의 this

9.1. 형변환

  1. 형변환 연산자가 constexpr을 지원하지 않는 클래스의 static_cast<T>
  2. static_cast<T>으로 호환이 불가능한 C 방식의 형변환 (T), T()
  3. static_cast<T>으로 호환이 불가능한 const_cast<T>
  4. reinterpret_cast<T>
  5. dynamic_cast<T>


[1] 현재는 Zig 정도가 상수 표현식 기능을 제공한다 [2] 이것도 동적 메모리 할당을 사용한다 [3] 함수형 언어에서 함수와 정확히 같다 [4] [5] 운영체제 플랫폼 구분 등 [6] 그러나 추후 표준에서 이 기능을 다시 넣을 준비를 하고 있다 [7] 이 부분은 C++26에서 <cmath>의 수학 함수들을 constexpr로 만들면서 일부분 해결됐다 [8] const T& 는 rvalue를 받으면 임시 객체를 만든다 [9] 오버로딩된 멤버 함수는 static_cast<Class::(*)(type param)>(&Class::method)와 같이 얻어내야 한다 [10] 반대는 불가능하다

파일:CC-white.svg 이 문서의 내용 중 전체 또는 일부는
문서의 r381
, 8번 문단
에서 가져왔습니다. 이전 역사 보러 가기
파일:CC-white.svg 이 문서의 내용 중 전체 또는 일부는 다른 문서에서 가져왔습니다.
[ 펼치기 · 접기 ]
문서의 r381 ( 이전 역사)
문서의 r ( 이전 역사)