LearnCPP - F

Justin·2026년 2월 21일

LearnCPP.com

목록 보기
12/22

F.1 — Constexpr 함수 (1부)

상수 표현식에는 한 가지 불편한 점이 있습니다. 바로 일반 함수는 상수 표현식 안에서 호출할 수 없다는 것입니다.

#include <iostream>

int main() {
	constexpr double radius { 3.0 };
    constexpr double pi { 3.1459265359 };
    constexpr double circumference { 2.0 * radius * pi };
    
    std::cout << "Our circle has circumference " << circumference << '\n';
}

이 코드를 실행하면 다음과 같은 결과가 나옵니다.
Our circle has circumference 18.8496

코드를 깔끔하게 만들기 위해 이 계산 과정을 함수로 분리해 보겠습니다.

#include <iostream>

double calcCircumference(double radius)
{
    constexpr double pi { 3.14159265359 };
    return 2.0 * pi * radius;
}

int main()
{
    constexpr double circumference { calcCircumference(3.0) }; // 컴파일 에러 발생
    std::cout << "Our circle has circumference " << circumference << "\n";
    return 0;
}

안타깝게도 이 코드는 컴파일되지 않습니다.
constexpr 변수인 circumference는 초기값으로 반드시 상수 표현식을 가져야 합니다.
하지만 우리가 만든 calcCircumference()는 일반 함수이기 때문에 상수 표현식으로 인정받지 못합니다.

C++에서는 오직 상수 표현식만 허용되는 상황들이 있습니다.
이런 상황에서 함수를 사용하고 싶은데 일반 함수는 쓸 수 없다면 어떻게 해야 할까요?


Constexpr 함수는 상수 표현식에서 사용할 수 있습니다

constexpr 함수는 상수 표현식 안에서 호출할 수 있도록 특별히 허용된 함수입니다.
함수를 constexpr 함수로 만드는 방법은 아주 간단합니다.
함수의 반환 타입 맨 앞에 constexpr 키워드만 적어주면 됩니다.

위에서 에러가 났던 예제를 constexpr 함수를 이용해 고쳐보겠습니다.

#include <iostream>

constexpr double calcCircumference(double radius) // 이제 constexpr 함수입니다
{
    constexpr double pi { 3.14159265359 };
    return 2.0 * pi * radius;
}

int main()
{
    constexpr double circumference { calcCircumference(3.0) }; // 이제 컴파일됩니다

    std::cout << "Our circle has circumference " << circumference << "\n";

    return 0;
}

이제 calcCircumference()constexpr 함수가 되었기 때문에,
circumference 변수를 초기화하는 것과 같은 상수 표현식 안에서 당당하게 사용할 수 있습니다.


Constexpr 함수는 컴파일 타임에 계산될 수 있습니다

5.5강에서 배운 내용을 떠올려 봅시다. constexpr 변수를 초기화할 때처럼 반드시 상수 표현식이 필요한 곳에서는, 그 값이 컴파일 타임에 미리 계산되어야 합니다. 만약 그 안에 constexpr 함수가 있다면, 그 함수 역시 컴파일 타임에 계산되어야만 합니다.

우리가 작성한 예제에서 circumference 변수는 constexpr이므로 컴파일 타임에 값이 필요합니다.
따라서 컴파일러는 calcCircumference(3.0)을 프로그램 실행 전에(컴파일 타임에) 미리 계산합니다.

함수 호출 결과를 알아낸 컴파일러는 코드의 함수 호출 부분을 그 결과값으로 완전히 바꿔치기 합니다.
즉, calcCircumference(3.0)이라는 코드가 18.8496이라는 결과값으로 바뀌게 됩니다.
사실상 컴파일러는 아래와 같은 코드를 컴파일하게 되는 셈입니다.

#include <iostream>

constexpr double calcCircumference(double radius)
{
    constexpr double pi { 3.14159265359 };
    return 2.0 * pi * radius;
}

int main()
{
    constexpr double circumference { 18.8496 };

    std::cout << "Our circle has circumference " << circumference << "\n";

    return 0;
}

함수가 컴파일 타임에 계산되려면 다음 두 가지 조건도 반드시 충족해야 합니다.

  • 함수에 넘겨주는 인자 값을 컴파일 타임에 미리 알 수 있어야 합니다. (예: 인자가 상수 표현식이어야 함)
  • constexpr 함수 내부의 모든 코드와 수식들이 컴파일 타임에 계산 가능한 것이어야 합니다.
    (만약 constexpr 함수 안에서 다른 함수를 부른다면, 그 함수 역시 컴파일 타임에 계산할 수 있는 함수여야만 합니다.)

Constexpr 함수는 런타임(프로그램 실행 중)에도 실행될 수 있습니다

constexpr 함수라고 해서 무조건 컴파일 타임에만 써야 하는 것은 아닙니다.
런타임에도 얼마든지 사용할 수 있으며, 이때는 constexpr이 아닌 일반 결과값을 반환합니다.

#include <iostream>

constexpr int greater(int x, int y)
{
    return (x > y ? x : y);
}

int main()
{
    int x{ 5 }; // constexpr 아님
    int y{ 6 }; // constexpr 아님

    std::cout << greater(x, y) << " is greater!\n"; // 런타임에 평가됩니다

    return 0;
}

