KoreanFoodie's Study

C++ 기초 개념 16-2 : constexpr 와 컴파일 타임 상수 본문

Tutorials/C++ : Beginner

C++ 기초 개념 16-2 : constexpr 와 컴파일 타임 상수

GoldGiver 2022. 5. 23. 17:26

모두의 코드를 참고하여 핵심 내용을 간추리고 있습니다. 자세한 내용은 모두의 코드의 씹어먹는 C++ 강좌를 참고해 주세요!

상수식 (Constance expression)

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

int arr[size]; // size 가 정수 상수식이어야 함

...

template<int N>
struct A
{
	int operator()() { return N; }
};
A<number> a; // number 가 정수 상수식이어야 함

...

enum B { b = number, b, c}; // number 가 정수 상수식이어야 함

 

 

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

그렇다면 constexpr 와 const 는 어떤 차이가 있는 걸까?

 

 

constexpr vs const

다음 예시를 보자.

// const 를 사용하는 경우
int a1;
// Do something...
const int b1 = a1;

// constexpr 를 사용하는 경우
int a2;
// Do something...
constexpr int b2 = a2;

const 를 사용하는 경우는 에러가 발생하지 않지만, constexpr 를 사용하는 경우는 에러가 발생한다.

이는 a2 의 값이 컴파일 타입에 뭐가 될지 알 수 없기 때문이다!

요약하면, constexpr 은 항상 const 이지만, const 는 constexpr 가 아니다!

추가로, const 객체가 상수식으로 초기화 되었다 하더라도, 컴파일러에 따라 이를 런타임에 초기화할지, 컴파일에 초기화할지 다를 수 있다. 예를 들어,

const int i = 1;

i 는 컴파일 타임에 초기화될수도, 런타임에 초기화될 수도 있다.

 

 

constexpr 으로 TMP 대체하기

다음과 같은 코드가 있다고 해 보자.

#include <iostream>

int Factorial(int N)
{
	int result = 1;
	for (int i = 1; i <= N; ++i)
		result *= i;
	return result;
}

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

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

위의 코드는 컴파일 에러를 발생시킨다. 왜냐하면 constexpr 를 사용하지 않을 경우 컴파일 타임 상수인 객체를 만드는 것이 불가능하기 때문이다.

 

TMP 를 이용해서 위의 문제를 해결해 보자.

#include <iostream>

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

template <>
struct Factorial<1>
{
	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 를 사용하면 이러한 문제를 깔끔하게 해결할 수 있다. 아래 코드를 보자.

#include <iostream>

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

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

int main() 
{
	A<Factorial(10)> a;
	std::cout << a() << std::endl;
}

constexpr 함수는 조건이 맞을 때, 해당 함수의 리턴값을 컴파일 타임 상수로 만들어버린다! constexpr 함수는 constexpr 값을 리턴해야 하는 상황이 아닐 경우, 일반 함수처럼 동작한다.

 

아래와 같은 제약 조건들을 제회하면, 모든 작업을 constexpr 함수 내부에서 수행할 수 있다 :

  • goto 문 사용
  • 예외 처리 (try 문; C++ 20 부터 가능하게 바뀜)
  • 리터럴 타입이 아닌 변수의 정의
  • 초기화 되지 않는 변수의 정의
  • 실행 중간에 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); // constexpr 이 아닌 함수
  return total;
}

 

다음의 경우는 정상적으로 동작한다 :

// 10 은 컴파일 타임 상수
A<Factorial(10)> a;

// ten 은 컴파일 타임 상수
constexpr int ten = 10;
A<Factorial(ten)> a2;

 

 

리터럴 타입?

