모두의 코드
씹어먹는 C++ - <16 - 3. 타입을 알려주는 키워드 decltype 와 친구 std::declval>

작성일 : 2019-09-15 이 글은 38366 번 읽혔습니다.

이번 강좌에서는

  • decltype 키워드 설명

  • C++ 의 값 카테고리 - lvalue, prvalue, xvalue

  • std::declval 함수 설명

에 대해서 다룹니다.

안녕하세요 여러분! 지난번 강좌에서 다룬 constexpr 는 잘 써먹고 계신가요? 그 동안 강좌에서 C++ 에서 사용되는 대부분의 키워드를 다루웠던 것 같은데 아직 하나 빠먹은 것이 있습니다. 바로 타입 관련 연산을 사용할 때 요긴하게 쓰이는 decltype 키워드 입니다.

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.ba->b 같은 꼴) 등을 의미합니다. 엄밀한 정의는 여기 에서 볼 수 있는데, 쉽게 생각하면 어떠한 연산을 하지 않고 단순히 객체 하나만을 가리키는 식이라고 보시면 됩니다.

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

  • 만일 식의 값 종류가 xvalue 라면 decltypeT&& 가 됩니다.

  • 만일 식의 값 종류가 lvalue 라면 decltypeT& 가 됩니다.

  • 만일 식의 값 종류가 prvalue 라면 decltypeT 가 됩니다.

잠깐! 지금 위 4 문장에서 너무나 많은 개념들이 나타났습니다. 값의 종류? xvalue? lvalue? prvalue? 얘네들의 정체가 무엇인지 바로 알아보도록 하겠습니다.

Value Category

사람의 경우 이름과 나이라는 정보가 항상 따라다니듯이, 모든 C++ 식(expression)에는 두 가지 정보가 항상 따라다닙니다. 바로 식의 타입값 카테고리(value category) 입니다.

타입은 여태까지 공부하신 분들이라면 잘 알겠을 그 타입이 맞습니다. 하지만 값 카테고리가 뭔지는 약간 생소할 수 도 있는데, 이는 이전에 이야기 하였던 좌측값/우측값을 일컫는 것입니다. 하지만 C++ 에서는 사실 총 5 가지의 값 카테고리가 존재합니다.

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

  • 정체를 알 수 있는가? 정체를 알 수 있다는 말은 해당 식이 어떤 다른 식과 같은 것인지 아닌지를 구분할 수 있다는 말입니다. 일반적인 변수라면 주소값을 취해서 구분할 수 있겠고, 함수의 경우라면 그냥 이름만 확인해보면 될 것입니다.

  • 이동 시킬 수 있는가? 해당 식을 다른 곳으로 안전하게 이동할 수 있는지의 여부를 묻습니다. 즉 해당 식을 받는 이동 생성자, 이동 대입 연산자 등을 사용할 수 있어야만 합니다.

이를 바탕으로 값 카테고리를 구분해보자면 아래 표와 같습니다.

이동 시킬 수 있다

이동 시킬 수 없다

정체를 알 수 있다

xvalue

lvalue

정체를 알 수 없다

prvalue

쓸모 없음!

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

Value Category

위 그림을 보면 어떤 식으로 구분될 수 있는지 이해가 더 잘 될 것입니다.

lvalue

예를 들어서 평범한 int 타입 변수 i 를 생각해봅시다.

int i;
i;

그리고 우리가 i 라는 식을 썼을 때, 이 식의 정체를 알 수 있나요? 어떤 다른 식 j 라는 것이 있을 때 구분할 수 있을까요? 물론 이죠. i 라는 식의 주소값은 실제 변수 i 의 주소값이 될 것입니다. 그렇다면 i 는 이동 가능한가요? 아니죠. int&& x = i; 는 컴파일되지 않습니다. 따라서 ilvalue 입니다.

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

  • 변수, 함수의 이름, 어떤 타입의 데이터 멤버 (예컨대 std::endl, std::cin) 등등

  • 좌측값 레퍼런스를 리턴하는 함수의 호출식. std::cout << 1 이나 ++it 같은 것들

  • a = b, a += b, a *= b 같이 복합 대입 연산자 식들

  • ++a, --a 같은 전위 증감 연산자 식들

  • a.m, p->m 과 같이 멤버를 참조할 때. 이 때 menum 값이거나 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 들은 좌측값 레퍼런스를 초기화 하는데에 사용할 수 있습니다.

그렇다면 한 가지 퀴즈!

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

f(3);

