KoreanFoodie's Study

C++ 기초 개념 16-3 : decltype 과 std::declval 본문

Tutorials/C++ : Beginner

C++ 기초 개념 16-3 : decltype 과 std::declval

GoldGiver 2022. 5. 24. 17:40

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

decltype

decltype 키워드는 C++ 11 에 추가된 키워드로, decltype 라는 이름의 함수처럼 사용된다.

decltype(/* 타입을 알고자 하는 식 */)

이때, decltype 은 함수와는 달리, 타입을 알고자 하는 식의 타입으로 치환되게 된다. 예를 들어,

#include <iostream>

struct A {
  double d;
};

int main() {
  int a = 3;
  decltype(a) b = 2;  // int

  int& r_a = a;
  decltype(r_a) r_b = b;  // int&

  int&& x = 3;
  decltype(x) y = 2;  // int&&

  A* aa;
  decltype(aa->d) dd = 0.1;  // double
}

위 코드에서 decltype 은 각각 int, int&, int&& 로 치환되어 컴파일된다. 위와 같이 decltype 에 전달된 식이 괄호로 둘러쌓이지 않은 식별자 표현식(id-expression) 이라면 해당 식의 타입을 얻을 수 있다.

참고로 식별자 표현식이란 변수의 이름, 함수의 이름, enum 이름, 클래스 멤버 변수(a.b 나 a->b 같은 꼴) 등을 의미한다. 엄밀한 정의는 여기에서 볼 수 있는데, 쉽게 생각하면 어떠한 연산을 하지 않고 단순히 객체 하나만을 가리키는 식이라고 보면 된다.

 

그렇다면 만약 decltype 에 식별자 표현식이 아닌 식을 전달하면 어떨까? 그렇다면 해당 식의 값의 종류(value category) 에 따라 달라진다.

  • 만일 식의 값 종류가 xvalue 라면 decltype 은 T&& 가 된다.
  • 만일 식의 값 종류가 lvalue 라면 decltype 은 T& 가 된다.
  • 만일 식의 값 종류가 prvalue 라면 decltype 은 T 가 된다.

이제 각 xvalue, lvalue, prvalue 의 개념에 대해 알아보도록 하자.

 

 

Value Category

모든 C++ 식(expression) 에는 두 가지 정보가 항상 따라다닌다. 바로 식의 타입과 값 카테고리(value category)이다.

값 카테고리는 좌측값/우측값 따위를 의미하는데, 사실 C++ 에서는 총 5개의 값 카테고리가 존재한다.

C++ 에서 어떠한 식의 값 카테고리를 따질 때 크게 두 가지 질문을 던질 수 있다.

  • 정체를 알 수 있는가? : 정체를 알 수 있다는 말은 해당 식이 어떤 다른 식과 같은 것인지 아닌지를 구분할 수 있다는 말이다. 일반적인 변수라면 주소값을 취해서 구분할 수 있겠고, 함수의 경우라면 그냥 이름만 확인해보면 된다.
  • 이동 시킬 수 있는가? : 해당 식을 다른 곳으로 안전하게 이동할 수 있는지의 여부를 묻는다. 즉, 해당 식을 받는 이동 생성자, 이동 대입 연산자 등을 사용할 수 있어야만 한다.

이를 바탕으로 값 카테고리를 구분하면 아래 표를 그릴 수 있다.

  이동 시킬 수 있다 이동 시킬 수 없다
정체를 알 수 있다 xvalue lvalue
정체를 알 수 없다 prvalue 쓸모 없음!

추가적으로, 정체를 알 수 있는 모든 식들을 glvalue 라고 하며, 이동시킬 수 있는 모든 식들을 rvalue 라고 한다. 그리고 C++ 에서 실체도 없으면서 이동도 시킬 수 없는 애들은 어차피 언어 상 아무런 의미를 갖지 않기 때문에 따로 부르는 명칭은 없다.

각 값 카테고리를 번역하면, lvalue 는 좌측값, prvalue (pure rvalue) 는 순수 우측값, xvalue (eXpiring value) 는 소멸하는 값, glvalue (generalized lvalue) 는 일반화된 좌측값, rvalue 는 우측값 이라 부를 수 있다.

위 그림으로 카테고리를 이해하자!

 

 

lvalue

평범한 int 타입 변수 i 를 보자.

int i;
i;

int&& x = i; // 컴파일 에러!

i 는 다른 변수(예를 들면 j)와 구별 가능하고, i 라는 식의 주소값은 실제 변수 i 의 주소값이 된다. 하지만 i 는 이동 불가능하므로, i 는 lvalue 이다!

