모두의 코드
씹어먹는 C++ - <16 - 2. constexpr 와 함께라면 컴파일 타임 상수는 문제없어>

작성일 : 2019-08-18 이 글은 34963 번 읽혔습니다.

이번 강좌에서는

  • 컴파일 타임 상수

  • constexpr 객체, constexpr 함수, constexpr 생성자

  • 리터럴 타입

에 대해 다룹니다.

안녕하세요 여러분! 이번 강좌에서는 C++ 11 에서 새롭게 도입된 constexpr 키워드에 대해 알아보도록 하겠습니다. constexpr 키워드는 객체나 함수 앞에 붙일 수 있는 키워드로, 해당 객체나 함수의 리턴값을 컴파일 타임에 값을 알 수 있다 라는 의미를 전달하게 됩니다.

컴파일러가 컴파일 타임에 어떠한 식의 값을 결정할 수 있다면 해당 식을 상수식 (Constant expression) 이라고 표현합니다. 그리고 이러한 상수식들 중에서 값이 정수인 것을 정수 상수식(Integral constant expression) 이라고 하게 되는데, 정수 상수식들은 매우 쓰임새가 많습니다.

예를 들어서

int arr[size];

위 배열 선언식이 컴파일 되기 위해서는 size 가 정수 상수식이여야 하고,

template <int N>
struct A {
  int operator()() { return N; }
};
A<number> a;

템플릿 타입 인자의 경우도 마찬가지로 number 가 정수 상수식이여야만 합니다. 그 외에도,

enum A { a = number, b, c };

enum 에서 값을 지정해줄 때에 오는 number 역시 정수 상수식이여만 합니다. 이처럼 C++ 언어 상 정수 상수식이 등장하는 곳은 매우 많습니다.

constexpr

constexpr 은 앞서 말한 대로, 어떠한 식이 상수식 이라고 명시해주는 키워드 입니다. 만일, 객체의 정의에 constexpr 이 오게 된다면, 해당 객체는 어떠한 상수식에도 사용될 수 있습니다. 아래 예시를 보실까요.

#include <iostream>

template <int N>
struct A {
  int operator()() { return N; }
};