위 코드에서 a 는 무슨 값 카테고리에 들어갈까요? a 는 우측값 레퍼런스기는 하지만, 식 a 의 경우는 lvalue 입니다! 왜냐하면 이름이 있잖아요. 식 a타입 은 우측값 레퍼런스가 맞지만, 식 a값 카테고리lvalue 가 됩니다. 따라서 아래 같은 식들 모두 컴파일 됩니다.

#include <iostream>

void f(int&& a) { std::cout << &a; }
int main() { f(3); }

만약에 a 가 우측값 레퍼런스니까 a 는 우측값일꺼야 라고 생각했다면, 타입과 값 카테고리가 다른 개념이란 사실을 헷갈린 경우가 되겠습니다.

prvalue

int f() { return 10; }

f();  // <-- ?

그렇다면 위 코드의 f() 를 살펴봅시다. 위 식은 어떤 카테고리에 들어갈까요? 먼저 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 과 같이 멤버를 참조할 때. 이 때 menum 값이거나 static 이 아닌 멤버 함수여야함.

  • this

  • enum

  • 람다식 []() { return 0;}; 과 같은 애들.

등등... 여러가지가 있습니다.

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

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

와 같이 됩니다.

xvalue

만일 값 카테고리가 lvalueprvalue 두 개로만 구분된다면 문제가 있습니다. 만일 좌측값으로 분류되는 식을 이동 시킬 방법이 없기 때문입니다. 따라서 우리는 좌측값 처럼 정체가 있지만 이동도 시킬 수 있는 것들을 생각해봐야 합니다.

C++ 에서 이러한 형태의 값의 카테고리에 들어가는 식들로 가장 크게 우측값 레퍼런스를 리턴하는 함수의 호출식 을 들 수 있습니다. 대표적으로 std::move(x) 가 있지요. std::move 함수는 아래와 같이 생겼습니다.

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

다른 복잡한 것들은 모두 건너 뛰더라도 move 의 리턴 타입 만큼은 우측값 레퍼런스 임을 알 수 있습니다. 따라서 move 를 호출한 식은 lvalue 처럼 좌측값 레퍼런스를 초기화 하는데 사용할 수 도 있고, prvalue 처럼 우측값 레퍼런스에 붙이거나 이동 생성자에 전달해서 이동 시킬 수 있습니다.

주의 사항

여기에서 다룬 값 카테고리에 대한 설명은 굉장히 가볍게 짚고 넘어간 것입니다. 좀 더 자세히 알고 싶은 분들은 언제나 그렇듯이 훌륭한 정보를 제공해주는 cppreferenceC++ 표준 을 참조하시기를 바라겠습니다.

자, 그렇다면 이제 다시 decltype 에 대한 설명으로 돌아와보겠습니다. 앞서 말했듯이 decltype 에 식별자 표현식이 아닌 식이 전달된다면, 식의 타입이 T 라고 할 때 아래와 같은 방식으로 타입을 리턴한다고 하였습니다.

  • 만일 식의 값 종류가 xvalue 라면 decltypeT&& 가 됩니다.

  • 만일 식의 값 종류가 lvalue 라면 decltypeT& 가 됩니다.

  • 만일 식의 값 종류가 prvalue 라면 decltypeT 가 됩니다.

그렇다면 예를 들어서 아래의 코드를 살펴봅시다.

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

위에서 본 바에 따르면 a + bprvalue 이므로 a + b 식의 실제 타입인 int 로 추론됩니다. 따라서 위 식은 그냥 int c; 를 한 것 과 똑같게 되겠지요.

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

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

일단 (a) 는 식별자 표현식이 아니기 때문에 어느 값 카테고리에 들어가는지 생각해봐야 합니다. 쉽게 생각하면 &(a) 와 같이 주소값 연산자를 적용할 수 있고, 당연히도 이동 불가능 이므로 lvalue 가 됩니다. 따라서 bint 가 될 것이라는 예상과는 다르게 int& 로 추론됩니다!

이는 C++ 에서 괄호의 유무로 인해 무언가 결과가 달라지는 첫 번째 경우가 아닐까 싶네요.

decltype 의 쓰임새

그렇다면 decltype 는 도대체 왜 쓰이는 것일까요? 타입 추론이 필요한 부분에는 그냥 auto 로도 충분하지 않을까요? 예를 들어서

int i = 4;
auto j = i;  // int j = i;

를 할 때나

int i = 4;
decltype(i) j = i;  // int j = i;

는 같기 때문이지요. 하지만 auto 는 엄밀히 말하자면 정확한 타입을 표현하지 않습니다. 예를 들어서

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

auto 의 경우 const 를 띄어버리지만, decltype 의 경우 이를 그대로 보존합니다. 그 외에도 배열의 경우 auto 는 암시적으로 포인터로 변환하지만, decltype 의 경우 배열 타입 그대로를 전달할 수 있습니다. 예컨대

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