위에서 constexpr 함수 내부에서 불가능한 작업으로 리터럴(Literal) 타입이 아닌 변수의 정의라고 이야기했다. 리터럴 타입은 쉽게 생각하면 컴파일러가 컴파일 타임에 정의할 수 있는 타입이라고 생각하면 된다. C++ 에서는 다음과 같이 정의되어 있다 :

  • void 
  • 스칼라 타입 (char, int, bool, long, float, double) 등등
  • 레퍼런스 타입
  • 리터럴 타입의 배열
  • 혹은 아래 조건들을 만족하는 타입
    • 디폴트 소멸자를 가지고
    • 다음 중 하나를 만족하는 타입
      • 람다 함수
      • Arggregate 타입 (사용자 정의 생성자, 소멸자가 없으며 모든 데이터 멤버들이 public) (쉽게 말해 pair 같은 애들을 이야기함)
      • constexpr 생성자를 가지며 복사 및 이동 생성자가 없음

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

 

 

constexpr 생성자

아래와 같은 방식으로, constexpr 객체를 만들고, 멤버 함수를 사용할 수 있다!

#include <iostream>

class Vector
{
public:
	constexpr Vector(int a, int b) : a_(a), b_(b) {}

	constexpr int a() const { return a_; }
	constexpr int b() const { return b_; }

private:
	int a_;
	int b_;
};

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

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

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

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

	// AddVec 역시 constexpr 를 리턴함
	A<AddVec(v1, v2).b()> y;
	std::cout << y() << std::endl;
}

참고로, const 한 객체를 인자로 받은 함수에서, 객체의 메소드를 호출할 때는 const 메소드를 호출해야 에러가 나지 않는다!

#include <iostream>

class K
{
public:
	K(int k) : k_(k) {}
	int const_k() const { return k_; }
	int just_k() { return k_; }
private:
	int k_;
};

int foo(const K& kk)
{
	return kk.const_k();
}

int boo(const K& kk)
{
	return kk.just_k();
}

int main() 
{
	K kk{1};
	foo(kk); // Works Fine
	boo(kk); // Error!
}

 

 

if constexpr

만약 인자가 포인터 타입이면 포인터 값을, 포인터가 아니라면 그냥 값을 출력하도록 만드는 함수를 짜고 싶다고 하자.

이를 템플릿을 이용하면 다음과 같이 만들 수 있다.

template <typename T>
void showValue(T t)
{
	std::cout << t << std::endl;
}

template <typename T>
void showValue(T* t)
{
	std::cout << *t << std::endl;
}

하지만 위의 예시는 같은 코드를 두 번 써야 한다는 단점이 있다. <type_traits> 헤더의 is_pointer<T> 를 사용하면 다음과 같이 함수를 짤 수 있다 :

template <typename T>
void showValue(T t)
{
	// if (std::is_pointer_v<T>) // 아래와 같은 뜻
	if (std::is_pointer<T>::value) // T 가 pointer 면 true
	{
		std::cout << *t << std::endl;
	}
	else
	{
		std::cout << t << std::endl;	
	}
}

하지만 위의 코드는, 포인터가 아닌 변수를 인자로 받을 때, 에러를 발생시킨다. 왜냐하면, if 문에서의 *t 가 컴파일 되게 되는데, 포인터가 아닌 변수의 포인터를 찾으려고 하기 때문이다. 

이 경우, if constexpr 를 도입하면 문제가 깔끔하게 해결된다.

template <typename T>
void showValue(T t)
{
	if constexpr (std::is_pointer_v<T>)
	{
		std::cout << *t << std::endl;
	}
	else
	{
		std::cout << t << std::endl;	
	}
}

if constexpr 은 조건이 반드시 bool 로 타입 변환될 수 있어야 하는 컴파일 타임 상수식이어야 한다. 만약 if constexpr 이 참이라면 else 에 해당하는 부분은 컴파일되지 않고 (완전히 무시), 마찬가지로 거짓이면 else 에 해당하는 부분만 컴파일 된다.

따라서, 인자로 포인터가 아닌 변수가 들어올 경우, *t 가 컴파일 되지 않으므로 컴파일 오류가 발생하지 않는다!

 

 

C++ 20

C++ 20 에 추가된 기능에는 constexpr vector 와 constexpr string 이 있다. (참고로, string_view 는 이미 constexpr 이다)

 

 
Comments