decltype 과 Value Category 그리고 std::declval
모두의 코드 내용을 공부하고 정리한 내용입니다.
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
라면decltype
는T&&
- 만일 식의 값 종류가
lvalue
라면decltype
는T&
- 만일 식의 값 종류가
prvalue
라면decltype
는T
- 만일 식의 값 종류가
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;
는 컴파일되지 않는다. - 따라서
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
이 아닌 멤버 함수인 경우 제외.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
과 같이 멤버를 참조할 때. 이 때m
은enum
값이거나static
이 아닌 멤버 함수여야함.this
enum
값- 람다식
[]() { return 0;};
과 같은 애들. 등등…
- 문자열 리터럴을 제외 한 모든 리터럴들.
- 이
prvalue
들은 정체를 알 수 없는 녀석들 이기 때문에 주소값을 취할 수 없다. - 따라서
&a++
이나&42
와 같은 문장은 모두 오류이다. - 또한,
prvalue
들은 식의 좌측에 올 수 없지만,prvalue
는 우측값 레퍼런스와 상수 좌측값 레퍼런스를 초기화 하는데 사용할 수 있다.
xvalue
- 만일 값 카테고리가
lvalue
와prvalue
두 개로만 구분된다면 문제가 있다. - 만일 좌측값으로 분류되는 식을 이동 시킬 방법이 없기 때문이다.
- 따라서 좌측값 처럼 정체가 있지만 이동도 시킬 수 있는 것들을 생각해봐야 한다.
- C++ 에서 이러한 형태의 값의 카테고리에 들어가는 식들로 가장 크게 우측값 레퍼런스를 리턴하는 함수의 호출식 을 들 수 있습니다.
-
대표적으로
std::move(x)
가 있다. - 따라서
move
를 호출한 식은lvalue
처럼 좌측값 레퍼런스를 초기화 하는데 사용할 수 도 있고,prvalue
처럼 우측값 레퍼런스에 붙이거나 이동 생성자에 전달해서 이동 시킬 수 있습니다.
- 다시 본론으로
int a, b;
decltype(a + b) c; // c 의 타입은?
a + b
는prvalue
이므로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를 알지 못한다.t
와u
가 정의된 후에 사용해야 하기 때문이다.
- 하지만 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();
}
개인 공부 기록용 블로그입니다.
틀린 부분 있으다면 지적해주시면 감사하겠습니다!!
댓글남기기