이 예제에서 변수 xy는 상수가 아니기 때문에, 함수에 전달되는 값을 컴파일 타임에는 알 수 없습니다.
따라서 이 함수는 컴파일 타임에 계산되지 못합니다. 하지만 문제없습니다.
프로그램이 실행될 때(런타임) 정상적으로 함수가 호출되어, 우리가 기대하는 일반적인 int 값을 무사히 반환합니다.

핵심 포인트
constexpr 함수가 런타임에 실행될 때는 일반 함수와 완전히 똑같이 동작합니다.
즉, 런타임에는 constexpr이라는 키워드가 아무런 영향도 주지 않습니다.

핵심 포인트
C++에서 constexpr 함수가 컴파일 타임과 런타임 양쪽에서 모두 실행될 수 있도록 허용한 이유는,
단 하나의 함수로 두 가지 상황을 모두 처리하기 위해서입니다.
만약 이게 불가능했다면, 똑같은 역할을 하는 함수를 두 개(컴파일 타임용 함수 하나, 런타임용 일반 함수 하나)나 만들어야 했을 겁니다. 이는 코드를 중복되게 만들 뿐만 아니라, 두 함수의 이름마저 서로 다르게 지어야 하는 번거로움을 초래했을 것입니다!


F.2 — Constexpr 함수 (2부)

상수가 필수가 아닌 문맥에서의 Constexpr 함수 호출

constexpr 함수는 언제나 컴파일 타임에 실행될 거라고 생각하기 쉽지만, 아쉽게도 항상 그런 것은 아닙니다.
레슨 5.5 상수 표현식에서 다루었듯이, 상수 표현식이 반드시 필요하지 않은 상황에서는 컴파일러가 해당 코드를 컴파일 타임에 처리할지,
아니면 프로그램이 실행되는 런타임에 처리할지 자유롭게 선택할 수 있습니다.

따라서 이런 곳에 쓰인 constexpr 함수는 컴파일 타임과 런타임 중 언제든 평가될 수 있습니다.

#include <iostream>

constexpr int getValue(int x)
{
    return x;
}

int main()
{
    int x { getValue(5) }; // 런타임 또는 컴파일 타임에 평가될 수 있음

    return 0;
}

위 예제에서 getValue()constexpr 함수이므로 getValue(5)라는 호출 자체는 상수 표현식입니다. 하지만 변수 xconstexpr로 선언되지 않았기 때문에 굳이 상수로 초기화할 필요가 없습니다. 즉, 우리가 상수 표현식을 제공했음에도 불구하고 컴파일러는 getValue(5)를 컴파일 타임에 미리 계산할지, 아니면 프로그램 실행 중에 계산할지 알아서 결정합니다.

핵심 포인트
constexpr 함수가 무조건 컴파일 타임에 평가된다고 보장할 수 있는 유일한 경우는 상수 표현식이 '반드시 필요한' 상황에 쓰였을 때뿐입니다.


필수 상수 표현식에서의 Constexpr 함수 진단 (오류 확인)

컴파일러는 어떤 constexpr 함수가 실제로 컴파일 타임에 평가되기 전까지는, 이 함수가 컴파일 타임에 제대로 작동하는지 미리 검사할 의무가 없습니다. 즉, 런타임 용도로는 아무 문제 없이 컴파일되지만 막상 컴파일 타임에 쓰려고 하면 오류를 뿜어내는 constexpr 함수를 만들기란 아주 쉽습니다. 다소 억지스럽지만 이해를 돕기 위한 예제를 볼까요?

#include <iostream>

int getValue(int x)
{
    return x;
}

// 이 함수는 런타임에 평가될 수 있습니다.
// 컴파일 타임에 평가될 경우 컴파일 오류가 발생합니다.
// 왜냐하면 getValue(x) 호출을 컴파일 타임에 해결할 수 없기 때문입니다.
constexpr int foo(int x)
{
    if (x < 0) return 0; // C++23의 P2448R1 채택 이전에 필요했던 코드 (아래 참고 확인)
    return getValue(x);  // 여기서 constexpr이 아닌 함수를 호출함
}

int main()
{
    int x { foo(5) };           // 정상: 런타임에 평가됨
    constexpr int y { foo(5) }; // 컴파일 오류: foo(5)는 컴파일 타임에 평가될 수 없음

    return 0;
}

위 코드에서 foo(5)가 일반 변수 x를 초기화하는 데 쓰였을 때는 런타임에 실행되며 정상적으로 5를 반환합니다.

하지만 foo(5)constexpr 변수인 y를 초기화하는 데 쓰이면, 컴파일러는 이를 무조건 컴파일 타임에 계산해야 합니다.
이때 컴파일러는 함수 내부의 getValue()constexpr 함수가 아님을 발견하고, 결국 foo(5)를 컴파일 타임에 계산할 수 없다고 판단해 오류를 발생시킵니다.

그러므로 constexpr 함수를 만들 때는 반드시 상수가 필요한 상황(예: constexpr 변수 초기화)에서 호출해 보아, 컴파일 타임에도 정상적으로 작동하는지 명시적으로 테스트해야 합니다.

모범 사례
모든 constexpr 함수는 컴파일 타임에 실행될 수 있도록 작성해야 합니다. 언젠가는 상수 표현식이 필요한 곳에 쓰이게 될 테니까요.
런타임에는 잘 작동하던 코드가 컴파일 타임에는 실패할 수 있으므로, 항상 상수가 필수인 문맥에서 함수를 테스트하는 습관을 들이세요.

