모두의 코드 내용을 공부하고 정리한 내용입니다.


decltype

  • C++11부터 decltype에 전달된 식이 괄호로 둘러쌓이지 않은 식별자 표현식(id-expression) 이라면 해당 식의 타입을 반환하는 함수를 제공한다.
  • 식별자 표현식이란 변수의 이름, 함수의 이름, enum 이름, 클래스 멤버 수(a.b 나 a->b같은 꼴) 등을 의미한다.
    • 쉽게 생각하면 어떠한 연산을 하지 않고 단순히 객체 하나만을 가리키는 식
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에 식별자 표현식이 아닌 식을 전달하면 어떻게 될까??
  • 해당 식의 값의 종류(value category)에 다르다
    • 만일 식의 값 종류가 xvalue라면 decltypeT&&
    • 만일 식의 값 종류가 lvalue라면 decltypeT&
    • 만일 식의 값 종류가 prvalue라면 decltypeT


Value Category

  • 모든 C++ 식(expression)에는 두 가지 정보가 있는데, 바로 식의 타입값 카테고리(value category) 이다.
  • 식의 타입은 알겠는데 값의 카테고리는 무엇인가?
    • 좌측값과 우측값을 일컫는 말인데 사실 C++에는 5가지의 값 카테고리가 존재한다.


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


  이동 시킬 수 있다 이동 시킬 수 없다
정체를 알 수 있다 xvalue(eXpiring value) lvalue
정체를 알 수 없다 prvalue(pure rvalue) 쓸모 없음!


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


lvalue

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이 아닌 멤버 함수인 경우 제외.
    • a[n] 과 같은 배열 참조 식들
    • 문자열 리터럴 "hi"
  • lvalue 들은 주소값 연산자 &를 통해 해당 식의 주소값을 알아 낼 수 있고 lvalue들은 좌측값 레퍼런스를 초기화 하는데에 사용할 수 있다.


void f(int&& a) {
  a;
}

f(3);
  • 위 코드에서 a 는 무슨 값 카테고리일까?
    • a 는 우측값 레퍼런스기는 하지만, 식 a 의 경우는 lvalue 이다!
  • 왜냐하면 이름이 있기 때문이다.
    • a 의 타입 은 우측값 레퍼런스가 맞지만, 식 a의 값 카테고리는 lvalue가 된다.
    • 따라서 아래 같은 식들 모두 컴파일 됩니다.
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는 우측값 레퍼런스와 상수 좌측값 레퍼런스를 초기화 하는데 사용할 수 있다.


xvalue

  • 만일 값 카테고리가 lvalueprvalue 두 개로만 구분된다면 문제가 있다.
  • 만일 좌측값으로 분류되는 식을 이동 시킬 방법이 없기 때문이다.
    • 따라서 좌측값 처럼 정체가 있지만 이동도 시킬 수 있는 것들을 생각해봐야 한다.
  • C++ 에서 이러한 형태의 값의 카테고리에 들어가는 식들로 가장 크게 우측값 레퍼런스를 리턴하는 함수의 호출식 을 들 수 있습니다.
  • 대표적으로 std::move(x) 가 있다.

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


  • 다시 본론으로
int a, b;
decltype(a + b) c; // c 의 타입은?
  • a + bprvalue이므로 a + b 식의 실제 타입인 int로 추론


int a;
decltype((a)) b; // b 의 타입은?
  • 우선 (a) 는 식별자 표현식이 아니다
  • 쉽게 생각하면 &(a) 와 같이 주소값 연산자를 적용할 수 있고, 당연히도 이동 불가능 이므로 lvalue되므로
  • int&로 추론된다.


  • 근데 auto가 있는데 굳이 사용해야 하나?
  • 사실 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;
}
  • result의 타입이 t+u 결과에 의해 결정된다
  • 여기에서 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;
}            // 컴파일 오류!!
  • 컴파일러가 decltype에 있는 t와 u를 알지 못한다.
    • tu가 정의된 후에 사용해야 하기 때문이다.
  • 하지만 C++ 14 부터 추가된 아래와 같은 문법으로 구현 가능
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) { return t + u; }
  • 람다 함수와 비슷하게 생겼는데 리턴 타입에는 auto를 쓰고 -> 뒤에 실제 리턴 타입을 기입하면 된다.


std::declval

  • C++ 11 에서 새로 추가된 std::declval 함수 <utilty> 헤더 파일에 정의됨

  • 만약 어떤 클래스의 멤버 함수의 리턴값을 알고싶어 decltype을 사용했다고 생각하자.
  • 그런데 해당 함수에 생성자가 없으면 컴파일 오류가 발생할 것이다.
    • decltype(A().func()) what_type; 에서 A() 생성자가 없으면 컴파일 오류 발생
    • 참고로 decltype안에 들어가는 식은, 그냥 식의 형태로만 존재할 뿐 컴파일 시에, decltype() 전체 식이 타입으로 변환되기 때문에 decltype안에 있는 식은 런타임 시에 실행되는 것이 아니다.
    • 그렇다고 해서 decltype안에 문법상 틀린 식을 전달할 수 있는 것은 아니기 때문에 위에서 생성자가 없으면 컴파일 오류가 발생하는 것이다.


  • std::declval을 이용하면 해결이 된다.
  • std::declval에 타입 T를 전달하면, T의 생성자를 직접 호출하지 않더라도 T가 생성된 객체를 나타낼 수 있다.
  • 그리고 T타입에 생성자가 존재하지 않아도 T()를 한 것 같은 효과를 낸다.
  • declval 함수를 타입 연산에서만 사용해야지, 실제로 런타임에 사용하면 오류가 발생
struct B {
  B(int x) {}
  int f() { return 0; }
};

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


  • 참고로 C++ 14 부터는 함수의 리턴 타입을 컴파일러가 알아서 유추해주는 기능이 추가되었다.
  • 이 경우 그냥 함수 리턴 타입을 auto 로 지정해주면 된다
template <typename T>
auto call_f_and_return(T& t) {
	return t.f();
}


Value categories

개인 공부 기록용 블로그입니다.
틀린 부분 있으다면 지적해주시면 감사하겠습니다!!

카테고리:

업데이트:

댓글남기기