이름을 가진 대부분의 객체들은 모두 lvalue 이다. 왜냐하면 해당 객체의 주소값을 취할 수 있기 때문이다. lvalue 카테고리 안에 들어가는 식들을 나열하면 : (자세한 내용은 이 링크에)

  • 변수, 함수의 이름, 어떤 타입의 데이터 멤버 (예컨대 std::endl, std::cin) 등등
  • 좌측값 레퍼런스를 리턴하는 함수의 호출식. std::cout << 1 이나 ++it 같은 것들
  • a = b, a += b, a *= b 같은 복합 대입 연산자 식들
  • ++a, --a 같은 전위 증감 연산자 식들
  • a.m, p->m 과 같이 멤버를 참조할 때. 이 때 m 은 enum 값이거나 static 이 아닌 멤버 함수인 경우 제외. (아래 설명 참조)
class A {
  int f();         // static 이 아닌 멤버 함수
  static int g();  // static 인 멤버 함수
}

A a;
a.g;  // <-- lvalue
a.f;  // <-- lvalue 아님 (아래 나올 prvalue)
  • a[n] 과 같은 배열 참조 식들
  • 문자열 리터럴 "hi"

등등이 이싿. 특히 이 lvalue 들은 주소값 연산자 (&) 를 통해 해당 식의 주소값을 알아낼 수 있다. 예를 들어 &++i 나 &std::endl 은 모두 올바른 작업이다. 또한 lvalue 들은 좌측값 레퍼런스를 초기화하는데에 사용할 수 있다.

그렇다면 아래 코드에서 a 는 어떤 값 카테고리를 가질까?

void f(int&& a)
{
	a; // <-- ?
}
f(3);

a 는 우측값 레퍼런스이지만, 식 a 의 경우는 lvalue 이다. 왜냐하면 이름이 있기 때문이다(a 라는 이름)! a 의 타입은 우측값 레퍼런스가 맞지만, 식 a 의 값 카테고리는 lvalue 가 된다. 따라서 아래 식도 잘 컴파일 된다!

#include <iostream>
void f(int&& a) { std::cout << &a << std::endl;}
int main() { f(3); }

 

 

prvalue

아래 코드의 f( ) 를 보자.

int f() { return 10; }
f(); // <-- ?

f( ) 는 실체가 없다. 이는 주소값을 취할 수 없다는 뜻이다. 하지만 f( ) 는 우측값 레퍼런스에 붙을 수 있다. 따라서 f( ) 는 prvalue 이다.

prvalue 로 대표적인 것들은 아래와 같다.

  • 문자열 리터럴을 제외한 모든 리터럴들. 42, true, nullptr 같은 애들
  • 레퍼런스가 아닌 것을 리턴하는 함수의 호출식. 예를 들어서 str.substr(1, 2), str1 + str2
  • 후위 증감 연산자 식. a++, a--
  • 산술 연산자, 논리 연산자 식들. a + b, a && b , a < b 같은 것들을 의미한다. 물론, 이들은 연산자 오버로딩 된 경우들 말고 디폴트로 제공되는 것들을 말한다.
  • 주소값 연산자 식 &a
  • a.m, p->m 과 같이 멤버를 참조할 때. 이 때 m 은 enum 값이거나 static 이 아닌 멤버 함수이어야 함.
  • this
  • enum 값
  • 람다식 [ ]( ) { return 0; }; 와 같은 애들.

등등이 있다.

이 prvalue 들은 정체를 알 수 없는 녀석들이기 때문에 주소값을 취할 수 없다. 따라서 &a++ 이나 &42 와 같은 문장은 모두 오류이다. 또한, prvalue 들은 식의 좌측에 올 수 없다. 하지만 prvalue는 우측값 레퍼런스와 상수 좌측값 레퍼런스를 초기화하는데 사용할 수 있다.

const int& r = 42;
int&& rr = 42;
int& rrr = 42; // <-- 불가능

 

 

xvalue

만약 값 카테고리가 lvalue 와 prvalue 로만 구분된다면, 좌측값으로 분류되는 식을 이동시킬 방법이 없는 문제가 생긴다.  이러한 형태의 값의 카테고리에 들어가는 식들로 가장 크게 우측값 레퍼런스를 리턴하는 함수의 호출식을 들 수 있다. 대표적으로 std::move(x) 가 있다. std::move 함수는 아래와 같이 생겼다.

template <class T>
constexpr typename std::remove_reference<T>::type&& move(T&& t) noexcept;

move 의 리턴 타입은 우측값 레퍼런스이다. 따라서 move 를 호출한 식은 lvalue 처럼 좌측값 레퍼런스를 초기화하는데 사용할 수도 있고, prvalue 처럼 우측값 레퍼런스에 붙이거나 이동 생성자에 전달해서 이동시킬 수 있다.

앞서 decltype 에 대해 설명했던 것을 다시 상기해보자.

  • 만일 식의 값 종류가 xvalue 라면 decltype 은 T&& 가 된다.
  • 만일 식의 값 종류가 lvalue 라면 decltype 은 T& 가 된다.
  • 만일 식의 값 종류가 prvalue 라면 decltype 은 T 가 된다.