고급 독자를 위한 참고
C++23 이전에는 constexpr 함수를 컴파일 타임에 실행 가능하게 만드는 인자 조건이 단 하나도 없다면 프로그램 자체가 '잘못된 형식'으로 간주되었습니다. 위 예제에서 if (x < 0) return 0; 코드가 없다면 컴파일 타임 실행이 아예 불가능하므로 문제가 됩니다. 하지만 오류 메시지 출력이 필수가 아니었기에 컴파일러가 조용히 넘어갈 수도 있었습니다. 이 까다로운 규칙은 C++23(P2448R1)에서 폐지되었습니다.


Constexpr / Consteval 함수의 매개변수는 Constexpr이 아닙니다

constexpr 함수가 받는 매개변수는 자동으로 constexpr이 되지 않으며, 코드에 constexpr이라고 직접 적을 수도 없습니다.

핵심 포인트
만약 함수의 매개변수가 constexpr이라면, 그 함수는 오직 상수 인자로만 호출될 수 있다는 뜻이 될 것입니다.
하지만 constexpr 함수는 런타임에 실행될 때 일반적인 변수를 인자로 받아 작동할 수도 있어야 하므로 매개변수가 상수로 고정되지 않습니다.

매개변수가 constexpr이 아니기 때문에, 함수 내부에서 상수가 꼭 필요한 곳에 이 매개변수를 써서는 안 됩니다.

consteval int goo(int c)    // c는 constexpr이 아니며, 상수 표현식에 사용할 수 없음
{
    return c;
}

constexpr int foo(int b)    // b는 constexpr이 아니며, 상수 표현식에 사용할 수 없음
{
    constexpr int b2 { b }; // 컴파일 오류: constexpr 변수는 상수 표현식 초기화 값이 필요함

    return goo(b);          // 컴파일 오류: consteval 함수 호출에는 상수 표현식 인자가 필요함
}

int main()
{
    constexpr int a { 5 };

    std::cout << foo(a); // 정상: 상수 표현식 a는 constexpr 함수 foo()의 인자로 사용할 수 있음

    return 0;
}

위에서 인자 a는 상수지만, 정작 함수로 전달된 매개변수 bconstexpr이 아닙니다.
따라서 b를 상수가 필요한 곳(예: 변수 b2 초기화나 goo(b) 호출)에 쓰면 컴파일 오류가 발생합니다.

(참고: constexpr 함수의 매개변수를 const로 선언할 수는 있으며, 이 경우에는 '런타임 상수'로 취급됩니다.)

관련 내용: 상수로만 이루어진 매개변수가 꼭 필요하다면 '11.9 - 타입이 아닌 템플릿 매개변수(Non-type template parameters)' 문서를 참고하세요.


Constexpr 함수는 암시적으로 인라인(Inline) 처리됩니다

컴파일러가 constexpr 함수를 컴파일 타임에 계산하려면, 함수를 호출하기 전에 해당 함수의 전체 내용(정의)을 모두 알고 있어야 합니다.
단순히 "이런 함수가 뒤에 나올 거야"라고 알려주는 전방 선언만으로는 턱없이 부족합니다.

이 말은 즉, 여러 파일에서 호출되는 constexpr 함수는 사용하는 모든 번역 단위(.cpp 파일)마다 그 내용이 전부 복사되어 들어가야 한다는 뜻입니다. 원래 C++에서는 똑같은 함수가 여러 번 정의되면 '단일 정의 원칙(ODR)' 위반으로 오류가 나지만, 이 문제를 피하기 위해
constexpr 함수는 자동으로 '인라인(inline)' 처리되어 ODR 규칙의 예외를 적용받습니다.

결과적으로 constexpr 함수는 어디서든 쉽게 불러올 수 있도록 보통 헤더 파일(.h)에 정의됩니다.

규칙
컴파일러constexpr (또는 consteval) 함수의 단순한 전방 선언이 아닌 전체 코드 내용을 볼 수 있어야 합니다.

모범 사례

  • 단일 소스 파일(.cpp)에서만 쓰는 constexpr/consteval 함수는 해당 파일 내에서 사용되기 전에 미리 작성하세요.
  • 여러 소스 파일에서 공통으로 쓴다면, 어디서든 #include 할 수 있도록 헤더 파일에 작성하세요.

단, 오직 런타임에만 실행될 constexpr 함수라면 전방 선언만으로도 충분합니다.
즉 컴파일 타임에 계산할 필요가 없는 상황이라면, 다른 파일에 정의된 constexpr 함수를 전방 선언만으로 불러와 쓸 수 있습니다.

고급 독자를 위한 참고
C++ 표준(CWG2166)에 따르면, 컴파일 타임에 실행되는 constexpr 함수의 전방 선언에 대한 정확한 규칙은
"해당 함수가 실제로 쓰이기 전 가장 바깥쪽 호출 단계 이전에만 정의되어 있으면 된다"는 것입니다.
따라서 서로가 서로를 호출하는 상호 재귀 함수를 만들기 위해 다음과 같은 코드 작성이 허용됩니다.

constexpr int foo(int);

constexpr int goo(int c)
{
	return foo(c);   // note that foo is not defined yet
}

