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.bora->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의 정체를 알 수 있나? → 가능.&ii는 이동 가능한가? → 불가능.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에 다시 등장한다.
댓글 남기기