이 되겠지요. 즉 decltype 를 이용하면 타입 그대로 정확하게 전달할 수 있습니다.

물론 이것 뿐만이 아닙니다. 템플릿 함수에서 어떤 객체의 타입이 템플릿 인자들에 의해서 결정되는 경우가 있습니다. 예를 들어서 아래와 같은 함수를 생각해봅시다.

template <typename T, typename U>
void add(T t, U u, /* 무슨 타입이 와야 할까요? */ result) {
  *result = t + u;
}

add 함수는 단순히 tu 를 더해서 result 에 저장하는 함수 입니다. 문제는 이 result 의 타입이 t + u 의 결과에 의해 결정된다는 사실 입니다. 예를 들어서 tdouble 이고 us 가 int 라면 result 의 타입은 double* 이 되겠지요.

따라서 이런 경우에 result 에 타입이 올 자리에 decltype 를 사용해주면 됩니다.

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

와 같이 말이지요.

그렇다면 위 함수를 살짝 바꿔서 result 에 전달하는 대신에 그냥 더한 값을 리턴해버리는 함수를 만들어보면 어떨까요? 만약에 그냥

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

위와 같이 한다면 한 가지 문제가 있습니다. 위 식을 컴파일 한다면 아래와 같이 오류가 발생할 것입니다.

컴파일 오류