constexpr int foo(int b) // okay because foo is still defined before any calls to goo
{
	return b;
}

int main()
{
	 constexpr int a{ goo(5) }; // this is the outermost invocation

	return 0;
}

요약

  • 함수를 constexpr로 표시하는 것은 "이 함수를 상수 표현식에 쓸 수 있다"는 뜻이지, "무조건 컴파일 타임에 계산된다"는 뜻이 아닙니다.
  • 상수 표현식은 상수가 반드시 필요한 문맥에서만 컴파일 타임 계산이 강제됩니다.
  • 상수가 굳이 필요 없는 곳이라면, 컴파일러가 알아서 컴파일 타임에 할지 런타임에 할지 효율적인 쪽으로 선택합니다.
  • 비상수(일반) 표현식은 언제나 런타임에 계산됩니다.

추가 예제 살펴보기

상황에 따라 constexpr 함수가 언제 평가될 확률이 높은지 다음 예제를 통해 감을 잡아봅시다.

#include <iostream>

constexpr int greater(int x, int y)
{
    return (x > y ? x : y);
}

int main()
{
    constexpr int g { greater(5, 6) };              // 사례 1: 항상 컴파일 타임에 평가됨
    std::cout << g << " is greater!\n";

    std::cout << greater(5, 6) << " is greater!\n"; // 사례 2: 런타임 또는 컴파일 타임에 평가될 수 있음

    int x{ 5 }; // constexpr은 아니지만 컴파일 타임에 값을 알 수 있음
    std::cout << greater(x, 6) << " is greater!\n"; // 사례 3: 높은 확률로 런타임에 평가됨

    std::cin >> x;
    std::cout << greater(x, 6) << " is greater!\n"; // 사례 4: 항상 런타임에 평가됨

    return 0;
}
  • 사례 1: 상수가 무조건 필요한 곳(constexpr 변수 초기화)에 쓰였으므로, greater()무조건 컴파일 타임에 계산됩니다.
  • 사례 2: 화면 출력 구문은 런타임에 실행되므로 상수가 굳이 필요하지 않은 곳입니다.
    하지만 전달한 인자(5, 6)가 모두 상수이므로 컴파일 타임에 계산될 '자격'은 갖췄습니다. 컴파일러가 자유롭게 시점을 선택합니다.
  • 사례 3: 일반 변수 x가 인자로 들어갔으므로 대개 런타임에 실행됩니다. 하지만 x의 값이 5라는 걸 컴파일러가 뻔히 알기 때문에, 이른바 'as-if 규칙(마치 ~인 것처럼 규칙)'에 따라 컴파일러 재량으로 몰래 컴파일 타임에 계산해버릴 수도 있습니다. (이 규칙 하에서는 일반 비상수 함수도 컴파일 타임에 계산될 수 있습니다!)
  • 사례 4: 사용자가 키보드로 어떤 값을 입력할지 컴파일 타임에는 절대 알 수 없으므로, 이 호출은 무조건 런타임에 실행됩니다.

핵심 포인트 (평가 시점 정리)
함수가 실제로 컴파일 타임에 평가될 가능성을 다음과 같이 분류해 볼 수 있습니다.

1. 항상 (표준에 의해 강제됨):

  • 상수 표현식이 필수인 곳에서 constexpr 함수를 호출할 때
  • 컴파일 타임에 평가 중인 다른 함수 안에서 constexpr 함수를 호출할 때

2. 아마도 (굳이 안 할 이유가 없음):

  • 상수가 필수는 아니지만, 매개변수로 전달된 값이 전부 상수일 때

3. 어쩌면 (최적화가 잘 된다면):

  • 상수가 필수는 아니고 인자도 변수지만, 그 변수의 값을 컴파일러가 미리 알 수 있을 때
  • 모든 인자가 상수이며, 일반 함수지만 내용이 단순해 컴파일 타임에 평가 가능할 때

4. 절대 아님 (불가능함):

  • 상수가 필수가 아니고, 전달된 인자의 값을 컴파일 시점에 전혀 알 수 없을 때 (예: 사용자 입력값)

참고: 여러분이 사용하는 컴파일러의 '최적화 설정'에 따라 이 결과가 달라질 수 있습니다. 디버그(Debug) 모드는 보통 최적화를 끄기 때문에 릴리즈(Release) 모드와 다른 결과를 보일 수 있습니다. (예를 들어, GCC나 Clang 컴파일러는 -O2 같은 최적화 옵션을 주지 않으면 굳이 상수가 필요 없는 곳에서는 컴파일 타임 계산을 시도하지 않습니다.)

고급 독자를 위한 참고
컴파일러는 함수 호출 자체를 '인라인(inline)'으로 코드에 덮어씌우거나 최적화 과정에서 함수 호출을 아예 지워버릴 수도 있습니다. 이런 모든 최적화 작업이 함수의 평가 시점이나 평가 여부에 영향을 미치게 됩니다.


F.3 — Constexpr 함수 (3부) 및 consteval

constexpr 함수가 컴파일 타임에 평가되도록 강제하기

constexpr 함수가 가능할 때마다 컴파일 타임에 평가되도록 컴파일러에게 강제할 수 있는 직접적인 방법은 없습니다.
(예: constexpr 함수의 반환값이 상수 표현식이 아닌 곳에 사용될 때)

