Decltype & Declval

Date:     Updated:

카테고리:

태그:

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


decltype

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

decltype 키워드는 c++ 11에 추가된 키워드로, 일종의 함수 처럼 사용된다. 실제 작동 방식은 함수와 약간 다른데, 타입을 알고자 하는 식의 타입으로 치환되게 된다.

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 or a->b)

그렇다면 decltype에 식별자 표현식이 아닌 식을 전달하면 어떻게 될까? 이런 경우 해당 식의 값의 종류(value category) 에 따라 결과가 달라진다.

Value Category

모든 c++ 식에는 두 가지 정보가 항상 따라다닌다. 바로 식의 타입값 카테고리 이다.

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

[1] 정체를 알 수 있는가? 정체를 알 수 있다는 말은 해당 식이 어떤 다른 식과 같은 것인지 아닌지를 구분할 수 있다는 말이다. 일반적인 변수라면 주소값을 취해 구분할 수 있고, 함수의 경우라면 그냥 이름을 비교하면 된다.

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

![[valueCategory.png 500]]
  이동 시킬 수 있음 이동 시킬 수 없음
정체를 알 수 있음 xvalue lvalue
정체를 알 수 없음 prvalue 쓸모 없음!
  • glvalue (generalized lvalue) : 정체를 알 수 있는 모든 식
  • rvalue : 이동 시킬 수 있는 모든 식
  • lvalue : 정체를 알 수 있음 & 이동 시킬 수 없음
  • prvalue (pure rvalue) : 정체를 알 수 없음 & 이동 시킬 수 있음
  • xvalue (expiring value) : 정체를 알 수 있음 & 이동 시킬 수 있음

앞서 말했듯이 decltype에 식별자 표현식이 아닌 식이 전달된다면, 식의 타입이 T라고 할 때

  • 만일 식의 값 카테고리가 xvalue라면 decltypeT&&
  • 만일 식의 값 카테고리가 lvalue라면 decltypeT&
  • 만일 식의 값 카테고리가 prvalue라면 decltypeT

Example

[1] lvalue

int i;
i;

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

  • i의 정체를 알 수 있나? → 가능. &i
  • i는 이동 가능한가? → 불가능. int&& x = i 는 컴파일 오류

이름을 가진 대부분의 객체들은 lvalue이다. 왜냐하면 해당 객체의 주소값을 취할 수 있기 때문이다. 그 외에도

  • 변수, 함수의 이름
  • 어떤 타입의 데이터 멤버 (std::endl, std::cin 등등..)
  • 좌측값 레퍼런스를 리턴하는 함수의 호출식 (std::cout << 1 이나 ++it 등등)
  • a=b, a += b, a *= b 와 같은 복합 대입 연산자 식들
  • ++a, --a 전위 증감 연산자 식들
  • a.m, p->m 과 같이 멤버를 참조할 떄
    • 이 때 menum 값이 아니고, 멤버 함수일 경우 static 함수만 해당
  • a[n]과 같은 배열 참조 식들
  • 문자열 리터럴 "hi"

등등이 있다. 특히 lvalue 들은 &++i&std::endl 처럼 주소값 연산자& 를 통해 해당 식의 주소값을 알아 낼 수 있다. 또한 lvalue들은 좌측값 레퍼런스를 초기화 하는데에도 사용된다.

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

f(3);

위 코드의 경우 a 의 타입은 우측값 레퍼런스이기는 하지만, a 의 값 카테고리는 lvalue이다. 왜냐하면 이름이 있기 떄문이다.

[2] prvalue

int f() { return 10; }

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
  • 람다식 ([] () { })

등등이 있다.

[3] xvalue

c++에서 xvalue 값 카테고리에 들어가는 식 중 가장 대표적으로 우측값 레퍼런스를 리턴하는 함수의 호출식 을 들 수 있다. std::move와 같이 말이다.

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

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

정리

  • 만일 식의 값 카테고리가 xvalue라면 decltypeT&&
  • 만일 식의 값 카테고리가 lvalue라면 decltypeT&
  • 만일 식의 값 카테고리가 prvalue라면 decltypeT
int a, b;
decltype(a + b) c;  // c 의 타입은?

a+bprvalue 이므로 a+b의 실제 타입인 int로 추론.

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

일단 (a) 는 식별자 표현식이 아니기 때문에 값 카테고리를 생각해봐야 한다. &(a) 와 같이 주소값 연산자를 적용할 수 있고, 이동이 불가능 하므로 lvalue가 된다. 따라서 bint&로 추론된다.

decltype 쓰임새

지금까지의 내용을 생각하면 타입 추론이 필요한 경우 그냥 auto를 쓰면 되지 왜 decltype을 써야 하는지 의문일 것이다.

하지만 엄밀히 말하자면 auto는 정확한 타입을 표현하지 않는다. 예를 들어

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

auto의 경우 const를 마음대로 띄어 버리지만, decltype의 경우 이를 그대로 보존한다. 그 외에도

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

배열의 경우 auto는 암시적으로 포인터로 변환하지만, decltype은 배열 타입 그대로를 전달할 수 있다.

decltype이 가장 빛을 바라는 곳은 바로 템플릿 함수이다. 템플릿 함수에서 어떤 객체의 타입이 템플릿 인자들에 의해 결정되는 경우가 있다. 예를 들어

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

add 함수는 단순히 tu를 더해 result에 저장하는 함수이다. 문제는 result의 타입이 t+u에 의해 결정된다는 것이다. 이런 경우 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;
}

[!error]

컴파일러가 위 식을 컴파일 할 때 tu를 해석할 수 없기 때문에 컴파일 에러가 발생한다. 이 경우 함수의 리턴 값을 인자들 뒷 부분에 써야 하는데, c++ 14부터 추가된 문법으로 구현이 가능하다.

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

리턴 타입 자리에는 auto 라 써놓고 -> 뒤에 실제 리턴 타입을 지정하면 된다. (람다와 아주 유사)

std::declval

declvaldecltype과는 다르게 키워드가 아닌 <utility>에 정의된 함수이다.

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

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

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

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

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

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

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

[!error]

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의 생성자 규칙을 모른다는 것이다.

이러한 문제는 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가 생성된 객체를 나타낼 수 있다. 심지어 T에 생성자가 존재하지 않더라도 마치 T() 를 한 것과 같은 효과를 낼 수 있다.

참고로 std::declval 함수를 런타임에 사용하면 오류가 발생한다.

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

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

[!error]

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

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

물론 그렇다고 해서 declval의 쓰임새가 없어진 것은 아니다! 바로 다음에 나올 type_traits에 다시 등장한다.



맨 위로 이동하기

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

댓글 남기기