test2.cc:3:10: error: ‘t’ was not declared in this scope
 decltype(t + u) add(T t, U, u) {
          ^
test2.cc:3:10: error: ‘t’ was not declared in this scope
test2.cc:3:14: error: ‘u’ was not declared in this scope
 decltype(t + u) add(T t, U, u) {

컴파일러가 위 식을 컴파일 할 때 decltype 안의 tu 를 보고 아니 이건 뭐지 한 것입니다. tu 의 정의가 decltype 나중에 나오기 때문이지요. 이 경우 함수의 리턴값을 인자들 정의 부분 뒤에 써야 합니다. 이는 C++ 14 부터 추가된 아래와 같은 문법으로 구현 가능합니다.

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

바로 리턴값 자리에는 그냥 auto 라고 써놓고, -> 뒤에 함수의 실제 리턴 타입을 지정해주는 것입니다. 이는 람다 함수 문법과 매우 유사합니다.

std::declval

다음으로 살펴볼 것은 decltype 처럼 C++ 11 에서 새로 추가된 std::declval 함수 입니다. declvaldecltype 과는 다르게 키워드가 아닌 <utilty> 에 정의된 함수 입니다.

예를 들어서 어떤 타입 Tf 라는 함수의 리턴 타입을 정의하고 싶다고 해봅시다. 그렇다면 decltype 를 이용하면 아래와 같은 코드를 작성할 수 있을 것입니다.

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

decltype(A().f()) ret_val;  // int ret_val; 이 된다.

참고로 위 과정에서 실제로 A 의 객체가 생성되거나 함수 f 가 호출되거나 그러지는 않습니다. decltype 안에 들어가는 식은, 그냥 식의 형태로만 존재할 뿐 컴파일 시에, decltype() 전체 식이 타입으로 변환되기 때문에 decltype 안에 있는 식은 런타임 시에 실행되는 것이 아닙니다.

물론 그렇다고 해서 decltype 안에 문법상 틀린 식을 전달할 수 있는 것은 아닙니다. 예를 들어서 어떤 클래스에서 디폴트 생성자가 없다고 해봅시다.

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

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

컴파일 한다면 아래와 같은 오류가 발생합니다.

컴파일 오류

test2.cc: In function ‘int main()’:
test2.cc:8:14: error: no matching function for call to ‘B::B()’
   decltype(B().f()) ret_val; // B() 는 문법상 틀린 문장 :(
              ^
test2.cc:3:3: note: candidate: B::B(int)
   B(int x) {}
   ^
test2.cc:3:3: note:   candidate expects 1 argument, 0 provided

그야 이유는 단순합니다.

B()

B() 에 해당하는 생성자가 존재하지 않는 다는 것이지요. 우리는 그냥 B 의 멤버 함수 f 의 타입 참조만 필요할 뿐인데, 실제 B 객체를 생성할 것도 아닌데도 B 의 생성자 규칙에 맞는 코드를 작성해야합니다.

물론 우리는 그냥 B(1) 과 같이 쓰면 된다는 것을 알고 있습니다. 그런데, 아래와 같은 상황을 생각해보세요.

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

위 함수는 어떤 임의의 타입 T 의 객체를 받아서 해당 객체의 멤버함수 f 를 호출해주는 함수 입니다. 이 함수를 이용하는 객체들에 멤버 함수 f 가 정의되어 있다고 가정한다면, 모두 이용할 수 있습니다.

문제는 모든 타입 T 들이 디폴트 생성자 T() 를 정의하고 있지 않을 수도 있다는 말입니다.

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
}

컴파일 하였다면

컴파일 오류

test2.cc: In function ‘int main()’:
test2.cc:18:22: error: no matching function for call to ‘call_f_and_return(B&)’
   call_f_and_return(b); // BAD
                      ^
test2.cc:2:19: note: candidate: template<class T> decltype (T().f()) call_f_and_return(T&)
 decltype(T().f()) call_f_and_return(T& t) {
                   ^~~~~~~~~~~~~~~~~
test2.cc:2:19: note:   template argument deduction/substitution failed:
test2.cc: In substitution of ‘template<class T> decltype (T().f()) call_f_and_return(T&) [with T = B]’:
test2.cc:18:22:   required from here
test2.cc:2:10: error: no matching function for call to ‘B::B()’
 decltype(T().f()) call_f_and_return(T& t) {
          ^~~

와 같이 오류가 발생합니다.

위 경우 call_f_and_return 함수에 a 를 전달했을 때에는 A 에 디폴트 생성자가 있으므로 잘 컴파일 되지만, b 를 전달할 때에는 B 에 디폴트 생성자가 없으므로 오류가 발생하게 됩니다.

따라서 위 처럼 직접 생성자를 사용하는 방식은 전달되는 타입들의 생성자가 모두 같은 꼴이지 않을 경우 문제가 생깁니다.

이 문제는 std::declval 를 사용하면 깔끔하게 해결할 수 있습니다.

#include <utility>

template <typename T>
decltype(std::declval<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);  // ok
}

위 코드는 잘 컴파일 됩니다.

std::declval 에 타입 T 를 전달하면, T 의 생성자를 직접 호출하지 않더라도 T 가 생성된 객체를 나타낼 수 있습니다. 즉,

std::declval<T>()

를 통해 심지어 T 에 생성자가 존재하지 않더라도 마치 T() 를 한 것과 같은 효과를 낼 수 있지요. 따라서 앞서 발생하였던 생성자의 형태가 모두 달라서 발생하는 오류를 막을 수 있습니다.

참고로 declval 함수를 타입 연산에서만 사용해야지, 실제로 런타임에 사용하면 오류가 발생합니다.

#include <utility>

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

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

컴파일 하였다면

컴파일 오류

/usr/include/c++/7/type_traits: In instantiation of ‘typename std::add_rvalue_reference< <template-parameter-1-1> >::type std::declval() [with _Tp = B; typename std::add_rvalue_reference< <template-parameter-1-1> >::type = B&&]’:
test2.cc:9:25:   required from here
/usr/include/c++/7/type_traits:2256:7: error: static assertion failed: declval() must not be used!
       static_assert(__declval_protector<_Tp>::__stop,
       ^~~~~~~~~~~~~

와 같이 오류가 발생합니다.

참고로 C++ 14 부터는 함수의 리턴 타입을 컴파일러가 알아서 유추해주는 기능이 추가되었습니다. 이 경우 그냥 함수 리턴 타입을 auto 로 지정해주면 됩니다.

template <typename T>
auto call_f_and_return(T& t) {
  return t.f();
}

따라서 위 처럼 간단하게 쓸 수 있습니다.

물론 그렇다고 해서 declval 의 쓰임새가 없어지냐? 아닙니다. 다음 강좌에서 <type_traits> 라이브러리를 다루면서 decltypestd::declval 을 사용한 놀라운 템플릿 메타프로그래밍 기법들을 소개하고자 합니다.

그럼 이번 강좌는 여기에서 마치도록 하겠습니다.

뭘 배웠지?

decltype 키워드를 통해서 우리가 원하는 식의 타입을 알 수 있습니다. 만일 해당 식이 단순한 식별자 표현식 (identifier expression) 이라면 그냥 그 식의 타입으로 치환됩니다. 그 이외의 경우라면 해당 식의 값 카테고리가 뭐냐에 따라서 decltype 의 타입이 정해집니다. C++ 의 모든 식에는 두 가지 꼬리표가 따라다니는데 하나는 타입이고, 다른 하나는 값 카테고리 입니다. 값 카테고리는 크게 3 가지 종류로 lvalue, prvalue, xvalue 가 있습니다. std::declval 함수를 사용해서 원하는 타입의 생성자 호출을 우회해서 멤버 함수의 타입에 접근할 수 있습니다.

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

현재 여러분이 보신 강좌는 <씹어먹는 C++ - <16 - 3. 타입을 알려주는 키워드 decltype 와 친구 std::declval>> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 17 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

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