하지만 컴파일 타임 평가가 가능한 constexpr 함수를, 상수 표현식이 필요한 곳에 반환값을 사용하도록 설정하여
강제로 컴파일 타임에 평가되게 만들 수는 있습니다. 단, 이 작업은 함수를 호출할 때마다 매번 해줘야 합니다.

가장 흔한 방법은 함수의 반환값으로 constexpr 변수를 초기화하는 것입니다 (이전 예제들에서 변수 'g'를 사용했던 이유가 바로 이것입니다). 안타깝게도 이 방식은 단지 컴파일 타임 평가를 보장하기 위해 프로그램에 불필요한 새 변수를 도입해야 하므로, 코드가 지저분해지고 가독성이 떨어집니다.


C++20의 Consteval

C++20은 consteval이라는 새로운 키워드를 도입했습니다. 이 키워드는 함수가 반드시 컴파일 타임에 평가되어야 함을 나타내며, 그렇지 않으면 컴파일 에러가 발생합니다. 이러한 함수를 즉시 함수(immediate functions)라고 부릅니다.

#include <iostream>

consteval int greater(int x, int y) // 이제 이 함수는 consteval입니다
{
    return (x > y ? x : y);
}

int main()
{
    constexpr int g { greater(5, 6) };              // 정상: 컴파일 타임에 평가됩니다
    std::cout << g << '\n';

    std::cout << greater(5, 6) << " is greater!\n"; // 정상: 컴파일 타임에 평가됩니다

    int x{ 5 }; // constexpr이 아닙니다
    std::cout << greater(x, 6) << " is greater!\n"; // 에러: consteval 함수는 반드시 컴파일 타임에 평가되어야 합니다

    return 0;
}

위 예제에서 greater()를 처음 두 번 호출한 부분은 컴파일 타임에 정상적으로 평가됩니다. 하지만 greater(x, 6) 호출은 컴파일 타임에 평가될 수 없으므로 컴파일 에러가 발생합니다.

모범 사례 (Best practice)
어떤 이유로든 반드시 컴파일 타임에 평가되어야 하는 함수가 있다면 consteval을 사용하세요.
(예: 컴파일 타임에만 가능한 특정 작업을 수행하는 경우)

놀랍게도 consteval 함수의 매개변수 자체는 constexpr이 아닙니다. (consteval 함수가 오직 컴파일 타임에만 평가될 수 있음에도 불구하고요)
이는 언어 설계상의 일관성을 유지하기 위해 내려진 결정입니다.


constexpr 함수 호출이 컴파일 타임인지 런타임인지 확인하기

현재 C++에서는 이것을 완벽하고 신뢰할 수 있게 알아낼 방법이 없습니다.

std::is_constant_evaluatedif consteval을 사용하면 되지 않나요? (고급)

이 두 가지 기능 모두 함수 호출이 컴파일 타임에 평가되는지 런타임에 평가되는지를 정확히 알려주지는 않습니다.

<type_traits> 헤더에 정의된 std::is_constant_evaluated()는 현재 함수가 상수 평가 컨텍스트(constant-evaluated context)에서 실행 중인지를 알려주는 bool 값을 반환합니다. '상수 평가 컨텍스트'(또는 상수 컨텍스트)란 constexpr 변수의 초기화처럼 상수 표현식이 반드시 요구되는 상황을 뜻합니다. 따라서 컴파일러가 상수 표현식을 컴파일 타임에 평가해야만 하는 상황에서는 std::is_constant_evaluated()가 예상대로 true를 반환합니다.

이 기능은 다음과 같은 코드를 작성할 수 있도록 의도되었습니다.

#include <type_traits> // std::is_constant_evaluated() 사용을 위해

constexpr int someFunction()
{
    if (std::is_constant_evaluated()) // 상수 컨텍스트에서 평가되는 경우
        doSomething();
    else
        doSomethingElse();
}

하지만 주의할 점이 있습니다. 컴파일러는 상수 표현식이 필요하지 않은 상황에서도 자체적인 판단으로 constexpr 함수를 컴파일 타임에 평가할 수 있습니다. 이런 경우에는 함수가 실제로 컴파일 타임에 평가되었음에도 불구하고 std::is_constant_evaluated()false를 반환합니다.

결론적으로 std::is_constant_evaluated()의 진짜 의미는 "이 함수가 현재 컴파일 타임에 평가되고 있다"가 아니라, "컴파일러가 이 함수를 컴파일 타임에 평가하도록 강제받고 있다"입니다.

핵심 인사이트

이 동작이 조금 이상해 보일 수 있지만, 합당한 이유가 있습니다.

  • 이 기능을 처음 제안한 문서에 따르면, C++ 표준은 실제로 "컴파일 타임"과 "런타임"을 엄격하게 구분 짓지 않습니다. 이 구분을 명확히 정의하려면 언어 표준에 너무 큰 변화가 필요했을 것입니다.
  • 최적화는 프로그램의 겉보기 동작(observable behavior)을 바꿔서는 안 됩니다. 만약 함수가 컴파일 타임에 평가될 때마다 std::is_constant_evaluated()가 무조건 true를 반환한다면, 최적화 프로그램이 런타임 대신 컴파일 타임 평가를 선택했을 때 함수의 동작 결과가 달라질 수 있습니다. 즉, 어떤 최적화 레벨로 컴파일했느냐에 따라 프로그램이 완전히 다르게 작동하는 심각한 문제가 생길 수 있습니다!
  • C++23에서 도입된 if constevalif (std::is_constant_evaluated())를 대체하는 기능으로, 문법이 더 깔끔하고 몇 가지 자잘한 문제를 해결해 줍니다. 하지만 기본적인 동작 원리와 평가 방식은 같습니다.

