Constexpr

Date:     Updated:

카테고리:

태그:

모두의 코드 씹어먹는 c++ 자료를 보고 정리한 내용입니다.


constexpr

constexprc++ 11에서 새롭게 도입된 키워드로 객체나 함수 앞에 붙일 수 있고, 해당 객체나 함수의 리턴값을 컴파일 타임에 알 수 있다 라는 의미를 전달한다.

컴파일러가 어떤 식의 값을 컴파일 타임에 결정할 수 있다면 해당 식을 상수식(Const expression) 이라고 표현한다. 그리고 이러한 상수식들 중 값이 정수인 것들을 정수 상수식(Integral const 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 역시 정수 상수식이어야만 한다.

constexpr은 앞서 말한 데로, 어떠한 식이 상수식 이라 명시해주는 키워드이다. 만약 객체를 정의할 떄 constexpr을 명시해준다면, 해당 객체는 어떤 상수식에도 사용될 수 있다.

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;
}

당연한 이야기지만 constexpr은 상수이므로 값을 수정할 수 없다. 그렇다면 const 키워드와의 차이는 뭘까?

constexpr vs const

const로 정의된 상수는 굳이 컴파일 타임에 그 값을 알 필요가 없다.

int a;

// Do something...

const int b = a;

위 코드에서 b의 값을 지정해주면 바꿀 수 없지만, b의 값을 컴파일 타임에 알 수는 없다.

int a;

// Do something...

constexpr int b = a;  // error

반면 constexpr 변수의 경우 오른쪽에는 반드시 상수식이 와야 한다. 위 코드에서 컴파일러는 컴파일 타임에 a의 값이 무엇인지 알 수 없으므로 컴파일 오류가 발생한다.

정리하자면

  • constexpr은 항상 const이지만
  • constconstexpr이 아니다!

여담으로 아래와 같이 const 객체가 상수식으로 초기화 되었다 하더라고 컴파일러가 이를 런타임에 초기화 할지, 컴파일 타임에 초기화 할지는 컴파일러에 따라 다르다. 따라서 컴파일 타임에 상수를 사용하고 싶다면 반드시 constexpr 키워드를 사용해야 한다.

const int i = 3;

constexpr 함수

constexpr로 객체를 선언한다면 해당 객체는 컴파일 타임 상수로 정의된다. 그렇다면 컴파일 타임 상수인 객체를 만들어내는 함수를 정의할 수 있을까?

constexpr 이전에 해당 작업을 하려면 아래와 같이 굉장히 난해한 템플릿 메타프로그래밍 방식을 사용해야 했다.

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;
}

하지만! constexpr 키워드 등장 이후 해당 작업을 매우매우매우 편리하게 할 수 있다.

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;
}

[!success] 미쳐따…

constexpr 함수의 개념이 처음 도입되었을 때는 여러 제약 조건이 많았다. 예를 들어 함수 내부에서 변수를 정의할 수 없고, return 문은 딱 하나만 있어야 했다.

하지만 c++ 14 부터 제약 조건들이 많이 완화되어, 아래 경우를 빼곤는 모두 constexpr 함수 내에서 수행이 가능하다.

  • goto 문 사용
  • 예외처리(try 문) → c++ 20 부터 가능
  • 리터럴 타입이 아닌 변수 정의
  • 초기화 되지 않은 변수 정의
  • constexpr이 아닌 함수 호출

그런데 constexpr 함수는 컴파일 타임 상수들만 인자로 받을 수 있을까? 아니다. 놀랍게도 constexpr 함수에 컴파일 타임 상수가 아닌 값이 인자로 전달되었다면 해당 함수는 그냥 일반 함수처럼 동작하게 된다.

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; // OK
}

따라서 함수에 constexpr을 붙일 수 있다면 붙여주는 것이 좋다.

리터럴 타입

앞서 constexpr 함수 내부에서는 리터럴 타입의 변수만 정의가 가능하다고 했다. 리터럴 타입은 쉽게 이야기하면 컴파일러가 컴파일 타임에 정의할 수 있는 타입이다. c++에서 정의하는 바 로는

  • void형, 스칼라 타입 (char, int, bool, float, 등등...)
  • 레퍼런스 타입
  • 리터럴 타입의 배열
  • 혹은 아래 조건들을 만족하는 타입
    • 디폴트 소멸자를 가짐
    • 다음 중 하나를 만족
      • 람다 함수
      • Arggregate 타입 (사용자 정의 생성자, 소멸자가 없으며 모든 데이터 멤버들이 public) → pair 같은 클래스
      • 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_;
};

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;
}

constexpr 생성자 역시 마찬가지로 일반적인 constexpr 함수에서 적용되는 제약들이 적용된다.

if constexpr

만약 타입에 따라 형태가 달라지는 함수를 만들고 싶을 떄 어떻게 해야 할까?

대표적인 함수로 get_value가 있는데, 이 함수는 인자가 포인터 타입이면 *한 값을 리턴하고 포인터 타입이 아니면 그냥 원래의 인자를 리턴한다.

또한 아래와 같이 템플릿 타입 추론을 통해서도 해결이 가능하다.

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);
}

결과는 잘 나오지만, 문제는

  • show_value 함수가 정확히 어떠한 형태의 T를 요구하는지 한 눈에 파악하기 힘들다
  • 같은 함수를 두 번 정의해야 한다.

c++ 표준 라이브러리의 type_traits 에서 위와 같은 문제를 해결해주는 여러가지 템플릿 함수들을 제공해주는데, 이들 중 해당 타입이 포인터인지 아닌지 확인해주는 함수도 있다. 이를 사용해서 코드를 다시 구성해보면

#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);
}

[!error] test2.cc:6:43: error: invalid type argument of unary ‘*’ (have ‘int’) std::cout « “포인터 이다 : “ « *t « std::endl;

컴파일 오류가 발생한다.

문제는 템플릿이 인스턴스화 되면서 컴파일이 불가능한 코드가 생성된다는 것이다.

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 을 통해 깔끔하게 해결 가능하다.

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;
  }
}

if constexpr은 반드시 bool 타입으로 변환될 수 있어야 하고, 컴파일 타임 상수식이어야 한다. 만약 if constexpr 이 참이라면 else에 해당하는 부분은 아예 컴파일 되지 않고 완전히 무시된다. 반대의 경우도 그러하다.



맨 위로 이동하기

Cpp 카테고리 내 다른 글 보러가기

댓글 남기기