아래 코드를 보자.

int a, b;
decltype(a + b) c; // c 의 타입은?

a + b 는 prvalue 이므로, c 의 타입은 그냥 int 가 된다.

 

그렇다면 아래 식은 어떨까?

int a;
decltype((a)) b; // b 의 타입은?

(a) 는 lvalue 이므로, b 는 int& 로 추론된다! ( &(a) 처럼 주소를 취할 수 있음) 만약 괄호가 없으면, b 는 그냥 int 가 된다!

 

 

decltype 의 쓰임새

그렇다면 decltype 은 어디에 쓰일까? 사실 auto 도 비슷한 용도로 쓰일 수 있지 않을까? 아래의 코드를 비교해 보자.

const int i = 4;
auto j = i;			// int j = i;
decltype(i) k = i;	// const int k = i;

int arr[10];
auto arr2 = arr;			// int* arr2 = arr;
decltype(arr) arr3 = arr; 	// int arr3[10];

위처럼, auto 는 가능한 타입을 알아서 추론하는 반면, decltype 은 원하는 타입 그대로를 받는다.

 

또한 템플릿 함수를 사용하는 경우에도 decltype 을 사용할 수 있다.

template <typename T, typename U>
void add(T t, U u, decltype(t + u)* result)
{
	*result = t + u;
}

이걸 다음과 같이 간략화하면 어떨까?

template <typename T, typename U>
decltype(t + u) add(T t, U u)
{
	return t + u;
}

위 코드는 t 와 u 를 보고 이건 뭐지 라는 컴파일 에러를 뱉는다. t 와 u 의 정의가 decltype 보다 나중에 나오기 때문인데, 이 경우 함수의 리턴값을 인자들 정의 부분 뒤에 써야 한다. C++ 14 부터 추가된 아래와 같은 문법으로 구현 가능하다.

template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u)
{
	return t + u;
}

람다 함수와 흡사하게 생겼다. 리턴값은 그냥 auto 로 써놓고, -> 뒤에 실제의 함수의 리턴 타입을 정의해주는 방식이다.

 

 

std::declval

이제 std::declval 함수를 살펴보자. (키워드가 아니라, <utility> 에 정의된 함수임)

다음 코드를 보자.

struct A {
  int f() { return 0; }
};

struct B {
  B(int x) {}
  int f() { return 0; }
};

int main() {
	decltype(A().f()) ret_val;  // int ret_val; 이 된다.
	decltype(B().f()) ret_val;  // B() 는 문법상 틀린 문장 :(
}

클래스 A 는 기본 생성자 ( A( ) ) 가 있으므로 컴파일이 되지만, B 의 경우 B( ) 를 사용할 수가 없다. decltype 안에 들어가는 식은, 컴파일 시에 decltype( ) 전체 식이 타입으로 변환되므로 decltype 안에 있는 식은 런타임 시에 실행되는 것이 아니다.

 

다음과 같은 코드를 보자.

template <typename T>
decltype(T().f()) call_f_and_return(T& t) {
  return t.f();
}
struct A {
  int f() { return 0; }
};
struct B {
  B(int x) {}
  int f() { return 0; }
};

int main() {
  A a;
  B b(1);

  call_f_and_return(a);  // ok
  call_f_and_return(b);  // BAD
}

템플릿을 이용해 decltype 을 사용했지만, 이전의 예시처럼, 생성자 이슈가 있어 컴파일 오류가 발생할 것이다.

이는 std::declval 을 사용하면 깔끔하게 해결할 수 있다.

#include <utility>

template <typename T>
decltype(std::declval<T>().f()) call_f_and_return(T& t) {
  return t.f();
}

즉, T( ) 대신 std::declval<T>( ) 를 써준 것인데, std::declval 에 타입 T 를 전달하면, T 의 생성자를 직접 호출하지 않더라도 T 가 생성된 객체를 나타낼 수 있다.

즉, B 의 경우 기본 생성자가 없음에도 마치 T( ) 를 한 것 같은 효과를 낸 것이다.

참고로, declval 함수는 타입 연산에서만 사용해야지, 런타임에 사용하면 오류가 발생한다. 다음 코드를 보자.

#include <utility>

struct B {
  B(int x) {}
  int f() { return 0; }
};

int main() { B b = std::declval<B>(); } // Error!

 

참고로 C++ 14 부터는 auto 를 붙이면 함수의 리턴 타입을 컴파일러가 알아서 유추해준다.

물론 그렇다고 해서 declval 는 쓰임새가 있다. 다음 글에서 <type_traits> 라이브러리를 다루며 decltype 과 std::declval 을 사용한 TMP 기법들을 배워보자.

 

 
Comments