Decltype & Declval
카테고리: Cpp
태그: Programming
모두의 코드 씹어먹는 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
ora->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라면
decltype
은T&&
- 만일 식의 값 카테고리가 lvalue라면
decltype
은T&
- 만일 식의 값 카테고리가 prvalue라면
decltype
은T
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
과 같이 멤버를 참조할 떄- 이 때
m
은enum
값이 아니고, 멤버 함수일 경우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
과 같이 멤버를 참조할 떄- 이 때
m
은enum
값이거나, 멤버 함수일 경우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라면
decltype
은T&&
- 만일 식의 값 카테고리가 lvalue라면
decltype
은T&
- 만일 식의 값 카테고리가 prvalue라면
decltype
은T
int a, b;
decltype(a + b) c; // c 의 타입은?
a+b
는 prvalue
이므로 a+b
의 실제 타입인 int
로 추론.
int a;
decltype((a)) b; // b 의 타입은?
일단 (a)
는 식별자 표현식이 아니기 때문에 값 카테고리를 생각해봐야 한다. &(a)
와 같이 주소값 연산자를 적용할 수 있고, 이동이 불가능 하므로 lvalue
가 된다. 따라서 b
는 int&
로 추론된다.
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
함수는 단순히 t
와 u
를 더해 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]
컴파일러가 위 식을 컴파일 할 때 t
와 u
를 해석할 수 없기 때문에 컴파일 에러가 발생한다. 이 경우 함수의 리턴 값을 인자들 뒷 부분에 써야 하는데, c++ 14
부터 추가된 문법으로 구현이 가능하다.
template <typename T, typename U>
auto add(T t, U u) -> decltype(t + u) {
return t + u;
}
리턴 타입 자리에는 auto
라 써놓고 ->
뒤에 실제 리턴 타입을 지정하면 된다. (람다와 아주 유사)
std::declval
declval
은 decltype
과는 다르게 키워드가 아닌 <utility>
에 정의된 함수이다.
예를 들어 어떤 타입 T
의 f
라는 함수의 리턴 타입을 정의하고 싶다고 해보자. 그렇다면 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
에 다시 등장한다.
댓글 남기기