C++20: consteval을 사용해 constexpr 컴파일 타임 실행 강제하기

consteval 함수의 유일한 단점은 런타임에 평가될 수 없다는 것입니다.
상황에 따라 양쪽 모두에서 작동할 수 있는 constexpr 함수에 비해 유연성이 떨어집니다.

그렇다면 가장 이상적인 것은, 평소에는 유연한 constexpr 함수를 쓰되, 원할 때만 강제로 컴파일 타임에 평가되도록 만드는 편리한 방법일 것입니다 (반환값을 상수가 아닌 곳에 쓸 때도 포함해서요).

다음은 이 아이디어를 어떻게 구현할 수 있는지 보여주는 예제입니다.

#include <iostream>

#define CONSTEVAL(...) [] consteval { return __VA_ARGS__; }()               // Jan Scultke가 제안한 C++20 버전 (https://stackoverflow.com/a/77107431/460250)
#define CONSTEVAL11(...) [] { constexpr auto _ = __VA_ARGS__; return _; }() // Justin이 제안한 C++11 버전 (https://stackoverflow.com/a/63637573/460250)

// 이 함수는 상수 컨텍스트에서 실행되는 경우 두 숫자 중 더 큰 값을 반환하고
// 그렇지 않은 경우 더 작은 값을 반환합니다
constexpr int compare(int x, int y) // 함수는 constexpr입니다
{
    if (std::is_constant_evaluated())
        return (x > y ? x : y);
    else
        return (x < y ? x : y);
}

int main()
{
    int x { 5 };
    std::cout << compare(x, 6) << '\n';                  // 런타임에 실행되며 5를 반환합니다

    std::cout << compare(5, 6) << '\n';                  // 컴파일 타임에 실행될 수도 있고 아닐 수도 있지만, 항상 5를 반환합니다
    std::cout << CONSTEVAL(compare(5, 6)) << '\n';       // 항상 컴파일 타임에 실행되며 6을 반환합니다

    return 0;
}