int main() {
  constexpr int size = 3;
  int arr[size];  // Good!

  constexpr int N = 10;
  A<N> a;  // Good!
  std::cout << a() << std::endl;

  constexpr int number = 3;
  enum B { x = number, y, z };  // Good!
  std::cout << B::x << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

10
3

와 같이 잘 나옵니다.

constexpr 은 언뜻 보기에 const 와 큰 차이가 없어 보입니다. constexpr 로 정의된 변수들도 마찬가지로 상수이므로 수정할 수 없기 때문이죠. 하지만 둘은 큰 차이가 있습니다.

constexpr vs const

const 로 정의된 상수들은 굳이 컴파일 타임에 그 값을 알 필요가 없습니다. 예를 들어서;

int a;

// Do something...

const int b = a;

위 코드를 볼 때 b 의 값을 컴파일 타임에 알 수 는 없지만, b 의 값을 지정해주면 바꿀 수 없다는 점은 확실합니다.

반면에

int a;

// Do something...

constexpr int b = a;  // ??

반면에 constexpr 변수의 경우 반드시 오른쪽에 다른 상수식이 와야 합니다. 하지만 컴파일러 입장에서 컴파일 타임 시에 a 가 뭐가 올 지 알 수 없습니다. 따라서 위 코드는 컴파일 오류가 됩니다. 정리하자면, constexpr 은 항상 const 이지만, constconstexpr 이 아닙니다!

여담으로 const 객체가 만일 상수식으로 초기화 되었다 하더라도 컴파일러에 따라 이를 런타임에 초기화 할지, 컴파일에 초기화할지 다를 수 있습니다. 예컨대

const int i = 3;

위의 경우 i 는 컴파일 타임에 초기화될 수 도, 런타임에 초기화될 수 도 있습니다. 따라서 컴파일 타임에 상수를 확실히 사용하고 싶다면 constexpr 키워드를 꼭 사용해야 합니다.

constexpr 함수

앞서 constexpr 로 객체를 선언한다면 해당 객체는 컴파일 타임 상수로 정의된다고 하였습니다. 그렇다면 컴파일 타임 상수인 객체들을 만들어내는 함수를 정의할 수 는 없을까요?

constexpr 키워드가 등장하기 이전에는 컴파일 타임 상수인 객체를 만드는 함수를 작성하는 것이 불가능 하였습니다. 예를 들어서;

#include <iostream>

int factorial(int N) {
  int total = 1;
  for (int i = 1; i <= N; i++) {
    total *= i;
  }
  return total;
}

template <int N>
struct A {
  int operator()() { return N; }
};

int main() { A<factorial(5)> a; }

컴파일 하였다면

컴파일 오류

test2.cc: In function ‘int main()’:
test2.cc:17:14: error: call to non-constexpr function ‘int factorial(int)’
   A<factorial(5)> a;
     ~~~~~~~~~^~~
test2.cc:17:14: error: call to non-constexpr function ‘int factorial(int)’
test2.cc:17:17: note: in template argument for type ‘int’ 
   A<factorial(5)> a;
                 ^

와 같은 오류가 발생하게 됩니다. 왜냐하면 factorial(5) 는 컴파일 타임 상수가 아니기 때문이지요. 물론 우리는 똑똑하기 때문에 factorial(5) 를 컴파일 타임에 계산해서 그냥 A<120> a; 로 컴파일 해도 된다는 것을 알고 있지만, 컴파일러는 그렇지 않습니다.

따라서 이와 같은 문제를 해결하기 위해 기존에는 난해한 템플릿 메타프로그래밍을 사용해야했습니다. 예를 들어서 위 factorial 함수를 TMP 방식으로 접근한 코드를 살펴봅시다.

#include <iostream>

template <int N>
struct Factorial {
  static const int value = N * Factorial<N - 1>::value;
};

template <>
struct Factorial<0> {
  static const int value = 1;
};

template <int N>
struct A {
  int operator()() { return N; }
};

int main() {
  // 컴파일 타임에 값이 결정되므로 템플릿 인자로 사용 가능!
  A<Factorial<10>::value> a;

  std::cout << a() << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

3628800

와 같이 잘 나옵니다.

template <int N>
struct Factorial {
  static const int value = N * Factorial<N - 1>::value;
};

template <>
struct Factorial<0> {
  static const int value = 1;
};

아무래도 이전에 템플릿 메타프로그래밍 강좌를 보신 분들은 위 코드가 무엇을 하는지 단박에 이해하실 수 있을 것이라 생각합니다. N 부터 1 까지의 곱을 TMP 의 형태로 수행하게 됩니다.

// 컴파일 타임에 값이 결정되므로 템플릿 인자로 사용 가능!
A<Factorial<10>::value> a;

그렇다면 우리의 Factorial 클래스를 통해 계산한 10! 의 값을 사용해서 배열을 정의할 수 도 있습니다. 이 경우 위 코드는 A<3628800> a 와 동일합니다.

위와 같이 배열의 크기를 정의할 수 있는 이유는 Factorial<10>::value 의 값을 컴파일러가 컴파일 타임에 알아낼 수 있기 때문입니다.

하지만, 위 Factorial 클래스처럼 간단한 경우를 빼면 사실 TMP 를 이용해서 구현된 코드는 딱히 이해하기 쉽지 않습니다. 왜냐하면 템플릿의 특성상 조건문들은 대개 템플릿 특수화를 통해 구현되고, 반복문들은 재귀 호출의 형태로 구현되어야 하기 때문에 복잡하기 때문입니다.

하지만 함수의 리턴 타입에 constexpr 을 추가한다면 조건이 맞을 때, 해당 함수의 리턴값을 컴파일 타임 상수로 만들어버릴 수 있습니다.

그럼 아래 코드를 살펴보겠습니다.

#include <iostream>

constexpr int Factorial(int n) {
  int total = 1;
  for (int i = 1; i <= n; i++) {
    total *= i;
  }
  return total;
}

template <int N>
struct A {
  int operator()() { return N; }
};

int main() {
  A<Factorial(10)> a;

  std::cout << a() << std::endl;
}

성공적으로 컴파일 하였다면

3628800

와 같이 잘 나옵니다! 놀랍게도 Factorial(10) 이 컴파일 타임에 계산되어서 클래스 A 의 템플릿 인자로 들어가게 되었습니다.

constexpr int Factorial(int n) {
  int total = 1;
  for (int i = 1; i <= n; i++) {
    total *= i;
  }
  return total;
}

constexpr 함수를 살펴보면 그냥 일반 함수와는 다를 바가 없습니다. 사실 C++ 11constexpr 이 처음 도입되었을 때에는 constexpr 함수에는 여러 제약 조건이 많았습니다. 예를 들어서 함수 내부에서 변수들을 정의할 수 없고, return 문은 딱 하나만 있어야만 했습니다.

하지만 C++ 14 부터 위와 같은 제약 조건들이 완화되어서 아래와 제약 조건들 빼고는 모두 constexpr 함수 내부에서 수행할 수 있습니다.

  • goto 문 사용

  • 예외 처리 (try 문; C++ 20 부터 가능하게 바뀌었습니다.)

  • 리터럴 타입이 아닌 변수의 정의

  • 초기화 되지 않는 변수의 정의

  • 실행 중간에 constexpr 이 아닌 함수를 호출하게 됨

따라서 위와 같은 작업들을 하지 않는 이상 constexpr 키워드를 함수에 붙일 수 있게 됩니다. 만일 조건을 만족하지 않는 작업을 함수 내에서 하게 된다면 컴파일 타임 오류가 발생하게 됩니다. 예를 들어서

int not_constexpr(int x) { return x++; }
constexpr int Factorial(int n) {
  int total = 1;
  for (int i = 1; i <= n; i++) {
    total *= i;
  }

  not_constexpr(total);
  return total;
}

위 경우 중간에 constexpr 함수가 아닌 함수를 호출하게 되므로

컴파일 오류

test2.cc: In function ‘constexpr int Factorial(int)’:
test2.cc:28:16: error: call to non-constexpr function ‘int not_constexpr(int)’
   not_constexpr(total);
   ~~~~~~~~~~~~~^~~~~~~

위와 같은 오류가 발생하게 됩니다.

성공적으로 constexpr 함수를 정의하였다면 이를 이용해서 constexpr 상수들을 생성할 수 있습니다. constexpr 함수에 인자로 컴파일 타임 상수들을 전달하면, 그 반환값 역시 컴파일 타임 상수가 됩니다. 우리의 사용 예시도 마찬가지로

A<Factorial(10)> a;

위 처럼 컴파일 타임 상수인 10 을 전달하였기 때문에 Factorial(10) 의 반환값은 컴파일 타임 상수가 되어서 위처럼 템플릿 인자로 전달 가능하게 됩니다. 당연하게도 constexpr 으로 정의된 상수들 역시 컴파일 타임 상수 이므로;

constexpr int ten = 10;
A<Factorial(ten)> a;

위 역시 마찬가지로 동작합니다. 그렇다면 constexpr 함수는 컴파일 타임 상수들만 인자로 받을 수 있는 것일까요? 아닙니다! constexpr 함수에 인자로 컴파일 타임 상수가 아닌 값을 전달하였다면 그냥 일반 함수 처럼 동작하게 됩니다.

#include <iostream>

constexpr int Factorial(int n) {
  int total = 1;
  for (int i = 1; i <= n; i++) {
    total *= i;
  }
  return total;
}

int main() {
  int num;
  std::cin >> num;
  std::cout << Factorial(num) << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

5
120

와 같이 잘 실행됩니다. 위 경우 Factorial 에 컴파일 타임 상수가 아닌 일반 값을 전달하였지만, 컴파일 타입이 아닌 런타임에 Factorial 이 실행되어서 잘 작동합니다.

따라서 constexpr 을 함수에 붙일 수 있다면 붙여주는 것이 좋습니다. 왜냐하면 constexpr 처럼 동작하지 못한다면 그냥 일반 함수처럼 동작할 테이고, 컴파일 타임 상수를 생성할 수 있는 상황이라면 간단히 이용할 수 있기 때문이지요.

리터럴 타입?

앞서 constexpr 함수 내부에서 불가능한 작업으로 리터럴(Literal) 타입이 아닌 변수의 정의라고 이야기 하였습니다. 리터럴 타입은 쉽게 생각하면 컴파일러가 컴파일 타임에 정의할 수 있는 타입이라고 생각하시면 됩니다. C++ 에서 정의하는 바로는;

  • void

  • 스칼라 타입 (char, int, bool, long, float, double) 등등

  • 레퍼런스 타입

  • 리터럴 타입의 배열

  • 혹은 아래 조건들을 만족하는 타입

    • 디폴트 소멸자를 가지고

    • 다음 중 하나를 만족하는 타입

      • 람다 함수

      • Arggregate 타입 (사용자 정의 생성자, 소멸자가 없으며 모든 데이터 멤버들이 public)

        쉽게 말해 pair 같은 애들을 이야기함

      • constexpr 생성자를 가지며 복사 및 이동 생성자가 없음

들을 리터럴 타입이라 의미하며 해당 객체들만이 constexpr 로 선언되던지 constexpr 함수 내부에서 사용될 수 있습니다. 이전에는 리터럴 타입으로 정의되어 있는 것들이 매우 한정적이였는데 (대부분 스칼라 타입), C++ 14 부터 constexpr 생성자를 지원함으로써 사용자들이 리터럴 타입들을 직접 만들 수 있게 되었습니다.

그렇다면 constexpr 생성자를 어떻게 사용하는지 살펴보겠습니다.

constexpr 생성자

constexpr 로 생성자의 경우 일반적인 constexpr 함수에서 적용되는 제약조건들이 모두 적용됩니다. 또한 constexpr 생성자의 인자들은 반드시 리터럴 타입이여야만 하고, 해당 클래스는 다른 클래스를 가상 상속 받을 수 없습니다.

예를 들어서 아래와 같은 클래스를 생각해봅시다.

class Vector {
 public:
  constexpr Vector(int x, int y) : x_(x), y_(y) {}

  constexpr int x() const { return x_; }
  constexpr int y() const { return y_; }

 private:
  int x_;
  int y_;
};

Vector 클래스는 벡터를 나타내는 클래스 입니다 (std::vector 가 아닙니다!). Vector 의 생성자는 리터럴인 int 두 개를 인자로 받고 있습니다. 따라서 이는 적합한 constexpr 생성자가 되겠지요.

constexpr int x() const { return x_; }
constexpr int y() const { return y_; }

마찬가지로 두 멤버 변수를 접근하는 함수 역시 constexpr 로 정의해주었습니다. 따라서 x()y() 역시 constexpr 함수 내부에서 사용할 수 있게 됩니다.

그렇다면 실제 컴파일 시에 어떻게 작동하는지 살펴봅시다.

#include <iostream>

class Vector {
 public:
  constexpr Vector(int x, int y) : x_(x), y_(y) {}

  constexpr int x() const { return x_; }
  constexpr int y() const { return y_; }

 private:
  int x_;
  int y_;
};

constexpr Vector AddVec(const Vector& v1, const Vector& v2) {
  return {v1.x() + v2.x(), v1.y() + v2.y()};
}

template <int N>
struct A {
  int operator()() { return N; }
};

int main() {
  constexpr Vector v1{1, 2};
  constexpr Vector v2{2, 3};

  // constexpr 객체의 constexpr 멤버 함수는 역시 constexpr!
  A<v1.x()> a;
  std::cout << a() << std::endl;

  // AddVec 역시 constexpr 을 리턴한다.
  A<AddVec(v1, v2).x()> b;
  std::cout << b() << std::endl;
}

성공적으로 컴파일 하였다면

실행 결과

1
3

와 같이 잘 나옵니다.

constexpr Vector v1{1, 2};
constexpr Vector v2{2, 3};

먼저 위 처럼 우리가 만든 클래스인 Vectorconstexpr 로 선언할 수 있었습니다. 왜냐하면 constexpr 로 생성자를 만들었기 때문이지요.

A<v1.x()> a;

그리고 v1constexpr 멤버 함수인 x 를 호출하였는데, x 역시 constexpr 함수이므로 위 코드는 결국 A<1> a 와 다름이 없게 됩니다. 만일 v1 이나 x 가 하나라도 constexpr 이 아니라면 위 코드는 컴파일 되지 않습니다. constexpr 객체의 constexpr 멤버 함수만이 constexpr 을 줍니다!

// AddVec 역시 constexpr 을 리턴한다.
A<AddVec(v1, v2).x()> b;

그렇다면 우리의 AddVec 함수는 어떨까요? 마찬가지로 v1v2 를 인자로 받아서 constexpr 객체를 리턴하게 됩니다. 이것이 가능한 이유는 AddVecconstexpr 함수 이고, Vector 가 리터럴 타입이여서 그렇겠지요.

if constexpr

만약에 타입에 따라 형태가 달라지는 함수를 짜고 싶다면 어떻게 하시나요? 예를 들어서 get_value 라는 함수가 있는데, 이 함수는 인자가 포인터 타입이면 * 을 한 것을 리턴하고 아니면 그냥 원래의 인자를 리턴하는 함수 입니다.

템플릿 타입 유추를 이용하면 해당 함수는 다음과 같이 작성할 수 있습니다.

#include <iostream>

template <typename T>
void show_value(T t) {
  std::cout << "포인터가 아니다 : " << t << std::endl;
}

template <typename T>
void show_value(T* t) {
  std::cout << "포인터 이다 : " << *t << std::endl;
}

int main() {
  int x = 3;
  show_value(x);

  int* p = &x;
  show_value(p);
}

성공적으로 컴파일 하였다면

실행 결과

포인터가 아니다 : 3
포인터 이다 : 3

와 같이 잘 나옵니다. 아주 좋습니다. 하지만 문제는 1) show_value 함수가 정확히 어떠한 형태의 T 를 요구하는지 한 눈에 파악하기 힘들고 2) 같은 함수를 두 번 써야 한다는 점입니다.

C++ 표준 라이브러리의 <type_traits> 에서는 여러가지 템플릿 함수들을 제공하는데, 이들 중 해당 타입이 포인터 인지 아닌지 확인하는 함수도 있습니다. 이를 사용해서 한 번 구성해보겠습니다.

#include <iostream>
#include <type_traits>

template <typename T>
void show_value(T t) {
  if (std::is_pointer<T>::value) {
    std::cout << "포인터 이다 : " << *t << std::endl;
  } else {
    std::cout << "포인터가 아니다 : " << t << std::endl;
  }
}

int main() {
  int x = 3;
  show_value(x);

  int* p = &x;
  show_value(p);
}

컴파일 하였다면

컴파일 오류

test2.cc: In instantiation of ‘void show_value(T) [with T = int]’:
test2.cc:15:15:   required from here
test2.cc:6:43: error: invalid type argument of unary ‘*’ (have ‘int’)
     std::cout << "포인터 이다 : " << *t << std::endl;
                                           ^~

와 같이 오류가 발생합니다. 일단 is_pointer 부터 살펴봅시다.

if (std::is_pointer<T>::value) {

std::is_pointer 는 전달한 인자 T 가 포인터라면 valuetrue 가 되고, 포인터가 아니면 false 가 되는 템플릿 메타 함수 입니다. 따라서 만일 T 가 포인터라면 *t 를 출력하고 아니면 t 를 출력하겠지요.

하지만 문제는 템플릿이 인스턴스화 되면서 생성되는 코드에 컴파일이 불가능한 부분이 발생된다는 것입니다. show_value(x) 를 하게 된다면 생성되는 코드는

void show_value(int t) {
  if (std::is_pointer<int>::value) {
    std::cout << "포인터 이다 : " << *t << std::endl;
  } else {
    std::cout << "포인터가 아니다 : " << t << std::endl;
  }
}

가 되므로 int 타입인 t* 연산자가 붙게 됩니다. 따라서 위 if 문은 절대로 실행되지 않음에도 불구하고 컴파일 되지 않기 때문에 오류가 발생한 것이지요.

하지만 이 문제는 if constexpr 을 도입하면 깔끔히 해결됩니다.

#include <iostream>
#include <type_traits>

template <typename T>
void show_value(T t) {
  if constexpr (std::is_pointer<T>::value) {
    std::cout << "포인터 이다 : " << *t << std::endl;
  } else {
    std::cout << "포인터가 아니다 : " << t << std::endl;
  }
}

int main() {
  int x = 3;
  show_value(x);

  int* p = &x;
  show_value(p);
}

성공적으로 컴파일 하였다면

실행 결과

포인터가 아니다 : 3
포인터 이다 : 3

와 같이 잘 나옵니다. if constexpr 은 조건이 반드시 bool 로 타입 변환될 수 있어야 하는 컴파일 타임 상수식이어야만 합니다. 그 대신, if constexpr 이 참이라면 else 에 해당하는 문장은 컴파일 되지 않고 (완전히 무시) 마찬가지로 if constexpr 이 거짓이라면 else 에 해당 하는 부분만 컴파일 됩니다.

따라서 위 경우 std::is_pointer<int>::value 는 거짓 이므로 아예 *t 자체가 컴파일 되지 않습니다. 덕분에 컴파일 오류는 발생하지 않습니다.

참고로 std::is_pointer<T>::value 가 거추장스럽다면 그냥 std::is_pointer_v<T> 만 써도 됩니다. std::is_pointer_v<T> 는 아래와 같이 정의되어 있습니다.

template <class T>
inline constexpr bool is_pointer_v = is_pointer<T>::value;

(위 코드가 무엇을 뜻하는지 이해 하실 수 있겠죠?)

그렇다면 원래 함수는

template <typename T>
void show_value(T t) {
  if constexpr (std::is_pointer_v<T>) {
    std::cout << "포인터 이다 : " << *t << std::endl;
  } else {
    std::cout << "포인터가 아니다 : " << t << std::endl;
  }
}

로 좀더 깔끔하게 바뀝니다.

C++ 20

C++ 20 은 아직 나오지 않았지만, 추가될 기능들 중에 constexpr vector (!!)constexpr string (!!!) 이 있습니다.

이를 위해서 constexpr newconstexpr 소멸자가 추가되었다고 하니 constexpr 은 좀 더 많은 곳에서 사용될 것 같습니다. 심지어 디폴트로 함수를 그냥 constexpr 로 만들어버리자는 이야기도 나오고 있고 말이지요.

C++ 는 정말 끊임없이 발전하고 있습니다. 덕분에 배워야 할 것들도 정말 끊임 없는 것 같네요..

그렇다면 이번 강좌는 여기에서 마치도록 하겠습니다. 다음 강좌에서는 C++ 의 여러가지 라이브러리에 대해 소개하는 시간을 갖도록 하겠습니다.

뭘 배웠지?

constexpr 을 통해 컴파일 타임 상수인 객체를 선언할 수 있다. const 와 constexpr 은 다르다. const 는 컴파일 타임에 상수일 필요가 없다! (const 인 애들 중에서 constexpr 이 있다고 생각하면 된다) constexpr 로 정의된 함수는 인자로 리터럴을 전달하였을 때 컴파일 타임 상수를 리턴한다. constexpr 생성자를 가진 클래스는 constexpr 객체를 생성할 수 있다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <씹어먹는 C++ - <16 - 2. constexpr 와 함께라면 컴파일 타임 상수는 문제없어>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 19 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..