고급 독자를 위한 참고
위 예제는 가변 인자 매크로(variadic preprocessor macro: #define, ..., __VA_ARGS__)를 사용하여 즉시 실행되는 consteval 람다 함수를 정의하는 방식을 썼습니다.

매크로를 사용하지 않는 더 깔끔한 방법도 있습니다. 아래 코드를 참고하세요.

GCC 사용자를 위한 주의사항
GCC 14 이후 버전에 버그가 하나 있어서, 최적화 옵션이 켜져 있을 경우 아래 코드가 잘못된 결과를 낼 수 있습니다.

#include <iostream>

// 이 함수가 모든 유형의 값과 작동하도록 단축된 함수 템플릿(C++20) 및 auto 반환 형식을 사용합니다
// 자세한 내용은 아래 '관련 콘텐츠' 상자를 참조하세요 (이 함수를 사용하기 위해 작동 방식을 알 필요는 없습니다)
// 이전 예제와의 일관성을 위해 여기서는 대문자 이름을 사용하기로 했지만, 호출을 더 쉽게 볼 수 있게 해주는 효과도 있습니다
consteval auto CONSTEVAL(auto value)
{
    return value;
}

// 이 함수는 상수 컨텍스트에서 실행되는 경우 두 숫자 중 더 큰 값을 반환하고
// 그렇지 않은 경우 더 작은 값을 반환합니다
constexpr int compare(int x, int y) // 함수는 constexpr입니다
{
    if (std::is_constant_evaluated())
        return (x > y ? x : y);
    else
        return (x < y ? x : y);
}

int main()
{
    std::cout << CONSTEVAL(compare(5, 6)) << '\n';       // 컴파일 타임에 실행됩니다

    return 0;
}

consteval 함수의 매개변수는 무조건 상수 평가를 거쳐야 하므로, consteval 함수의 인자로 constexpr 함수를 집어넣으면 그 constexpr 함수 역시 강제로 컴파일 타임에 평가되어야만 합니다. 그 후 consteval 함수는 계산된 결과를 그대로 반환해 줍니다.

여기서 consteval 함수가 값을 복사해서 반환(return by value)한다는 점을 눈여겨보세요. std::string처럼 복사 비용이 큰 타입을 런타임에 이렇게 복사한다면 비효율적이겠지만, 컴파일 타임 컨텍스트에서는 전혀 문제가 되지 않습니다. 함수 호출 코드가 통째로 계산된 최종 결괏값으로 쏙 대체되기 때문입니다.

고급 독자를 위한 참고

  • auto 반환 타입은 [10.9 레슨 -- 함수의 타입 추론]에서 다룹니다.
  • 축약된 함수 템플릿(auto 매개변수)은 [11.8 레슨 -- 여러 템플릿 타입이 있는 함수 템플릿]에서 다룹니다.

F.4 — Constexpr 함수 (4부)

Constexpr/consteval 함수는 const가 아닌 지역 변수를 사용할 수 있습니다

constexpr이나 consteval 함수 안에서도 constexpr이 아닌 일반 지역 변수를 사용할 수 있으며, 이 변수들의 값을 자유롭게 변경할 수도 있습니다.

간단한(다소 억지스러운) 예를 살펴봅시다.

#include <iostream>

consteval int doSomething(int x, int y) // 이 함수는 consteval 함수입니다
{
    x = x + 2;       // const가 아닌 함수 매개변수의 값을 수정할 수 있습니다

    int z { x + y }; // const가 아닌 지역 변수를 생성할 수 있습니다
    if (x > y)
        z = z - 1;   // 그리고 그 값을 수정할 수 있습니다

    return z;
}

int main()
{
    constexpr int g { doSomething(5, 6) };
    std::cout << g << '\n';

    return 0;
}

이러한 함수가 컴파일 타임(프로그램을 컴파일하는 동안)에 평가될 때, 컴파일러는 기본적으로 이 함수를 직접 "실행"하여 계산된 값을 반환해 줍니다.


Constexpr/consteval 함수는 함수 매개변수나 지역 변수를 다른 constexpr 함수 호출의 인자로 사용할 수 있습니다

이전에 우리는 다음과 같이 배웠습니다.
"constexpr (또는 consteval) 함수가 컴파일 타임에 평가될 때, 그 안에서 호출되는 다른 모든 함수들도 컴파일 타임에 평가되어야 한다."

놀랍게도, constexpr이나 consteval 함수는 자신의 (constexpr이 아닌) 매개변수나 (const가 아닐 수도 있는) 일반 지역 변수를 다른 constexpr 함수를 부를 때 인자로 사용할 수 있습니다. constexpr이나 consteval 함수가 컴파일 타임에 실행될 때, 컴파일러는 이미 모든 매개변수와 지역 변수의 값을 알고 있어야 합니다 (그렇지 않으면 컴파일 타임에 계산할 수 없으니까요). 따라서 이런 특별한 상황에서는 C++가 이 변수들의 값을 다른 constexpr 함수 호출의 인자로 쓰는 것을 허용하며, 그 함수 호출 역시 컴파일 타임에 계산될 수 있습니다.

#include <iostream>

constexpr int goo(int c) // 이제 goo()는 constexpr 함수입니다
{
    return c;
}

constexpr int foo(int b) // foo() 내부에서 b는 상수 표현식이 아닙니다
{
    return goo(b);       // foo()가 컴파일 타임에 처리된다면, `goo(b)` 역시 컴파일 타임에 처리될 수 있습니다
}

int main()
{
    std::cout << foo(5);

    return 0;
}

위 예제에서 foo(5)는 컴파일 타임에 계산될 수도 있고 아닐 수도 있습니다. 만약 컴파일 타임에 계산된다면, 컴파일러는 b가 5라는 것을 알게 됩니다. 따라서 bconstexpr 변수가 아니더라도, 컴파일러는 goo(b)를 부르는 것을 goo(5)를 부르는 것처럼 취급하여 이 함수를 컴파일 타임에 계산할 수 있습니다. 반대로 foo(5)가 프로그램이 실행 중일 때(런타임) 처리된다면, goo(b) 역시 런타임에 처리됩니다.


constexpr 함수가 constexpr이 아닌 일반 함수를 호출할 수 있나요?

네, 가능합니다. 하지만 constexpr 함수가 상수 컨텍스트(반드시 상수가 필요한 상황)가 아닌 곳에서 사용될 때만 가능합니다.
만약 constexpr 함수가 상수 컨텍스트에서 실행 중일 때는 일반 함수를 호출할 수 없으며 (그러면 컴파일 타임에 상수 값을 만들어낼 수 없기 때문입니다), 시도할 경우 컴파일 에러가 발생합니다.

일반 함수 호출을 허용하는 이유는 constexpr 함수가 다음과 같은 작업을 할 수 있게 하기 위해서입니다.

#include <type_traits> // std::is_constant_evaluated 사용을 위해 포함

constexpr int someFunction()
{
    if (std::is_constant_evaluated()) // 상수 컨텍스트에서 평가 중이라면
        return someConstexprFcn();
    else
        return someNonConstexprFcn();
}

이제 이 변형된 코드를 살펴보세요.

constexpr int someFunction(bool b)
{
    if (b)
        return someConstexprFcn();
    else
        return someNonConstexprFcn();
}

이 코드는 someFunction(false)가 상수 표현식(constant expression) 안에서 절대 호출되지 않는 한 아무런 문제가 없습니다.

참고로...
C++23 이전 표준에서는, constexpr 함수가 최소한 한 가지 인자 조합에 대해서는 constexpr 값을 반환해야 하며, 그렇지 않으면 문법적으로 잘못된(ill-formed) 코드로 간주했습니다. constexpr 함수 안에서 무조건적으로 일반 함수를 호출하게 만들면 그 코드는 규칙에 어긋나게 됩니다. 하지만 컴파일러가 이런 경우 무조건 에러나 경고를 내야 하는 것은 아니었습니다. 그래서 직접 상수 컨텍스트에서 호출하려고 시도하지 않는 한, 컴파일러는 별다른 불평을 하지 않았을 것입니다. C++23부터는 이 요구 사항이 폐지되었습니다.

가장 좋은 결과를 위해 다음과 같은 방법을 권장합니다:

  • 가능하면 constexpr 함수 안에서는 일반(non-constexpr) 함수 호출을 피하세요.
  • 상수 컨텍스트일 때와 아닐 때 함수가 다르게 동작해야 한다면, C++20에서는 if (std::is_constant_evaluated())를, C++23 이후에서는 if consteval을 사용하여 조건에 따라 다르게 작동하도록 만드세요.
  • constexpr 함수를 만들면 항상 상수 컨텍스트에서 잘 작동하는지 테스트해 보세요. 일반 컨텍스트에서는 잘 작동하더라도 상수 컨텍스트에서는 에러가 날 수 있기 때문입니다.

언제 함수를 constexpr로 만들어야 할까요?

일반적인 규칙은 다음과 같습니다. 함수가 상수 표현식의 일부로 계산될 가능성이 있다면, 그 함수는 constexpr로 만들어야 합니다.

순수 함수(pure function)는 다음 조건을 만족하는 함수를 말합니다.

  1. 같은 인자를 주면 언제나 똑같은 결과를 반환합니다.
  2. 부작용(side effect)이 없습니다. (예를 들어, 함수 외부에 있는 정적 지역 변수나 전역 변수의 값을 바꾸지 않고, 입출력 작업도 하지 않습니다.)

순수 함수는 일반적으로 constexpr로 만드는 것이 좋습니다.

참고로...
constexpr 함수가 언제나 순수 함수여야만 하는 것은 아닙니다. C++23에서는 constexpr 함수가 정적(static) 지역 변수를 사용하고 수정할 수 있게 되었습니다. 정적 지역 변수의 값은 함수가 호출될 때마다 계속 유지되기 때문에, 이 값을 수정하는 것은 부작용(side-effect)으로 간주됩니다.
그렇긴 하지만, 여러분이 아주 간단하거나 일회성으로 쓸 프로그램을 만들면서 함수에 constexpr을 붙이지 않는다고 해서 세상이 무너지지는 않을 겁니다. (아마도요!)

모범 사례 (Best practice)
안 할 특별한 이유가 없다면, 상수 표현식의 일부로 계산될 수 있는 함수는 모두 constexpr로 만드세요. (지금 당장 그렇게 쓰이지 않더라도 말입니다.) 반대로 상수 표현식으로 쓰일 수 없는 함수에는 constexpr을 붙이면 안 됩니다.


왜 모든 함수를 constexpr로 만들지 않나요?

함수에 constexpr을 붙이고 싶지 않은 몇 가지 이유가 있습니다:

  1. constexpr은 "이 함수는 상수 표현식에서 쓸 수 있다"는 신호입니다. 만약 함수가 상수 표현식으로 계산될 수 없다면 constexpr로 표시해선 안 됩니다.
  2. constexpr은 함수의 겉모습(인터페이스)의 일부입니다. 한 번 함수를 constexpr로 만들면, 다른 constexpr 함수들이 이를 호출하거나 상수 표현식이 필요한 곳에서 이 함수를 사용하게 됩니다. 나중에 마음이 바뀌어 constexpr을 지워버리면, 이 함수를 사용하던 다른 코드들에서 에러가 발생하게 됩니다.
  3. constexpr 함수는 디버깅하기 더 어려울 수 있습니다. 디버거에서 실행을 잠시 멈추거나(중단점) 코드를 한 줄씩 확인(step through)할 수 없기 때문입니다.

컴파일 타임에 실제로 계산되지 않는데도 왜 constexpr 함수로 만들어야 하나요?

초보 프로그래머들은 종종 이렇게 묻습니다. "함수를 부를 때 일반 변수를 넣는 등, 내 프로그램에서는 어차피 실행할 때(런타임에)만 계산되는데 왜 굳이 constexpr을 붙여야 하죠?"

여기에는 몇 가지 이유가 있습니다:

  • constexpr을 사용해서 생기는 단점은 거의 없고, 오히려 컴파일러가 프로그램을 더 작고 빠르게 최적화하는 데 도움을 줄 수 있습니다.
  • 지금 당장 컴파일 타임에 함수를 부르지 않는다고 해서, 나중에 프로그램을 수정하거나 확장할 때도 안 부른다는 보장은 없습니다. 미리 constexpr을 붙여두지 않았다면, 나중에 상수 컨텍스트에서 호출할 때 깜빡하고 constexpr을 안 붙여서 성능 이점을 놓칠 수도 있습니다. 혹은 어딘가에서 반드시 상수 표현식이 필요한 곳에 반환값을 써야 할 때 어쩔 수 없이 나중에 부랴부랴 constexpr을 붙여야 할 수도 있죠.
  • 반복은 좋은 코딩 습관을 몸에 배게 해줍니다.

간단하지 않은 제대로 된 프로젝트에서는, 자신이 만든 함수가 미래에 재사용되거나 확장될 수 있다는 마음가짐으로 코드를 짜는 것이 좋습니다. 기존 함수를 수정할 때는 항상 코드를 망가뜨릴 위험이 따르며, 이는 곧 다시 테스트를 해야 한다는 뜻이므로 시간과 에너지가 소모됩니다. 나중에 다시 고치고 테스트하는 수고를 덜기 위해, "처음부터 올바르게 작성하는 데" 1~2분을 더 투자하는 것은 그만한 가치가 충분히 있습니다.

profile
안녕하세요.

0개의 댓글